From c40394ffd6ef3dc844b9e475e20324cb4c5f0055 Mon Sep 17 00:00:00 2001 From: Shuvadipta Das Date: Sat, 31 Jan 2026 10:23:42 +0530 Subject: [PATCH 1/4] feat(api): support inline images in emails using Content-ID --- apps/api/src/services/EmailService.ts | 10 ++- apps/api/src/services/SESService.ts | 89 ++++++++++++++----- .../services/__tests__/EmailService.test.ts | 63 +++++++++++++ packages/shared/src/schemas/index.ts | 2 + 4 files changed, 140 insertions(+), 24 deletions(-) diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 3ecc28b..3db6cf2 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 { @@ -364,7 +366,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..5d9abfd 100644 --- a/apps/api/src/services/__tests__/EmailService.test.ts +++ b/apps/api/src/services/__tests__/EmailService.test.ts @@ -801,5 +801,68 @@ 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); + }); + }); }); diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index ca76b85..b469914 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -380,6 +380,8 @@ 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).optional(), + disposition: z.enum(['attachment', 'inline']).default('attachment'), }), ) .max(10) // Maximum 10 attachments per email From 08e5c0d930f1db92b754a018ea08900f93032c64 Mon Sep 17 00:00:00 2001 From: Shuvadipta Das Date: Mon, 16 Feb 2026 18:27:32 +0530 Subject: [PATCH 2/4] fix: contentId header injection fixed and separte unit test SESService added to verify MIME boundaries --- .../src/services/__tests__/SESService.test.ts | 174 ++++++++++++++++++ packages/shared/src/schemas/index.ts | 13 +- 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/__tests__/SESService.test.ts diff --git a/apps/api/src/services/__tests__/SESService.test.ts b/apps/api/src/services/__tests__/SESService.test.ts new file mode 100644 index 0000000..014e7ad --- /dev/null +++ b/apps/api/src/services/__tests__/SESService.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { sendRawEmail } from '../SESService'; + +// Mock with both paths to catch resolution variations +vi.mock('../../app/constants.js', () => ({ + AWS_SES_ACCESS_KEY_ID: 'test-key-id', + AWS_SES_REGION: 'us-east-1', + AWS_SES_SECRET_ACCESS_KEY: 'test-secret', + DASHBOARD_URI: 'http://localhost:3000', + SES_CONFIGURATION_SET: 'test-config-set', + SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set', + TRACKING_TOGGLE_ENABLED: true, +})); + + + +// Mock the SES client +vi.mock('@aws-sdk/client-ses', () => { + const SESMock = vi.fn(); + SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({ MessageId: 'test-message-id' }); + return { SES: SESMock }; +}); + +describe('SESService', () => { + let sesInstance: any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should correctly structure MIME boundaries for mixed content (attachments)', async () => { + // Import the exported instance to verify calls + const { ses } = await import('../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=', // Hello + contentType: 'text/plain', + disposition: 'attachment' as const, + }, + ], + }; + + await sendRawEmail(params); + + expect(ses.sendRawEmail).toHaveBeenCalled(); + const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); + + // Verify boundary hierarchy: Mixed -> Alternative + // Explicitly check that "multipart/mixed" is the root content type (first occurrence) + expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s); + + // Check for presence of alternative boundary nested inside + expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/); + + // Verify distinct boundaries + 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 { ses } = await import('../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 sendRawEmail(params); + + const callArgs = (ses.sendRawEmail as any).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] : ''; + + // Check that related part contains alternative part + expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`); + // And also contains the inline attachment + expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`); + expect(rawMessage).toContain(`--${relatedBoundary}--`); + }); + + it('should correctly nest mixed > related > alternative boundaries', async () => { + const { ses } = await import('../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 sendRawEmail(params); + + const callArgs = (ses.sendRawEmail as any).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); + + // Find the mixed boundary + const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/); + const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED'; + + // Within mixed, we should find related + // The code structure: + // --mixed + // Content-Type: multipart/related; boundary="related" + // --related + // Content-Type: multipart/alternative; boundary="alt" + + // Check hierarchy via regex matching structure + // Check that related is defined inside mixed + expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`); + + // Find related boundary + const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/); + const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED'; + + // Check that alternative is defined inside related + 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 closing boundaries existence and order implies nesting + // The end of string should look like: + // --related-- + // --mixed + // ...attachment... + // --mixed-- + + 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 b469914..baa0bd3 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -380,9 +380,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).optional(), + 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(), From 3a42012ac73592a2d2c167fe98a02f5d7614810f Mon Sep 17 00:00:00 2001 From: Shuvadipta Das Date: Mon, 16 Feb 2026 18:47:42 +0530 Subject: [PATCH 3/4] fix: removed "any" data type from SESService.test.ts --- apps/api/src/services/__tests__/SESService.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/__tests__/SESService.test.ts b/apps/api/src/services/__tests__/SESService.test.ts index 014e7ad..62bf3a0 100644 --- a/apps/api/src/services/__tests__/SESService.test.ts +++ b/apps/api/src/services/__tests__/SESService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { sendRawEmail } from '../SESService'; // Mock with both paths to catch resolution variations @@ -22,8 +22,6 @@ vi.mock('@aws-sdk/client-ses', () => { }); describe('SESService', () => { - let sesInstance: any; - beforeEach(() => { vi.clearAllMocks(); }); @@ -49,7 +47,7 @@ describe('SESService', () => { await sendRawEmail(params); expect(ses.sendRawEmail).toHaveBeenCalled(); - const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0]; const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); // Verify boundary hierarchy: Mixed -> Alternative @@ -87,7 +85,7 @@ describe('SESService', () => { await sendRawEmail(params); - const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0]; const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); // Verify boundary hierarchy: Related -> Alternative @@ -129,7 +127,7 @@ describe('SESService', () => { await sendRawEmail(params); - const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0]; const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); // Root should be mixed From 64ba19e589ed7432f4f79f0315a7ca745cabed22 Mon Sep 17 00:00:00 2001 From: Shuvadipta Das Date: Mon, 16 Feb 2026 18:55:03 +0530 Subject: [PATCH 4/4] fix(test): consolidate SES MIME boundary tests into EmailService.test.ts --- .../services/__tests__/EmailService.test.ts | 161 +++++++++++++++- .../src/services/__tests__/SESService.test.ts | 172 ------------------ 2 files changed, 159 insertions(+), 174 deletions(-) delete mode 100644 apps/api/src/services/__tests__/SESService.test.ts diff --git a/apps/api/src/services/__tests__/EmailService.test.ts b/apps/api/src/services/__tests__/EmailService.test.ts index 5d9abfd..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(), })); @@ -866,3 +887,139 @@ describe('EmailService', () => { }); }); + +// ======================================== +// 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/apps/api/src/services/__tests__/SESService.test.ts b/apps/api/src/services/__tests__/SESService.test.ts deleted file mode 100644 index 62bf3a0..0000000 --- a/apps/api/src/services/__tests__/SESService.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -import { sendRawEmail } from '../SESService'; - -// Mock with both paths to catch resolution variations -vi.mock('../../app/constants.js', () => ({ - AWS_SES_ACCESS_KEY_ID: 'test-key-id', - AWS_SES_REGION: 'us-east-1', - AWS_SES_SECRET_ACCESS_KEY: 'test-secret', - DASHBOARD_URI: 'http://localhost:3000', - SES_CONFIGURATION_SET: 'test-config-set', - SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set', - TRACKING_TOGGLE_ENABLED: true, -})); - - - -// Mock the SES client -vi.mock('@aws-sdk/client-ses', () => { - const SESMock = vi.fn(); - SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({ MessageId: 'test-message-id' }); - return { SES: SESMock }; -}); - -describe('SESService', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should correctly structure MIME boundaries for mixed content (attachments)', async () => { - // Import the exported instance to verify calls - const { ses } = await import('../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=', // Hello - contentType: 'text/plain', - disposition: 'attachment' as const, - }, - ], - }; - - await sendRawEmail(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 - // Explicitly check that "multipart/mixed" is the root content type (first occurrence) - expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s); - - // Check for presence of alternative boundary nested inside - expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/); - - // Verify distinct boundaries - 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 { ses } = await import('../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 sendRawEmail(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] : ''; - - // Check that related part contains alternative part - expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`); - // And also contains the inline attachment - expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`); - expect(rawMessage).toContain(`--${relatedBoundary}--`); - }); - - it('should correctly nest mixed > related > alternative boundaries', async () => { - const { ses } = await import('../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 sendRawEmail(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); - - // Find the mixed boundary - const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/); - const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED'; - - // Within mixed, we should find related - // The code structure: - // --mixed - // Content-Type: multipart/related; boundary="related" - // --related - // Content-Type: multipart/alternative; boundary="alt" - - // Check hierarchy via regex matching structure - // Check that related is defined inside mixed - expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`); - - // Find related boundary - const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/); - const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED'; - - // Check that alternative is defined inside related - 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 closing boundaries existence and order implies nesting - // The end of string should look like: - // --related-- - // --mixed - // ...attachment... - // --mixed-- - - expect(rawMessage).toContain(`--${altBoundary}--`); - expect(rawMessage).toContain(`--${relatedBoundary}--`); - expect(rawMessage).toContain(`--${mixedBoundary}--`); - }); -});