FEAT: Email attachment support

This commit is contained in:
mike
2025-07-21 22:48:27 +02:00
parent d866a23726
commit 0e718d4dba
3 changed files with 66 additions and 21 deletions
+2 -1
View File
@@ -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({
+59 -20
View File
@@ -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: <https://${APP_URI}/unsubscribe/${unsubscribeId}>`;
}
// 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");
}
}
+5
View File
@@ -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(),
}),
};