feat(attachments): render inline cid images in place, not as attachments

Inline images (referenced by src="cid:…") are now classified at ingest and
kept out of the downloadable attachment lists, RSS/Atom enclosures, and the
API — while still stored in R2 and cleaned up with the email. Fixes the admin
email preview, which injected raw HTML into the data: iframe so cid refs never
resolved; it now rewrites them to absolute /files URLs.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 14:39:59 +02:00
1 parent be45e70571
commit 5137637181
14 files changed
+277 -31

No files matched your search

+98
View File
@@ -437,6 +437,104 @@ describe("processEmail — attachments", () => {
expect(typeof metadata.emails[0].attachmentIds[0]).toBe("string");
});
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
const env = createMockEnv({ withR2: true });
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const inlineImage: RawAttachment = {
filename: "logo.png",
contentType: "image/png",
content: new TextEncoder().encode("PNG").buffer as ArrayBuffer,
contentId: "logo123",
};
await processEmail(
makeInput({
content: '<p>Hi</p><img src="cid:logo123"/>',
attachments: [inlineImage, pdfAttachment],
}),
env as any,
);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
const inline = emailData.attachments.find(
(a: any) => a.filename === "logo.png",
);
const pdf = emailData.attachments.find(
(a: any) => a.filename === "report.pdf",
);
expect(inline.inline).toBe(true);
expect(pdf.inline).toBeUndefined();
// Metadata splits ids: the pdf is downloadable, the logo is inline-only.
expect(metadata.emails[0].attachmentIds).toEqual([pdf.id]);
expect(metadata.emails[0].inlineAttachmentIds).toEqual([inline.id]);
});
it("deletes inline image R2 objects when a trimmed email had them", async () => {
const env = createMockEnv({ withR2: true });
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const oldKey = `feed:${VALID_FEED_ID}:111`;
const inlineId = "old-inline-uuid";
const oldEmail = JSON.stringify({
subject: "Old",
from: "[email protected]",
content: "x".repeat(200) + '<img src="cid:c"/>',
receivedAt: 111,
headers: {},
attachments: [
{
id: inlineId,
filename: "logo.png",
contentType: "image/png",
size: 100,
contentId: "c",
inline: true,
},
],
});
await env.EMAIL_STORAGE.put(oldKey, oldEmail);
await mockR2.put(inlineId, new ArrayBuffer(100));
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({
emails: [
{
key: oldKey,
subject: "Old",
receivedAt: 111,
size: oldEmail.length,
inlineAttachmentIds: [inlineId],
},
],
}),
);
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
const res = await processEmail(
makeInput({ subject: "New" }),
tinyEnv as any,
);
expect(res.ok).toBe(true);
expect(mockR2._has(inlineId)).toBe(false);
});
it("deletes R2 objects when a trimmed email had attachments", async () => {
const env = createMockEnv({ withR2: true });
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
+15 -6
View File
@@ -5,6 +5,8 @@ import { dispatchFeedEvents } from "../application/feed-events";
import { extractEmailDomain } from "../infrastructure/favicon-fetcher";
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
import { getAttachmentBucket } from "../infrastructure/attachments";
import { extractInlineCids } from "../infrastructure/html-processor";
import { attachmentIdsForCleanup } from "./feed-cleanup";
import { FeedRepository } from "../infrastructure/feed-repository";
import { BackgroundScheduler } from "../infrastructure/worker";
import { Feed } from "../domain/feed.aggregate";
@@ -47,14 +49,16 @@ export type IngestResult =
async function uploadAttachments(
attachments: RawAttachment[],
bucket: R2Bucket,
inlineCids: Set<string>,
): Promise<AttachmentData[]> {
return Promise.all(
attachments.map(async (att) => {
const id = crypto.randomUUID();
const inline = att.contentId ? inlineCids.has(att.contentId) : false;
await bucket.put(id, att.content, {
httpMetadata: {
contentType: att.contentType,
contentDisposition: `attachment; filename="${att.filename}"`,
contentDisposition: `${inline ? "inline" : "attachment"}; filename="${att.filename}"`,
},
});
return {
@@ -63,6 +67,7 @@ async function uploadAttachments(
contentType: att.contentType,
size: att.content.byteLength,
...(att.contentId ? { contentId: att.contentId } : {}),
...(inline ? { inline: true } : {}),
};
}),
);
@@ -111,9 +116,10 @@ async function storeEmail(
ctx?: ExecutionContext,
): Promise<void> {
const attachmentBucket = getAttachmentBucket(env);
const inlineCids = extractInlineCids(input.content);
const storedAttachments: AttachmentData[] =
attachmentBucket && input.attachments?.length
? await uploadAttachments(input.attachments, attachmentBucket)
? await uploadAttachments(input.attachments, attachmentBucket, inlineCids)
: [];
const emailData = {
@@ -132,14 +138,17 @@ async function storeEmail(
const serialisedSize = new TextEncoder().encode(
JSON.stringify(emailData),
).byteLength;
const downloadableIds = storedAttachments
.filter((a) => !a.inline)
.map((a) => a.id);
const inlineIds = storedAttachments.filter((a) => a.inline).map((a) => a.id);
const newEntry: EmailMetadata = {
key: emailKey,
subject: emailData.subject,
receivedAt: emailData.receivedAt,
size: serialisedSize,
...(storedAttachments.length > 0
? { attachmentIds: storedAttachments.map((a) => a.id) }
: {}),
...(downloadableIds.length > 0 ? { attachmentIds: downloadableIds } : {}),
...(inlineIds.length > 0 ? { inlineAttachmentIds: inlineIds } : {}),
};
// Track the latest sender's domain (feed icon) and capture the RFC 8058
@@ -166,7 +175,7 @@ async function storeEmail(
const r2Deletions =
attachmentBucket && dropped.length > 0
? dropped
.flatMap((e) => e.attachmentIds ?? [])
.flatMap((e) => attachmentIdsForCleanup(e))
.map((id) => attachmentBucket.delete(id))
: [];
+9 -2
View File
@@ -4,9 +4,16 @@ import { getAttachmentBucket } from "../infrastructure/attachments";
import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "../domain/value-objects/feed-id";
// All R2 object ids an email owns — both downloadable attachments and inline
// images. Inline images are hidden from the user-facing lists but must still be
// purged from the bucket when the email is deleted.
export function attachmentIdsForCleanup(e: EmailMetadata): string[] {
return [...(e.attachmentIds ?? []), ...(e.inlineAttachmentIds ?? [])];
}
// Delete the R2 attachments belonging to the given email keys. Call before the
// emails are removed from feed metadata, while `emails` still carries their
// attachmentIds.
// attachment ids.
export async function deleteAttachmentsForEmails(
env: Env,
emails: readonly EmailMetadata[],
@@ -15,7 +22,7 @@ export async function deleteAttachmentsForEmails(
const keySet = new Set(keys);
const attachmentIds = emails
.filter((e) => keySet.has(e.key))
.flatMap((e) => e.attachmentIds ?? []);
.flatMap((e) => attachmentIdsForCleanup(e));
if (attachmentIds.length === 0) return;
const bucket = getAttachmentBucket(env);