diff --git a/packages/api/src/controllers/v1/index.ts b/packages/api/src/controllers/v1/index.ts index 9be09ff..f84c4e2 100644 --- a/packages/api/src/controllers/v1/index.ts +++ b/packages/api/src/controllers/v1/index.ts @@ -143,7 +143,7 @@ export class V1 { throw new HttpException(400, result.error.issues[0].message); } - const { from, name, reply, to, subject, body, subscribed, headers } = result.data; + const { from, name, reply, to, subject, body, subscribed, headers, attachments } = result.data; if (!project.email || !project.verified) { throw new HttpException(401, "Verify your domain before you start sending"); @@ -210,6 +210,7 @@ export class V1 { reply: reply ?? from ?? project.email, to: [email], headers, + attachments, content: { subject: enrichedSubject, html: EmailService.compile({ diff --git a/packages/api/src/services/EmailService.ts b/packages/api/src/services/EmailService.ts index 82d678b..a8293b9 100644 --- a/packages/api/src/services/EmailService.ts +++ b/packages/api/src/services/EmailService.ts @@ -9,6 +9,7 @@ export class EmailService { content, reply, headers, + attachments, }: { from: { name: string; @@ -23,6 +24,11 @@ export class EmailService { headers?: { [key: string]: string; } | null; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }> | null; }) { // Check if the body contains an unsubscribe link const regex = /unsubscribe\/([a-f\d-]+)"/; @@ -34,12 +40,20 @@ export class EmailService { unsubscribeLink = `List-Unsubscribe: `; } + // Generate a unique boundary for multipart messages + const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`; + const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null; + const rawMessage = `From: ${from.name} <${from.email}> To: ${to.join(", ")} Reply-To: ${reply || from.email} Subject: ${content.subject} MIME-Version: 1.0 -Content-Type: multipart/alternative; boundary="NextPart" +${ + mixedBoundary + ? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"` + : `Content-Type: multipart/alternative; boundary="${boundary}"` +} ${ headers ? Object.entries(headers) @@ -49,13 +63,28 @@ ${ } ${unsubscribeLink} ---NextPart +${mixedBoundary ? `--${mixedBoundary}\n` : ""}${ + mixedBoundary + ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` + : "" +}--${boundary} Content-Type: text/html; charset=utf-8 Content-Transfer-Encoding: 7bit ${EmailService.breakLongLines(content.html, 500)} ---NextPart-- -`; +--${boundary}-- +${ + attachments?.length + ? attachments.map(attachment => ` +--${mixedBoundary} +Content-Type: ${attachment.contentType} +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="${attachment.filename}" + +${EmailService.breakLongLines(attachment.content, 76, true)} +`).join('\n') + : "" +}${mixedBoundary ? `\n--${mixedBoundary}--` : ""}`; const response = await ses.sendRawEmail({ Destinations: to, @@ -501,23 +530,33 @@ ${ }; } - private static breakLongLines(input: string, maxLineLength: number): string { - const lines = input.split("\n"); - const result = []; - for (let line of lines) { - while (line.length > maxLineLength) { - let pos = maxLineLength; - while (pos > 0 && line[pos] !== " ") { - pos--; - } - if (pos === 0) { - pos = maxLineLength; - } - result.push(line.substring(0, pos)); - line = line.substring(pos).trim(); + private static breakLongLines(input: string, maxLineLength: number, isBase64: boolean = false): string { + if (isBase64) { + // For base64 content, break at exact intervals without looking for spaces + const result = []; + for (let i = 0; i < input.length; i += maxLineLength) { + result.push(input.substring(i, i + maxLineLength)); } - result.push(line); + return result.join("\n"); + } else { + // Original implementation for text content + const lines = input.split("\n"); + const result = []; + for (let line of lines) { + while (line.length > maxLineLength) { + let pos = maxLineLength; + while (pos > 0 && line[pos] !== " ") { + pos--; + } + if (pos === 0) { + pos = maxLineLength; + } + result.push(line.substring(0, pos)); + line = line.substring(pos).trim(); + } + result.push(line); + } + return result.join("\n"); } - return result.join("\n"); } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7ba4a2a..73e0aec 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -118,6 +118,11 @@ export const EventSchemas = { required_error: "Body is required. Read more: https://docs.useplunk.com/api-reference/transactional/send", }), headers: z.record(z.string()).nullish(), + attachments: z.array(z.object({ + filename: z.string(), + content: z.string(), // Base64 encoded content + contentType: z.string(), + })).max(5, "You can only include up to 5 attachments").nullish(), }), };