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;