Merge pull request #281 from 5h0ov/multipart/related-support-embed-image

feat(api): support inline images in emails using Content-ID
This commit is contained in:
Dries Augustyns
2026-03-05 11:26:41 +01:00
committed by GitHub
4 changed files with 309 additions and 27 deletions
+9 -1
View File
@@ -18,6 +18,8 @@ interface Attachment {
filename: string;
content: string; // Base64 encoded
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}
interface SendEmailParams {
@@ -381,7 +383,13 @@ export class EmailService {
// Parse attachments from JSON
const 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;
// Determine tracking based on project settings and email type
+66 -23
View File
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
filename: string;
content: string; // Base64 encoded
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}[]
| null;
tracking?: boolean;
@@ -101,8 +103,13 @@ export async function sendRawEmail({
}
// Generate unique boundaries 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 altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
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
const toHeader = to
@@ -118,17 +125,21 @@ export async function sendRawEmail({
// Extract just email addresses for Destinations (SES requirement)
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
const rawMessage = `From: ${from.name} <${from.email}>
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
${
mixedBoundary
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
: `Content-Type: multipart/alternative; boundary="${boundary}"`
}
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
@@ -138,29 +149,61 @@ ${
}
${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-Transfer-Encoding: 7bit
${breakLongLines(content.html, 500)}
--${boundary}--
${
attachments && attachments.length > 0
? '\n' +
attachments
.map(
attachment => `--${mixedBoundary}
--${altBoundary}--
`;
// Add inline attachments to the related container
if (relatedBoundary) {
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
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-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)}`,
)
.join('\n')
: ''
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
${breakLongLines(attachment.content, 76, true)}`;
}
rawMessage += `\n--${mixedBoundary}--`;
}
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
@@ -1,11 +1,32 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService';
import {sendRawEmail} from '../SESService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock SES service
// Mock AWS SDK globally (used by real SESService calls in MIME tests)
vi.mock('@aws-sdk/client-ses', () => {
const SESMock = vi.fn();
SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({MessageId: 'test-message-id'});
return {SES: SESMock};
});
// Mock constants to provide AWS credentials for SESService, preserving other exports
vi.mock('../../app/constants.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../app/constants.js')>();
return {
...actual,
AWS_SES_ACCESS_KEY_ID: 'test-key-id',
AWS_SES_REGION: 'us-east-1',
AWS_SES_SECRET_ACCESS_KEY: 'test-secret',
SES_CONFIGURATION_SET: 'test-config-set',
SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set',
TRACKING_TOGGLE_ENABLED: true,
};
});
// Mock SES service (default behavior for most tests)
vi.mock('../SESService', () => ({
sendRawEmail: vi.fn(),
}));
@@ -801,5 +822,204 @@ describe('EmailService', () => {
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);
});
});
});
// ========================================
// SES MIME BOUNDARY STRUCTURE
// ========================================
// These tests verify the raw MIME assembly logic inside sendRawEmail.
// They need the REAL sendRawEmail (not the mock above), so we mock
// at the AWS SDK level instead.
describe('SES MIME Boundary Structure', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should correctly structure MIME boundaries for mixed content (attachments)', async () => {
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
const params = {
from: {name: 'Sender', email: '[email protected]'},
to: ['[email protected]'],
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
attachments: [
{
filename: 'test.txt',
content: 'SGVsbG8=',
contentType: 'text/plain',
disposition: 'attachment' as const,
},
],
};
await realSendRawEmail(params);
expect(ses.sendRawEmail).toHaveBeenCalled();
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
// Verify boundary hierarchy: Mixed -> Alternative
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : '';
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`);
expect(rawMessage).toContain(`--${mixedBoundary}--`);
});
it('should correctly structure MIME boundaries for related content (inline images)', async () => {
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
const params = {
from: {name: 'Sender', email: '[email protected]'},
to: ['[email protected]'],
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
attachments: [
{
filename: 'image.png',
content:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
contentType: 'image/png',
contentId: 'image1',
disposition: 'inline' as const,
},
],
};
await realSendRawEmail(params);
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
// Verify boundary hierarchy: Related -> Alternative
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s);
const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : '';
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`);
expect(rawMessage).toContain(`--${relatedBoundary}--`);
});
it('should correctly nest mixed > related > alternative boundaries', async () => {
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
const params = {
from: {name: 'Sender', email: '[email protected]'},
to: ['[email protected]'],
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
attachments: [
{
filename: 'test.txt',
content: 'SGVsbG8=',
contentType: 'text/plain',
disposition: 'attachment' as const,
},
{
filename: 'image.png',
content:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
contentType: 'image/png',
contentId: 'image1',
disposition: 'inline' as const,
},
],
};
await realSendRawEmail(params);
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
// Root should be mixed
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/);
const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED';
// Within mixed, we should find related
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`);
const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/);
const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED';
// Within related, we should find alternative
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT';
// Verify all closing boundaries exist
expect(rawMessage).toContain(`--${altBoundary}--`);
expect(rawMessage).toContain(`--${relatedBoundary}--`);
expect(rawMessage).toContain(`--${mixedBoundary}--`);
});
});
+12 -1
View File
@@ -407,7 +407,18 @@ export const ActionSchemas = {
filename: z.string().min(1).max(255),
content: z.string().min(1), // Base64 encoded file content
contentType: z.string().min(1).max(255),
}),
contentId: z
.string()
.min(1)
.max(255)
.regex(/^[^<>\r\n]+$/, 'Content ID cannot contain <, >, \\r, or \\n')
.optional(),
disposition: z.enum(['attachment', 'inline']).default('attachment'),
})
.refine(data => data.disposition !== 'inline' || !!data.contentId, {
message: 'Content ID is required when disposition is inline',
path: ['contentId'],
}),
)
.max(10) // Maximum 10 attachments per email
.optional(),