From 766f2717a7fee196020d5c9acc3c0ba07cc6b6f7 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 23 May 2026 14:46:25 +0200 Subject: [PATCH] feat(entries): list email attachments with download links The email detail page loaded the full EmailData (including attachments) but never rendered them, so attachments were invisible. Add a conditional "Attachments" section linking each file to /files/:id/:filename with name and human-readable size. Co-Authored-By: Claude Opus 4.7 --- src/routes/entries.test.ts | 39 +++++++++++++++++++++++++++++- src/routes/entries.ts | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/routes/entries.test.ts b/src/routes/entries.test.ts index 6946a2a..c6628a7 100644 --- a/src/routes/entries.test.ts +++ b/src/routes/entries.test.ts @@ -13,7 +13,15 @@ function makeApp() { return app; } -async function seedFeed(env: ReturnType) { +async function seedFeed( + env: ReturnType, + attachments?: { + id: string; + filename: string; + contentType: string; + size: number; + }[], +) { await env.EMAIL_STORAGE.put( EMAIL_KEY, JSON.stringify({ @@ -22,6 +30,7 @@ async function seedFeed(env: ReturnType) { content: "

Email body

", receivedAt: RECEIVED_AT, headers: {}, + ...(attachments ? { attachments } : {}), }), ); await env.EMAIL_STORAGE.put( @@ -97,6 +106,34 @@ describe("GET /entries/:feedId/:entryId", () => { expect(body).toContain("sender@example.com"); }); + it("lists attachments with download links when present", async () => { + await seedFeed(env, [ + { + id: "att-123", + filename: "report final.pdf", + contentType: "application/pdf", + size: 2048, + }, + ]); + const app = makeApp(); + const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any); + const body = await res.text(); + expect(body).toContain("Attachments"); + expect(body).toContain("report final.pdf"); + expect(body).toContain( + `/files/att-123/${encodeURIComponent("report final.pdf")}`, + ); + expect(body).toContain("2.0 KB"); + }); + + it("does not render an attachments section when there are none", async () => { + await seedFeed(env); + const app = makeApp(); + const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any); + const body = await res.text(); + expect(body).not.toContain("Attachments"); + }); + it("sets Content-Security-Policy header", async () => { await seedFeed(env); const app = makeApp(); diff --git a/src/routes/entries.ts b/src/routes/entries.ts index b0af909..26afc5b 100644 --- a/src/routes/entries.ts +++ b/src/routes/entries.ts @@ -3,6 +3,12 @@ import { html, raw } from "hono/html"; import { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; import { processEmailContent } from "../utils/html-processor"; +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export async function handle(c: Context<{ Bindings: Env }>): Promise { const feedId = c.req.param("feedId"); const receivedAt = parseInt(c.req.param("entryId") ?? "", 10); @@ -53,6 +59,26 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { "default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'", ); + const attachments = emailData.attachments ?? []; + const attachmentsSection = attachments.length + ? html`
+

Attachments

+
    + ${attachments.map( + (a) => + html`
  • + ${a.filename} + ${formatBytes(a.size)} +
  • `, + )} +
+
` + : ""; + return c.html( html` @@ -86,6 +112,28 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { display: inline; margin: 0 1rem 0 0.25rem; } + .attachments { + margin-top: 2rem; + border-top: 1px solid #eee; + padding-top: 1rem; + } + .attachments h2 { + font-size: 1rem; + margin: 0 0 0.5rem; + } + .attachments ul { + list-style: none; + padding: 0; + margin: 0; + } + .attachments li { + margin: 0.25rem 0; + } + .attachments .size { + color: #666; + font-size: 0.875rem; + margin-left: 0.5rem; + } @@ -99,6 +147,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise {
${raw(processEmailContent(emailData.content))}
+ ${attachmentsSection} `, );