FEAT: Email attachment support
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user