feat(api): support inline images in emails using Content-ID
This commit is contained in:
@@ -18,6 +18,8 @@ interface Attachment {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: string; // Base64 encoded
|
content: string; // Base64 encoded
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendEmailParams {
|
interface SendEmailParams {
|
||||||
@@ -364,7 +366,13 @@ export class EmailService {
|
|||||||
// Parse attachments from JSON
|
// Parse attachments from JSON
|
||||||
const attachments =
|
const attachments =
|
||||||
email.attachments && Array.isArray(email.attachments)
|
email.attachments && Array.isArray(email.attachments)
|
||||||
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
? (email.attachments as Array<{
|
||||||
|
filename: string;
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
|
}>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Determine tracking based on project settings and email type
|
// Determine tracking based on project settings and email type
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: string; // Base64 encoded
|
content: string; // Base64 encoded
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
tracking?: boolean;
|
tracking?: boolean;
|
||||||
@@ -101,8 +103,13 @@ export async function sendRawEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique boundaries for multipart messages
|
// Generate unique boundaries for multipart messages
|
||||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
|
||||||
|
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
|
||||||
|
: null;
|
||||||
|
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
|
||||||
|
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
// Format To header with names if provided
|
// Format To header with names if provided
|
||||||
const toHeader = to
|
const toHeader = to
|
||||||
@@ -118,17 +125,21 @@ export async function sendRawEmail({
|
|||||||
// Extract just email addresses for Destinations (SES requirement)
|
// Extract just email addresses for Destinations (SES requirement)
|
||||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
||||||
|
|
||||||
|
// Determine root content type
|
||||||
|
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||||
|
if (mixedBoundary) {
|
||||||
|
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
|
||||||
|
} else if (relatedBoundary) {
|
||||||
|
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||||
|
}
|
||||||
|
|
||||||
// Build raw MIME message
|
// Build raw MIME message
|
||||||
const rawMessage = `From: ${from.name} <${from.email}>
|
let rawMessage = `From: ${from.name} <${from.email}>
|
||||||
To: ${toHeader}
|
To: ${toHeader}
|
||||||
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: ${rootContentType}
|
||||||
mixedBoundary
|
|
||||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
|
||||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
|
||||||
}
|
|
||||||
${
|
${
|
||||||
headers
|
headers
|
||||||
? Object.entries(headers)
|
? Object.entries(headers)
|
||||||
@@ -138,29 +149,61 @@ ${
|
|||||||
}
|
}
|
||||||
${unsubscribeHeader}
|
${unsubscribeHeader}
|
||||||
|
|
||||||
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
|
`;
|
||||||
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
|
|
||||||
}--${boundary}
|
// building the body
|
||||||
|
if (mixedBoundary) {
|
||||||
|
rawMessage += `--${mixedBoundary}\n`;
|
||||||
|
if (relatedBoundary) {
|
||||||
|
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
|
||||||
|
rawMessage += `--${relatedBoundary}\n`;
|
||||||
|
}
|
||||||
|
} else if (relatedBoundary) {
|
||||||
|
rawMessage += `--${relatedBoundary}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are nested, we need to specify that this next part is the alternative container
|
||||||
|
if (mixedBoundary || relatedBoundary) {
|
||||||
|
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alternative part content (always contains HTML)
|
||||||
|
rawMessage += `--${altBoundary}
|
||||||
Content-Type: text/html; charset=utf-8
|
Content-Type: text/html; charset=utf-8
|
||||||
Content-Transfer-Encoding: 7bit
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
${breakLongLines(content.html, 500)}
|
${breakLongLines(content.html, 500)}
|
||||||
--${boundary}--
|
--${altBoundary}--
|
||||||
${
|
`;
|
||||||
attachments && attachments.length > 0
|
|
||||||
? '\n' +
|
// Add inline attachments to the related container
|
||||||
attachments
|
if (relatedBoundary) {
|
||||||
.map(
|
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||||
attachment => `--${mixedBoundary}
|
for (const attachment of inlineAttachments) {
|
||||||
|
rawMessage += `\n--${relatedBoundary}
|
||||||
|
Content-Type: ${attachment.contentType}
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-ID: <${attachment.contentId || attachment.filename}>
|
||||||
|
Content-Disposition: inline; filename="${attachment.filename}"
|
||||||
|
|
||||||
|
${breakLongLines(attachment.content, 76, true)}`;
|
||||||
|
}
|
||||||
|
rawMessage += `\n--${relatedBoundary}--`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add regular attachments to the mixed container
|
||||||
|
if (mixedBoundary) {
|
||||||
|
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
|
||||||
|
for (const attachment of regularAttachments) {
|
||||||
|
rawMessage += `\n--${mixedBoundary}
|
||||||
Content-Type: ${attachment.contentType}
|
Content-Type: ${attachment.contentType}
|
||||||
Content-Transfer-Encoding: base64
|
Content-Transfer-Encoding: base64
|
||||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||||
|
|
||||||
${breakLongLines(attachment.content, 76, true)}`,
|
${breakLongLines(attachment.content, 76, true)}`;
|
||||||
)
|
}
|
||||||
.join('\n')
|
rawMessage += `\n--${mixedBoundary}--`;
|
||||||
: ''
|
}
|
||||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
|
||||||
|
|
||||||
// Determine which configuration set to use
|
// Determine which configuration set to use
|
||||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||||
|
|||||||
@@ -801,5 +801,68 @@ describe('EmailService', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should accept inline attachment with contentId', () => {
|
||||||
|
const result = ActionSchemas.send.safeParse({
|
||||||
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
|
subject: 'Inline Image',
|
||||||
|
body: '<img src="cid:logo" />',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'logo.png',
|
||||||
|
content: Buffer.from('image').toString('base64'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
contentId: 'logo',
|
||||||
|
disposition: 'inline',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const attachment = result.data.attachments![0];
|
||||||
|
expect(attachment.contentId).toBe('logo');
|
||||||
|
expect(attachment.disposition).toBe('inline');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject contentId exceeding 255 chars', () => {
|
||||||
|
const result = ActionSchemas.send.safeParse({
|
||||||
|
to: '[email protected]',
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'image.png',
|
||||||
|
content: Buffer.from('content').toString('base64'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
contentId: 'a'.repeat(256),
|
||||||
|
disposition: 'inline',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid disposition', () => {
|
||||||
|
const result = ActionSchemas.send.safeParse({
|
||||||
|
to: '[email protected]',
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'image.png',
|
||||||
|
content: Buffer.from('content').toString('base64'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
disposition: 'invalid-disposition',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ export const ActionSchemas = {
|
|||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
content: z.string().min(1), // Base64 encoded file content
|
content: z.string().min(1), // Base64 encoded file content
|
||||||
contentType: z.string().min(1).max(255),
|
contentType: z.string().min(1).max(255),
|
||||||
|
contentId: z.string().min(1).max(255).optional(),
|
||||||
|
disposition: z.enum(['attachment', 'inline']).default('attachment'),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.max(10) // Maximum 10 attachments per email
|
.max(10) // Maximum 10 attachments per email
|
||||||
|
|||||||
Reference in New Issue
Block a user