From 6cd2d425a2595dc983bc387655139d2c2e558063 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 23 May 2026 18:11:29 +0200 Subject: [PATCH] feat(attachments): list downloadable attachments on admin email detail page The admin email detail view loaded the full email but never rendered its attachments, so there was no way to download them from the admin UI (only the public entry view and the feed enclosure exposed them). Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- src/routes/admin.test.ts | 61 +++++++++++++++++++++++++++++++++++++ src/routes/admin/emails.tsx | 34 +++++++++++++++++++++ src/styles/components.css | 39 ++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1821dad..f1768fd 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ FEED_MAX_SIZE_BYTES = "524288" # 512 KB — adjust as needed ### Email attachments (R2) -When an incoming email contains attachments, the Worker can store them in a Cloudflare R2 bucket and expose them as `` elements in the RSS feed (and `` in Atom). Each attachment is served at `/files/{id}/{filename}` with an immutable cache header. +When an incoming email contains attachments, the Worker can store them in a Cloudflare R2 bucket and expose them as `` elements in the RSS feed (and `` in Atom). Each attachment is served at `/files/{id}/{filename}` with an immutable cache header. Attachments are also listed with download links on the admin email detail page and the public entry view. This feature is **optional**. If no R2 bucket is bound, attachments are silently ignored and nothing else changes. diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 4b7178d..ea70941 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -729,6 +729,67 @@ describe("Admin Routes", () => { expect(indicatorCount).toBe(1); }); + it("lists attachments with download links on the email detail page", async () => { + const authCookie = await loginAndGetCookie(); + const feedId = "detail-feed"; + const emailKey = `feed:${feedId}:1`; + await mockEnv.EMAIL_STORAGE.put( + emailKey, + JSON.stringify({ + subject: "With attachments", + from: "sender@example.com", + content: "

hello

", + receivedAt: 1, + headers: {}, + attachments: [ + { + id: "att-123", + filename: "report final.pdf", + contentType: "application/pdf", + size: 2048, + }, + ], + }), + ); + + const res = await request(`/admin/emails/${emailKey}`, { + headers: { Cookie: authCookie }, + }); + expect(res.status).toBe(200); + const body = await res.text(); + + expect(body).toContain("Attachments"); + expect(body).toContain( + `/files/att-123/${encodeURIComponent("report final.pdf")}`, + ); + expect(body).toContain("report final.pdf"); + expect(body).toContain("2.0 KB"); + }); + + it("does not render an attachments section when the email has none", async () => { + const authCookie = await loginAndGetCookie(); + const feedId = "detail-feed"; + const emailKey = `feed:${feedId}:2`; + await mockEnv.EMAIL_STORAGE.put( + emailKey, + JSON.stringify({ + subject: "No attachments", + from: "sender@example.com", + content: "

hello

", + receivedAt: 2, + headers: {}, + }), + ); + + const res = await request(`/admin/emails/${emailKey}`, { + headers: { Cookie: authCookie }, + }); + expect(res.status).toBe(200); + const body = await res.text(); + + expect(body).not.toContain("Attachments"); + }); + it("form-based bulk-delete also removes R2 attachments", async () => { const r2Env = createMockEnv({ withR2: true }) as unknown as Env; const bucket = r2Env.ATTACHMENT_BUCKET as unknown as { diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index 517cbc1..76d69b7 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -13,6 +13,7 @@ import { deleteKeysWithConcurrency, } from "./helpers"; import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls"; +import { formatBytes } from "../../utils/format"; import { emailsPageScript } from "../../scripts/generated/emails-page"; type AppEnv = { Bindings: Env }; @@ -470,6 +471,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => { if (!emailData) return c.text("Email not found", 404); const feedId = emailKey.split(":")[1]; + const attachments = emailData.attachments ?? []; const htmlContent = `${emailData.content}`; @@ -606,6 +608,38 @@ emailsRouter.get("/emails/:emailKey", async (c) => {

             
           
+
+          {attachments.length > 0 && (
+            
+

Attachments

+
    + {attachments.map((a) => ( +
  • + + + {a.filename} + + {formatBytes(a.size)} +
  • + ))} +
+
+ )} diff --git a/src/styles/components.css b/src/styles/components.css index 97e14a0..b03af62 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -787,6 +787,45 @@ table.table code { color: var(--color-text-secondary); } +.attachments { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-border); +} + +.attachments h2 { + font-size: var(--font-size-md); + margin: 0 0 var(--spacing-sm); +} + +.attachment-list { + list-style: none; + padding: 0; + margin: 0; +} + +.attachment-list li { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) 0; +} + +.attachment-list svg { + flex: 0 0 auto; + color: var(--color-text-secondary); +} + +.attachment-list a { + color: var(--color-primary); + word-break: break-all; +} + +.attachment-size { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + /* Compact copy-to-clipboard for table cells */ .copyable.copyable-inline { margin-bottom: 0;