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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 18:11:29 +02:00
parent 9141cf89bd
commit 6cd2d425a2
4 changed files with 135 additions and 1 deletions
+1 -1
View File
@@ -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 `<enclosure>` elements in the RSS feed (and `<link rel="enclosure">` 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 `<enclosure>` elements in the RSS feed (and `<link rel="enclosure">` 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.
+61
View File
@@ -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: "<p>hello</p>",
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: "<p>hello</p>",
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 {
+34
View File
@@ -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 = `<!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>`;
@@ -606,6 +608,38 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
<pre dangerouslySetInnerHTML={{ __html: rawHtml }}></pre>
</div>
</div>
{attachments.length > 0 && (
<div class="attachments">
<h2>Attachments</h2>
<ul class="attachment-list">
{attachments.map((a) => (
<li>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
<a
href={`/files/${a.id}/${encodeURIComponent(a.filename)}`}
download
>
{a.filename}
</a>
<span class="attachment-size">{formatBytes(a.size)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
+39
View File
@@ -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;