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:
1 parent
be45e70571
commit
5137637181
14 files changed
+277
-31
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user