diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 99dec73..bc40a38 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -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 diff --git a/apps/api/src/services/SESService.ts b/apps/api/src/services/SESService.ts index ef05b9a..d96cd73 100644 --- a/apps/api/src/services/SESService.ts +++ b/apps/api/src/services/SESService.ts @@ -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 diff --git a/apps/api/src/services/__tests__/EmailService.test.ts b/apps/api/src/services/__tests__/EmailService.test.ts index 0d6e141..4db3cc5 100644 --- a/apps/api/src/services/__tests__/EmailService.test.ts +++ b/apps/api/src/services/__tests__/EmailService.test.ts @@ -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(); + 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: 'test@example.com', + from: 'test@example.com', + subject: 'Inline Image', + body: '', + 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: 'test@example.com', + 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: 'test@example.com', + 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('../SESService'); + + const params = { + from: {name: 'Sender', email: 'sender@example.com'}, + to: ['recipient@example.com'], + content: {subject: 'Test Subject', html: '

Hello world

'}, + 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('../SESService'); + + const params = { + from: {name: 'Sender', email: 'sender@example.com'}, + to: ['recipient@example.com'], + content: {subject: 'Test Subject', html: '

Hello world

'}, + 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('../SESService'); + + const params = { + from: {name: 'Sender', email: 'sender@example.com'}, + to: ['recipient@example.com'], + content: {subject: 'Test Subject', html: '

Hello world

'}, + 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}--`); }); }); diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 7fa35b0..9eadd33 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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(),