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:
@@ -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 {
|
||||||
@@ -381,7 +383,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
import {sendRawEmail} from '../SESService';
|
import {sendRawEmail} from '../SESService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
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', () => ({
|
vi.mock('../SESService', () => ({
|
||||||
sendRawEmail: vi.fn(),
|
sendRawEmail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -801,5 +822,204 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 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}--`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -407,7 +407,18 @@ 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)
|
||||||
|
.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
|
.max(10) // Maximum 10 attachments per email
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user