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
+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>