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}
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 = '
';
+ 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: