feat(api): support inline images in emails using Content-ID

This commit is contained in:
Shuvadipta Das
2026-01-31 10:23:42 +05:30
parent 0af79eb84b
commit c40394ffd6
4 changed files with 140 additions and 24 deletions
+9 -1
View File
@@ -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
+66 -23
View File
@@ -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);
});
}); });
}); });
+2
View File
@@ -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