From ed77334df21c1c22b9f497ad9ae0cc2bb5f2209b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=22DualFroz=22=20Fox?= Date: Tue, 22 Sep 2026 20:00:23 +0200 Subject: [PATCH] Build CID attachment URLs against the API route and base path (#582) * fix: build CID attachment URLs against the API route and base path replaceCidReferences rewrote cid: references to /email/:id/attachment/:file, which matches no route: attachments are served from the API server, mounted at ${basePath}/api. When MailDev sits under a subpath the prefix was missing as well, which is the setup described in #439. Emit ${basePath}/api/email/:id/attachment/:file, root-relative by default, and thread the API server's basePath through getEmailHtml. baseUrl still prefixes //host for a caller that passes it. On the current code this does not change what the web UI shows for a typical message: mailparser already inlines related parts as data: URIs, and a cid: that survives parsing is one that matches no attachment. replaceCidReferences is exported from @maildev/smtp, though, so the URL it builds should resolve. Refs #439 * fix: omit the base path prefix when it resolves to the root A caller passing basePath '/' produced //api/email/..., which a browser reads as a protocol-relative URL rather than a path. Strip the slashes first and only add a prefix when something is left. --- .changeset/fix-cid-attachment-url.md | 11 +++++ packages/api/src/server.ts | 4 +- packages/smtp/src/__tests__/sanitize.test.ts | 43 ++++++++++++++++++-- packages/smtp/src/sanitize.ts | 18 +++++--- packages/smtp/src/server.ts | 2 +- packages/smtp/src/types.ts | 4 +- 6 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-cid-attachment-url.md diff --git a/.changeset/fix-cid-attachment-url.md b/.changeset/fix-cid-attachment-url.md new file mode 100644 index 0000000..aec7c6f --- /dev/null +++ b/.changeset/fix-cid-attachment-url.md @@ -0,0 +1,11 @@ +--- +'@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`. A base path that resolves to the root (`/`) +adds no prefix, so it can no longer produce a protocol-relative `//api/email/...`. 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..015f580 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,44 @@ 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 treat basePath "/" as no prefix', () => { + const html = '' + const attachments = [{ contentId: 'image123', generatedFileName: 'abc.png' }] + const result = replaceCidReferences(html, 'email1', attachments, undefined, '/') + expect(result).toContain('src="/api/email/email1/attachment/abc.png"') + }) + + it('should combine baseUrl and basePath', () => { + const html = '' + const attachments = [{ contentId: 'image123', generatedFileName: 'abc.png' }] + const result = replaceCidReferences(html, 'email1', attachments, 'example.com', '/mail') + expect(result).toContain('src="//example.com/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..a50db3a 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,19 @@ 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 segments = basePath ? basePath.replace(/^\/+|\/+$/g, '') : '' + const prefix = segments ? `/${segments}` : '' + 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 } /**