diff --git a/src/lib/cloudflare-email.ts b/src/lib/cloudflare-email.ts index d0fd7b4..0155b7f 100644 --- a/src/lib/cloudflare-email.ts +++ b/src/lib/cloudflare-email.ts @@ -1,6 +1,7 @@ import PostalMime from "postal-mime"; import { Env } from "../types"; import { processEmail, RawAttachment } from "./email-processor"; +import { normalizeCid } from "../utils/html-processor"; export async function handleCloudflareEmail( message: ForwardableEmailMessage, @@ -27,6 +28,7 @@ export async function handleCloudflareEmail( filename: a.filename || "attachment", contentType: a.mimeType || "application/octet-stream", content: a.content as ArrayBuffer, + contentId: normalizeCid(a.contentId), })); await processEmail( diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index 5d2d13b..3e9d787 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -21,6 +21,7 @@ export interface RawAttachment { filename: string; contentType: string; content: ArrayBuffer; + contentId?: string; } export interface ProcessEmailInput { @@ -88,6 +89,7 @@ async function uploadAttachments( filename: att.filename, contentType: att.contentType, size: att.content.byteLength, + ...(att.contentId ? { contentId: att.contentId } : {}), }; }), ); diff --git a/src/lib/forwardemail.ts b/src/lib/forwardemail.ts index e11eae7..72c7c4a 100644 --- a/src/lib/forwardemail.ts +++ b/src/lib/forwardemail.ts @@ -1,11 +1,14 @@ import { EmailParser } from "../utils/email-parser"; import { Env } from "../types"; import { processEmail, RawAttachment } from "./email-processor"; +import { normalizeCid } from "../utils/html-processor"; export interface ForwardEmailAttachment { filename?: string; contentType?: string; size?: number; + cid?: string; + contentId?: string; content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView; } @@ -73,13 +76,14 @@ export async function handleForwardEmail( const emailData = EmailParser.parseForwardEmailPayload(payload); const rawAttachments: RawAttachment[] = (payload.attachments ?? []) - .map((a) => { + .map((a): RawAttachment | null => { const buffer = toArrayBuffer(a.content); if (!buffer) return null; return { filename: a.filename || "attachment", contentType: a.contentType || "application/octet-stream", content: buffer, + contentId: normalizeCid(a.cid ?? a.contentId), }; }) .filter((a): a is RawAttachment => a !== null); diff --git a/src/routes/entries.ts b/src/routes/entries.ts index df39253..e9fe890 100644 --- a/src/routes/entries.ts +++ b/src/routes/entries.ts @@ -140,7 +140,9 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise {
${new Date(emailData.receivedAt).toUTCString()}
- ${raw(processEmailContent(emailData.content))} + ${raw( + processEmailContent(emailData.content, emailData.attachments), + )}
${attachmentsSection} diff --git a/src/routes/inbound.test.ts b/src/routes/inbound.test.ts index 4b97760..9c15730 100644 --- a/src/routes/inbound.test.ts +++ b/src/routes/inbound.test.ts @@ -256,6 +256,47 @@ describe("POST /api/inbound — attachment upload", () => { expect(mockR2._has(attachmentId)).toBe(true); }); + it("persists the attachment Content-ID and rewrites inline cid: images on the entry page", async () => { + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + const payload = makePayload({ + html: '

hi

pic', + attachments: [ + { + filename: "pic.png", + contentType: "image/png", + cid: "ii_mpi85rqy0", + content: { type: "Buffer", data: [137, 80, 78] }, + }, + ], + }); + const res = await worker.fetch(makeRequest(payload), env); + expect(res.status).toBe(200); + + const metadata = (await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + { type: "json" }, + )) as any; + const emailData = (await env.EMAIL_STORAGE.get(metadata.emails[0].key, { + type: "json", + })) as any; + const attachmentId = emailData.attachments[0].id; + expect(emailData.attachments[0].contentId).toBe("ii_mpi85rqy0"); + + const entryRes = await worker.fetch( + new Request( + `https://${DOMAIN}/entries/${VALID_FEED_ID}/${metadata.emails[0].receivedAt}`, + ), + env, + ); + expect(entryRes.status).toBe(200); + const html = await entryRes.text(); + expect(html).toContain(`/files/${attachmentId}/pic.png`); + expect(html).not.toContain("cid:ii_mpi85rqy0"); + }); + it("skips R2 when attachment content is null", async () => { await env.EMAIL_STORAGE.put( `feed:${VALID_FEED_ID}:config`, diff --git a/src/types/index.ts b/src/types/index.ts index 0afe603..4757fb4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,6 +18,7 @@ export interface AttachmentData { filename: string; contentType: string; size: number; + contentId?: string; // Normalized Content-ID (no <>) used to resolve inline cid: refs } // Email interface for stored emails diff --git a/src/utils/feed-generator.ts b/src/utils/feed-generator.ts index f4379d3..7d2bcbc 100644 --- a/src/utils/feed-generator.ts +++ b/src/utils/feed-generator.ts @@ -55,7 +55,11 @@ function buildFeed( for (const email of emails) { const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`; const firstAttachment = email.attachments?.[0]; - const bodyContent = processEmailContent(email.content); + const bodyContent = processEmailContent( + email.content, + email.attachments, + baseUrl, + ); feed.addItem({ title: email.subject, id: entryUrl, diff --git a/src/utils/html-processor.test.ts b/src/utils/html-processor.test.ts index e64b471..376e931 100644 --- a/src/utils/html-processor.test.ts +++ b/src/utils/html-processor.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { processEmailContent } from "./html-processor"; +import type { AttachmentData } from "../types"; describe("processEmailContent — body extraction", () => { it("extracts content inside tags", () => { @@ -123,3 +124,75 @@ describe("processEmailContent — mso style cleanup", () => { expect(result).not.toContain("mso-font-size"); }); }); + +describe("processEmailContent — inline cid: rewriting", () => { + const attachment = ( + overrides: Partial = {}, + ): AttachmentData => ({ + id: "att-123", + filename: "chicken big.png", + contentType: "image/png", + size: 100, + contentId: "ii_mpi85rqy0", + ...overrides, + }); + + it("rewrites cid: src to a relative /files URL when no baseUrl", () => { + const html = 'x'; + const result = processEmailContent(html, [attachment()]); + expect(result).toContain('src="/files/att-123/chicken%20big.png"'); + expect(result).not.toContain("cid:"); + }); + + it("rewrites cid: src to an absolute URL when baseUrl is given", () => { + const html = ''; + const result = processEmailContent( + html, + [attachment()], + "https://feed.example", + ); + expect(result).toContain( + 'src="https://feed.example/files/att-123/chicken%20big.png"', + ); + }); + + it("matches a stored Content-ID that has angle brackets", () => { + const html = ''; + const result = processEmailContent(html, [ + attachment({ contentId: "" }), + ]); + expect(result).toContain('src="/files/att-123/chicken%20big.png"'); + }); + + it("is case-insensitive on the cid: scheme", () => { + const html = ''; + const result = processEmailContent(html, [attachment()]); + expect(result).toContain('src="/files/att-123/chicken%20big.png"'); + }); + + it("leaves unknown cid references unchanged", () => { + const html = ''; + const result = processEmailContent(html, [attachment()]); + expect(result).toContain('src="cid:unknown"'); + }); + + it("leaves cid references unchanged when no attachments are provided", () => { + const html = ''; + const result = processEmailContent(html); + expect(result).toContain('src="cid:ii_mpi85rqy0"'); + }); + + it("ignores attachments without a contentId", () => { + const html = ''; + const result = processEmailContent(html, [ + attachment({ contentId: undefined }), + ]); + expect(result).toContain('src="cid:ii_mpi85rqy0"'); + }); + + it("does not touch normal http image sources", () => { + const html = ''; + const result = processEmailContent(html, [attachment()]); + expect(result).toContain('src="https://example.com/a.png"'); + }); +}); diff --git a/src/utils/html-processor.ts b/src/utils/html-processor.ts index c9d1dfd..22c1032 100644 --- a/src/utils/html-processor.ts +++ b/src/utils/html-processor.ts @@ -1,5 +1,16 @@ import { parseHTML } from "linkedom"; import escapeHtml from "escape-html"; +import { AttachmentData } from "../types"; + +// Strip surrounding angle brackets and whitespace from a Content-ID so that a +// stored value like "" matches an HTML reference "cid:ii_mpi85rqy0". +export function normalizeCid( + cid: string | null | undefined, +): string | undefined { + if (!cid) return undefined; + const trimmed = cid.trim().replace(/^<|>$/g, "").trim(); + return trimmed || undefined; +} function cleanMsoStyles(style: string): string { return style @@ -13,7 +24,27 @@ function isPlainText(content: string): boolean { return !/<[a-z][\s\S]*>/i.test(content); } -function sanitizeElement(el: Element): void { +function rewriteCidSrc( + el: Element, + cidMap: Map, + baseUrl: string, +): void { + const src = el.getAttribute("src") ?? ""; + const match = src.match(/^\s*cid:(.+)$/i); + if (!match) return; + const attachment = cidMap.get(normalizeCid(match[1]) ?? ""); + if (!attachment) return; + el.setAttribute( + "src", + `${baseUrl}/files/${attachment.id}/${encodeURIComponent(attachment.filename)}`, + ); +} + +function sanitizeElement( + el: Element, + cidMap: Map, + baseUrl: string, +): void { // Snapshot attribute names before mutating (linkedom attributes is array-like) const attrs = Array.from( el.attributes as unknown as ArrayLike<{ name: string }>, @@ -33,6 +64,9 @@ function sanitizeElement(el: Element): void { } } } + if (cidMap.size > 0) { + rewriteCidSrc(el, cidMap, baseUrl); + } // Strip mso-* inline style properties (Office HTML noise) const style = el.getAttribute("style"); if (style !== null) { @@ -52,22 +86,39 @@ function sanitizeElement(el: Element): void { * - Removes dangerous elements: