Skip to content

Commit

Permalink
fix: email component crashes on big attachments (#475)
Browse files Browse the repository at this point in the history
  • Loading branch information
krisgardiner committed Jun 4, 2024
1 parent f64a0c8 commit 5c8d858
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 33 deletions.
33 changes: 32 additions & 1 deletion commons/src/connections/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
let queryString = `${getMiddlewareApiUrl(query.component_id)}/files/${
const queryString = `${getMiddlewareApiUrl(query.component_id)}/files/${
query.file_id
}/download`;

Expand All @@ -16,3 +17,33 @@ export const downloadFile = async (query: FileQuery): Promise<string> => {
.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<Blob> => {
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;
};
20 changes: 20 additions & 0 deletions commons/src/methods/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, any>): string {
Expand Down
17 changes: 13 additions & 4 deletions commons/src/methods/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 18 additions & 6 deletions commons/src/store/files.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand All @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion commons/src/types/Nylas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export interface File {
size: number;
content_disposition: string;
content_id?: string;
data?: string;
data?: string | Blob;
}

export interface MiddlewareResponse<T = unknown> {
Expand Down
62 changes: 51 additions & 11 deletions components/email/src/Email.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +91,7 @@
export let you: Partial<Account>;
export let show_reply: boolean;
export let show_reply_all: boolean;
export let show_forward: boolean;
const defaultValueMap: Partial<EmailProperties> = {
Expand Down Expand Up @@ -241,7 +242,7 @@
}
let main: Element;
let messageRefs: Element[] = [];
let messageRefs: HTMLElement[] = [];
const MAX_DESKTOP_PARTICIPANTS = 2;
const MAX_MOBILE_PARTICIPANTS = 1;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -704,14 +705,18 @@
}
}
function fetchIndividualMessage(messageID: string): Promise<Message | null> {
async function fetchIndividualMessage(
messageID: string,
): Promise<Message | null> {
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;
});
Expand Down Expand Up @@ -898,13 +903,15 @@
function initializeAttachedFiles() {
const messageType = getMessageType(activeThread);
attachedFiles = activeThread[messageType]?.reduce(
(files: Record<string, File[]>, message) => {
for (const [fileIndex, file] of message.files.entries()) {
if (isFileAnAttachment(file)) {
if (!files[message.id]) {
files[message.id] = [];
}
files[message.id] = [
...files[message.id],
message.files[fileIndex],
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -952,7 +979,20 @@
async function handleDownloadFromMessage(event: MouseEvent) {
const file = (<any>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 {
Expand Down
20 changes: 10 additions & 10 deletions components/email/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"

0 comments on commit 5c8d858

Please sign in to comment.