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);
|
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) {
|
if (!project.email || !project.verified) {
|
||||||
throw new HttpException(401, "Verify your domain before you start sending");
|
throw new HttpException(401, "Verify your domain before you start sending");
|
||||||
@@ -210,6 +210,7 @@ export class V1 {
|
|||||||
reply: reply ?? from ?? project.email,
|
reply: reply ?? from ?? project.email,
|
||||||
to: [email],
|
to: [email],
|
||||||
headers,
|
headers,
|
||||||
|
attachments,
|
||||||
content: {
|
content: {
|
||||||
subject: enrichedSubject,
|
subject: enrichedSubject,
|
||||||
html: EmailService.compile({
|
html: EmailService.compile({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export class EmailService {
|
|||||||
content,
|
content,
|
||||||
reply,
|
reply,
|
||||||
headers,
|
headers,
|
||||||
|
attachments,
|
||||||
}: {
|
}: {
|
||||||
from: {
|
from: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +24,11 @@ export class EmailService {
|
|||||||
headers?: {
|
headers?: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
attachments?: Array<{
|
||||||
|
filename: string;
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
}> | null;
|
||||||
}) {
|
}) {
|
||||||
// Check if the body contains an unsubscribe link
|
// Check if the body contains an unsubscribe link
|
||||||
const regex = /unsubscribe\/([a-f\d-]+)"/;
|
const regex = /unsubscribe\/([a-f\d-]+)"/;
|
||||||
@@ -34,12 +40,20 @@ export class EmailService {
|
|||||||
unsubscribeLink = `List-Unsubscribe: <https://${APP_URI}/unsubscribe/${unsubscribeId}>`;
|
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}>
|
const rawMessage = `From: ${from.name} <${from.email}>
|
||||||
To: ${to.join(", ")}
|
To: ${to.join(", ")}
|
||||||
Reply-To: ${reply || from.email}
|
Reply-To: ${reply || from.email}
|
||||||
Subject: ${content.subject}
|
Subject: ${content.subject}
|
||||||
MIME-Version: 1.0
|
MIME-Version: 1.0
|
||||||
Content-Type: multipart/alternative; boundary="NextPart"
|
${
|
||||||
|
mixedBoundary
|
||||||
|
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
||||||
|
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||||
|
}
|
||||||
${
|
${
|
||||||
headers
|
headers
|
||||||
? Object.entries(headers)
|
? Object.entries(headers)
|
||||||
@@ -49,13 +63,28 @@ ${
|
|||||||
}
|
}
|
||||||
${unsubscribeLink}
|
${unsubscribeLink}
|
||||||
|
|
||||||
--NextPart
|
${mixedBoundary ? `--${mixedBoundary}\n` : ""}${
|
||||||
|
mixedBoundary
|
||||||
|
? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n`
|
||||||
|
: ""
|
||||||
|
}--${boundary}
|
||||||
Content-Type: text/html; charset=utf-8
|
Content-Type: text/html; charset=utf-8
|
||||||
Content-Transfer-Encoding: 7bit
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
${EmailService.breakLongLines(content.html, 500)}
|
${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({
|
const response = await ses.sendRawEmail({
|
||||||
Destinations: to,
|
Destinations: to,
|
||||||
@@ -501,23 +530,33 @@ ${
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static breakLongLines(input: string, maxLineLength: number): string {
|
private static breakLongLines(input: string, maxLineLength: number, isBase64: boolean = false): string {
|
||||||
const lines = input.split("\n");
|
if (isBase64) {
|
||||||
const result = [];
|
// For base64 content, break at exact intervals without looking for spaces
|
||||||
for (let line of lines) {
|
const result = [];
|
||||||
while (line.length > maxLineLength) {
|
for (let i = 0; i < input.length; i += maxLineLength) {
|
||||||
let pos = maxLineLength;
|
result.push(input.substring(i, i + 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");
|
||||||
|
} 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",
|
required_error: "Body is required. Read more: https://docs.useplunk.com/api-reference/transactional/send",
|
||||||
}),
|
}),
|
||||||
headers: z.record(z.string()).nullish(),
|
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