From a80ff2633f2c1b2e5e50397385e6e11268dcafc6 Mon Sep 17 00:00:00 2001 From: Kris Gardiner Date: Tue, 4 Jun 2024 14:40:08 -0400 Subject: [PATCH] fix: email component crashes on big attachments --- commons/src/connections/files.ts | 33 +++++++++++++++- commons/src/methods/api.ts | 20 ++++++++++ commons/src/methods/component.ts | 17 +++++++-- commons/src/store/files.ts | 24 +++++++++--- commons/src/types/Nylas.ts | 2 +- components/email/src/Email.svelte | 62 +++++++++++++++++++++++++------ components/email/yarn.lock | 20 +++++----- 7 files changed, 145 insertions(+), 33 deletions(-) diff --git a/commons/src/connections/files.ts b/commons/src/connections/files.ts index dfff00e9..7b8bcf81 100644 --- a/commons/src/connections/files.ts +++ b/commons/src/connections/files.ts @@ -3,11 +3,12 @@ import { handleError, handleResponse, getMiddlewareApiUrl, + getDashboardApiUrl, } from "../methods/api"; import type { FileQuery, MiddlewareResponse } from "@commons/types/Nylas"; export const downloadFile = async (query: FileQuery): Promise => { - let queryString = `${getMiddlewareApiUrl(query.component_id)}/files/${ + const queryString = `${getMiddlewareApiUrl(query.component_id)}/files/${ query.file_id }/download`; @@ -16,3 +17,33 @@ export const downloadFile = async (query: FileQuery): Promise => { .then((json) => json.response) .catch((error) => handleError(query.component_id, error)); }; + +export const streamDownloadFile = async ({ + file_id, + component_id, + access_token, +}: { + [key: string]: string; +}): Promise => { + const baseUrl = getDashboardApiUrl(component_id); + const url = `${baseUrl}/components/files/${file_id}/download`; + + const response = await fetch(url, { + // replace with your actual download endpoint + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Component-Id": component_id || "", // Component ID is passed as header + "X-Access-Token": access_token || "", // Access Token is passed as header + Authorization: `Bearer ${access_token || ""}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + + return blob; +}; diff --git a/commons/src/methods/api.ts b/commons/src/methods/api.ts index 6588b9c1..687eaf4e 100644 --- a/commons/src/methods/api.ts +++ b/commons/src/methods/api.ts @@ -72,6 +72,26 @@ export function getMiddlewareApiUrl(id: string): string { return API_GATEWAY; } +export function getDashboardApiUrl(id: string): string { + if (process.env.NODE_ENV === "development") { + return `http://localhost:4000`; + } + + let region = ""; + if (id.substring(3, 4) === "-") { + const code = id.substring(0, 3); + if (typeof REGION_MAPPING[code] !== "undefined") { + region = REGION_MAPPING[code]; + } + } + + const baseUrl = region.includes("ireland") + ? "https://dashboard-api-gateway.eu.nylas.com" + : "https://dashboard-api-gateway.us.nylas.com"; + + return baseUrl; +} + export function silence(error: Error) {} export function buildQueryParams(params: Record): string { diff --git a/commons/src/methods/component.ts b/commons/src/methods/component.ts index 272aba10..31808544 100644 --- a/commons/src/methods/component.ts +++ b/commons/src/methods/component.ts @@ -120,10 +120,19 @@ export default function parseStringToArray(parseStr: string): string[] { return [parseStr.trim()]; } -export function downloadAttachedFile(fileData: string, file: File): void { - const buffer = Uint8Array.from(atob(fileData), (c) => c.charCodeAt(0)); - const blob = new Blob([buffer], { type: file.content_type }); - const blobFile = window.URL.createObjectURL(blob); +export function downloadAttachedFile( + fileData: string | Blob, + file: File, +): void { + let blobFile; + + if (typeof fileData === "string") { + const buffer = Uint8Array.from(atob(fileData), (c) => c.charCodeAt(0)); + const blob = new Blob([buffer], { type: file.content_type }); + blobFile = window.URL.createObjectURL(blob); + } else if (typeof fileData !== "string") { + blobFile = window.URL.createObjectURL(fileData); + } const a = document.createElement("a"); a.href = blobFile; diff --git a/commons/src/store/files.ts b/commons/src/store/files.ts index 6180833d..6a715dfa 100644 --- a/commons/src/store/files.ts +++ b/commons/src/store/files.ts @@ -1,6 +1,6 @@ import { writable } from "svelte/store"; import type { File, Message } from "@commons/types/Nylas"; -import { downloadFile } from "@commons/connections/files"; +import { downloadFile, streamDownloadFile } from "@commons/connections/files"; import { InlineImageTypes } from "@commons/constants/attachment-content-types"; function initializeFilesForMessage() { const { subscribe, set, update } = writable< @@ -25,14 +25,26 @@ function initializeFilesForMessage() { !inlineFiles[file.id] ) { inlineFiles[file.id] = file; - inlineFiles[file.id].data = await downloadFile({ - file_id: file.id, - component_id: query.component_id, - access_token: query.access_token, - }); + + if (file.size > 4194304) { + const blob = await streamDownloadFile({ + file_id: file.id, + component_id: query.component_id, + access_token: query.access_token, + }); + + inlineFiles[file.id].data = blob; + } else { + inlineFiles[file.id].data = await downloadFile({ + file_id: file.id, + component_id: query.component_id, + access_token: query.access_token, + }); + } } } filesMap[incomingMessage.id] = inlineFiles; + update((files) => { files[incomingMessage.id] = inlineFiles; return { ...files }; diff --git a/commons/src/types/Nylas.ts b/commons/src/types/Nylas.ts index 747df0f9..0498c4f1 100644 --- a/commons/src/types/Nylas.ts +++ b/commons/src/types/Nylas.ts @@ -148,7 +148,7 @@ export interface File { size: number; content_disposition: string; content_id?: string; - data?: string; + data?: string | Blob; } export interface MiddlewareResponse { diff --git a/components/email/src/Email.svelte b/components/email/src/Email.svelte index 608239e5..7e9c6e06 100644 --- a/components/email/src/Email.svelte +++ b/components/email/src/Email.svelte @@ -56,7 +56,7 @@ import { FolderStore } from "@commons/store/folders"; import * as DOMPurify from "dompurify"; import LoadingIcon from "./assets/loading.svg"; - import { downloadFile } from "@commons/connections/files"; + import { streamDownloadFile } from "@commons/connections/files"; import ReplyIcon from "./assets/reply.svg"; import ReplyAllIcon from "./assets/reply-all.svg"; import ForwardIcon from "./assets/forward.svg"; @@ -91,6 +91,7 @@ export let you: Partial; export let show_reply: boolean; export let show_reply_all: boolean; + export let show_forward: boolean; const defaultValueMap: Partial = { @@ -241,7 +242,7 @@ } let main: Element; - let messageRefs: Element[] = []; + let messageRefs: HTMLElement[] = []; const MAX_DESKTOP_PARTICIPANTS = 2; const MAX_MOBILE_PARTICIPANTS = 1; @@ -478,7 +479,7 @@ * individual messages to trash folder as a workaround **/ if (query.component_id && _this.thread_id) { - activeThread.messages.forEach(async (message, i) => { + activeThread.messages.forEach(async (message) => { await updateMessage( query.component_id, { ...message, folder_id: trashFolderID }, @@ -704,14 +705,18 @@ } } - function fetchIndividualMessage(messageID: string): Promise { + async function fetchIndividualMessage( + messageID: string, + ): Promise { if (id) { return fetchMessage(query, messageID).then(async (json) => { if (FilesStore.hasInlineFiles(json)) { const messageWithInlineFiles = await getMessageWithInlineFiles(json); + dispatchEvent("messageLoaded", messageWithInlineFiles); return messageWithInlineFiles; } + dispatchEvent("messageLoaded", json); return json; }); @@ -898,6 +903,7 @@ function initializeAttachedFiles() { const messageType = getMessageType(activeThread); + attachedFiles = activeThread[messageType]?.reduce( (files: Record, message) => { for (const [fileIndex, file] of message.files.entries()) { @@ -905,6 +911,7 @@ if (!files[message.id]) { files[message.id] = []; } + files[message.id] = [ ...files[message.id], message.files[fileIndex], @@ -923,11 +930,31 @@ access_token, }); for (const file of Object.values(fetchedFiles)) { - if (message.body) { - message.body = message.body?.replaceAll( - `src="cid:${file.content_id}"`, - `src="data:${file.content_type};base64,${file.data}"`, - ); + let dataUrl: string | null = null; + + if (typeof file.data !== "string") { + const reader = new FileReader(); + reader.onload = function (event) { + dataUrl = event.target.result as string; + }; + reader.onloadend = function () { + const rawData = dataUrl.split("base64,")[1]; + + if (message.body) { + message.body = message.body?.replaceAll( + `src="cid:${file.content_id}"`, + `src="data:${file.content_type};base64,${rawData}"`, + ); + } + }; + reader.readAsDataURL(file.data); + } else if (typeof file.data === "string") { + if (message.body) { + message.body = message.body?.replaceAll( + `src="cid:${file.content_id}"`, + `src="data:${file.content_type};base64,${dataUrl ?? file.data}"`, + ); + } } } return message; @@ -936,7 +963,7 @@ async function downloadSelectedFile(event: MouseEvent, file: File) { event.stopImmediatePropagation(); if (id && ((activeThread && _this.thread_id) || _this.message_id)) { - const downloadedFileData = await downloadFile({ + const downloadedFileData = await streamDownloadFile({ file_id: file.id, component_id: id, access_token, @@ -952,7 +979,20 @@ async function handleDownloadFromMessage(event: MouseEvent) { const file = (event.detail).file; - downloadSelectedFile(event, file); + + if (file.data instanceof Blob) { + const url = URL.createObjectURL(file.data); + + const link = document.createElement("a"); + link.href = url; + link.download = file.filename; // Use the file name or 'download' if the name is not available + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + downloadSelectedFile(event, file); + } } function isThreadADraftEmail(currentThread: Thread): boolean { diff --git a/components/email/yarn.lock b/components/email/yarn.lock index 55fbe0c2..d6a5d36c 100644 --- a/components/email/yarn.lock +++ b/components/email/yarn.lock @@ -3,18 +3,18 @@ "@types/dompurify@^2.3.1": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.1.tgz#2934adcd31c4e6b02676f9c22f9756e5091c04dd" - integrity sha512-YJth9qa0V/E6/XPH1Jq4BC8uCMmO8V1fKWn8PCvuZcAhMn7q0ez9LW6naQT04UZzjFfAPhyRMZmI2a2rbMlEFA== + "integrity" "sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg==" + "resolved" "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz" + "version" "2.4.0" dependencies: "@types/trusted-types" "*" "@types/trusted-types@*": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" - integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== + "integrity" "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + "resolved" "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz" + "version" "2.0.7" -dompurify@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.3.tgz#c1af3eb88be47324432964d8abc75cf4b98d634c" - integrity sha512-dqnqRkPMAjOZE0FogZ+ceJNM2dZ3V/yNOuFB7+39qpO93hHhfRpHw3heYQC7DPK9FqbQTfBKUJhiSfz4MvXYwg== +"dompurify@^2.3.3": + "integrity" "sha512-FgbqnEPiv5Vdtwt6Mxl7XSylttCC03cqP5ldNT2z+Kj0nLxPHJH4+1Cyf5Jasxhw93Rl4Oo11qRoUV72fmya2Q==" + "resolved" "https://registry.npmjs.org/dompurify/-/dompurify-2.5.5.tgz" + "version" "2.5.5"