mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user