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(),