diff --git a/.changeset/fix-cid-attachment-url.md b/.changeset/fix-cid-attachment-url.md new file mode 100644 index 0000000..4a525f0 --- /dev/null +++ b/.changeset/fix-cid-attachment-url.md @@ -0,0 +1,10 @@ +--- +'@maildev/smtp': patch +'@maildev/api': patch +--- + +Fix the URL that `replaceCidReferences` rewrites inline (CID) attachments to. It +built `/email/:id/attachment/:file`, which matches no route: the attachment is +served from `/api/email/:id/attachment/:file`, under the configured base path. +The URL is now root-relative and includes both parts, so it resolves at the root +and under a subpath such as `/mail`. diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index b5887ad..671c616 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -457,11 +457,11 @@ export class APIServer extends EventEmitter { `${apiPath}/email/:id/html`, async (request, reply) => { const { id } = request.params - const baseUrl = request.headers.host ?? '' try { if (this.smtp) { - const html = await this.smtp.getEmailHtml(id, { baseUrl }) + // No host: the HTML is rendered for a browser already on this origin + const html = await this.smtp.getEmailHtml(id, { basePath }) if (!html) { return reply.status(404).send({ error: 'Email has no HTML content' }) } diff --git a/packages/smtp/src/__tests__/sanitize.test.ts b/packages/smtp/src/__tests__/sanitize.test.ts index 3d94460..52a4d98 100644 --- a/packages/smtp/src/__tests__/sanitize.test.ts +++ b/packages/smtp/src/__tests__/sanitize.test.ts @@ -73,7 +73,7 @@ describe('replaceCidReferences', () => { const result = replaceCidReferences(html, 'email1', attachments) - expect(result).toContain('/email/email1/attachment/abc.png') + expect(result).toContain('/api/email/email1/attachment/abc.png') expect(result).not.toContain('cid:') }) @@ -85,7 +85,7 @@ describe('replaceCidReferences', () => { const result = replaceCidReferences(html, 'email1', attachments) - expect(result).toContain('/email/email1/attachment/abc.png') + expect(result).toContain('/api/email/email1/attachment/abc.png') }) it('should include baseUrl when provided', () => { @@ -96,7 +96,30 @@ describe('replaceCidReferences', () => { const result = replaceCidReferences(html, 'email1', attachments, 'example.com') - expect(result).toContain('//example.com/email/email1/attachment/abc.png') + expect(result).toContain('//example.com/api/email/email1/attachment/abc.png') + }) + + it('should include basePath when MailDev is mounted under a subpath', () => { + const html = '' + const attachments = [ + { contentId: 'image123', generatedFileName: 'abc.png' }, + ] + + const result = replaceCidReferences(html, 'email1', attachments, undefined, '/mail') + + expect(result).toContain('src="/mail/api/email/email1/attachment/abc.png"') + }) + + it('should normalize a basePath given with or without slashes', () => { + const html = '' + const attachments = [ + { contentId: 'image123', generatedFileName: 'abc.png' }, + ] + + for (const basePath of ['mail', '/mail', '/mail/']) { + const result = replaceCidReferences(html, 'email1', attachments, undefined, basePath) + expect(result).toContain('src="/mail/api/email/email1/attachment/abc.png"') + } }) it('should replace multiple cid references', () => { diff --git a/packages/smtp/src/sanitize.ts b/packages/smtp/src/sanitize.ts index fa54a29..16ae54a 100644 --- a/packages/smtp/src/sanitize.ts +++ b/packages/smtp/src/sanitize.ts @@ -45,14 +45,16 @@ export function sanitizeHtml(html: string | undefined): string | undefined { * @param html - HTML content with cid: references * @param emailId - ID of the email * @param attachments - Array of attachments with contentId - * @param baseUrl - Optional base URL for attachment URLs + * @param baseUrl - Optional host to prefix as `//host/...`; unset gives a root-relative URL + * @param basePath - Optional pathname the API is mounted under, e.g. `/mail` * @returns HTML with cid: references replaced with actual URLs */ export function replaceCidReferences( html: string, emailId: string, attachments: Array<{ contentId?: string; generatedFileName: string }>, - baseUrl?: string + baseUrl?: string, + basePath?: string ): string { if (!attachments || attachments.length === 0) { return html @@ -72,7 +74,7 @@ export function replaceCidReferences( 'g' ) - const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl) + const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl, basePath) const replacement = `src="${url}"` result = result.replace(regex, replacement) @@ -82,15 +84,18 @@ export function replaceCidReferences( } /** - * Build URL for an attachment + * Build URL for an attachment. The API serves these from `${basePath}/api/email/...`, + * so both parts belong here or the src 404s. */ function buildAttachmentUrl( emailId: string, filename: string, - baseUrl?: string + baseUrl?: string, + basePath?: string ): string { const base = baseUrl ? `//${baseUrl}` : '' - return `${base}/email/${emailId}/attachment/${encodeURIComponent(filename)}` + const prefix = basePath ? `/${basePath.replace(/^\/|\/$/g, '')}` : '' + return `${base}${prefix}/api/email/${emailId}/attachment/${encodeURIComponent(filename)}` } /** diff --git a/packages/smtp/src/server.ts b/packages/smtp/src/server.ts index 15f0cb5..c933860 100644 --- a/packages/smtp/src/server.ts +++ b/packages/smtp/src/server.ts @@ -258,7 +258,7 @@ export class SMTPServer extends EventEmitter { // Replace CID references with actual URLs if (email.attachments && email.attachments.length > 0) { - html = replaceCidReferences(html, id, email.attachments, options.baseUrl) + html = replaceCidReferences(html, id, email.attachments, options.baseUrl, options.basePath) } return html diff --git a/packages/smtp/src/types.ts b/packages/smtp/src/types.ts index 427e956..1b81d6b 100644 --- a/packages/smtp/src/types.ts +++ b/packages/smtp/src/types.ts @@ -218,8 +218,10 @@ export interface RelayResult { * HTML rendering options for email */ export interface HTMLRenderOptions { - /** Base URL for attachment URLs */ + /** Host to prefix attachment URLs with; unset gives a root-relative URL */ baseUrl?: string + /** Pathname the API is mounted under, e.g. `/mail` */ + basePath?: string } /**