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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 14:39:59 +02:00
parent be45e70571
commit 5137637181
14 changed files with 277 additions and 31 deletions
+45
View File
@@ -766,6 +766,51 @@ describe("Admin Routes", () => {
expect(body).toContain("2.0 KB");
});
it("renders inline cid images in place and hides them from the attachments list", async () => {
const authCookie = await loginAndGetCookie();
const feedId = "detail-feed";
const emailKey = `feed:${feedId}:3`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
JSON.stringify({
subject: "With inline image",
from: "sender@example.com",
content: '<p>hello</p><img src="cid:logo123"/>',
receivedAt: 3,
headers: {},
attachments: [
{
id: "img-1",
filename: "logo.png",
contentType: "image/png",
size: 512,
contentId: "logo123",
inline: true,
},
],
}),
);
const res = await request(`/admin/emails/${emailKey}`, {
headers: { Cookie: authCookie },
});
expect(res.status).toBe(200);
const body = await res.text();
// The rendered preview is a base64 data: iframe; decode and inspect it.
const match = body.match(/data:text\/html;base64,([A-Za-z0-9+/=]+)/);
expect(match).not.toBeNull();
const decoded = Buffer.from(match![1], "base64").toString("utf-8");
// cid: is rewritten to an absolute /files URL so it resolves in the iframe.
expect(decoded).toContain(
"https://test.getmynews.app/files/img-1/logo.png",
);
expect(decoded).not.toContain("cid:logo123");
// Inline image is not surfaced as a downloadable attachment.
expect(body).not.toContain("Attachments");
});
it("does not render an attachments section when the email has none", async () => {
const authCookie = await loginAndGetCookie();
const feedId = "detail-feed";
+13 -2
View File
@@ -12,7 +12,9 @@ import {
feedRssUrl,
feedAtomUrl,
feedEmailAddress,
baseUrl,
} from "../../infrastructure/urls";
import { processEmailContent } from "../../infrastructure/html-processor";
import { formatBytes } from "../../domain/format";
import { EmailAddress } from "../../domain/value-objects/email-address";
import { emailsPageScript } from "../../scripts/generated/emails-page";
@@ -463,9 +465,18 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
if (!emailData) return c.text("Email not found", 404);
const feedId = repo.feedIdFromEmailKey(emailKey);
const attachments = emailData.attachments ?? [];
// Inline images render in place; only downloadable attachments go in the list.
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
const htmlContent = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','SF Pro Display','Helvetica Neue',Arial,sans-serif;line-height:1.5;padding:16px;margin:0;color:#333;box-sizing:border-box}img{max-width:100%;height:auto}a{color:#0070f3}@media(prefers-color-scheme:dark){body{background-color:#1c1c1e;color:#ffffff}a{color:#0a84ff}}</style></head><body>${emailData.content}</body></html>`;
// The rendered preview lives in a `data:` iframe, which has no origin to
// resolve relative URLs against — so cid: refs must be rewritten to absolute
// /files URLs (and the content sanitized) before embedding.
const renderedBody = processEmailContent(
emailData.content,
emailData.attachments,
baseUrl(env),
);
const htmlContent = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','SF Pro Display','Helvetica Neue',Arial,sans-serif;line-height:1.5;padding:16px;margin:0;color:#333;box-sizing:border-box}img{max-width:100%;height:auto}a{color:#0070f3}@media(prefers-color-scheme:dark){body{background-color:#1c1c1e;color:#ffffff}a{color:#0a84ff}}</style></head><body>${renderedBody}</body></html>`;
const encodedHtmlContent = (() => {
const encoder = new TextEncoder();
+9 -7
View File
@@ -325,13 +325,15 @@ apiApp.openapi(
from: data.from,
receivedAt: data.receivedAt,
content: data.content,
attachments: (data.attachments ?? []).map((a) => ({
id: a.id,
filename: a.filename,
contentType: a.contentType,
size: a.size,
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
})),
attachments: (data.attachments ?? [])
.filter((a) => !a.inline)
.map((a) => ({
id: a.id,
filename: a.filename,
contentType: a.contentType,
size: a.size,
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
})),
},
200,
);
+29 -1
View File
@@ -20,14 +20,17 @@ async function seedFeed(
filename: string;
contentType: string;
size: number;
contentId?: string;
inline?: boolean;
}[],
content = "<p>Email body</p>",
) {
await env.EMAIL_STORAGE.put(
EMAIL_KEY,
JSON.stringify({
subject: "Test Subject",
from: "sender@example.com",
content: "<p>Email body</p>",
content,
receivedAt: RECEIVED_AT,
headers: {},
...(attachments ? { attachments } : {}),
@@ -126,6 +129,31 @@ describe("GET /entries/:feedId/:entryId", () => {
expect(body).toContain("2.0 KB");
});
it("renders inline images in place and omits them from the attachments list", async () => {
await seedFeed(
env,
[
{
id: "img-1",
filename: "logo.png",
contentType: "image/png",
size: 512,
contentId: "logo123",
inline: true,
},
],
'<p>Body</p><img src="cid:logo123"/>',
);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
// The cid: ref is rewritten to the stored file URL (rendered in place)…
expect(body).toContain('src="/files/img-1/logo.png"');
expect(body).not.toContain("cid:logo123");
// …and the image is not listed as a downloadable attachment.
expect(body).not.toContain("Attachments");
});
it("does not render an attachments section when there are none", async () => {
await seedFeed(env);
const app = makeApp();
+3 -1
View File
@@ -46,7 +46,9 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
);
const attachments = emailData.attachments ?? [];
// Inline images render in place (cid: refs are rewritten by processEmailContent);
// only genuine, downloadable attachments belong in the list below.
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
const attachmentsSection = attachments.length
? html`<section class="attachments">
<h2>Attachments</h2>