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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 14:46:25 +02:00
parent d322bc1e92
commit 766f2717a7
2 changed files with 87 additions and 1 deletions
+38 -1
View File
@@ -13,7 +13,15 @@ function makeApp() {
return app; return app;
} }
async function seedFeed(env: ReturnType<typeof createMockEnv>) { async function seedFeed(
env: ReturnType<typeof createMockEnv>,
attachments?: {
id: string;
filename: string;
contentType: string;
size: number;
}[],
) {
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
EMAIL_KEY, EMAIL_KEY,
JSON.stringify({ JSON.stringify({
@@ -22,6 +30,7 @@ async function seedFeed(env: ReturnType<typeof createMockEnv>) {
content: "<p>Email body</p>", content: "<p>Email body</p>",
receivedAt: RECEIVED_AT, receivedAt: RECEIVED_AT,
headers: {}, headers: {},
...(attachments ? { attachments } : {}),
}), }),
); );
await env.EMAIL_STORAGE.put( await env.EMAIL_STORAGE.put(
@@ -97,6 +106,34 @@ describe("GET /entries/:feedId/:entryId", () => {
expect(body).toContain("sender@example.com"); 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 () => { it("sets Content-Security-Policy header", async () => {
await seedFeed(env); await seedFeed(env);
const app = makeApp(); const app = makeApp();
+49
View File
@@ -3,6 +3,12 @@ import { html, raw } from "hono/html";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
import { processEmailContent } from "../utils/html-processor"; 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<Response> { export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
const feedId = c.req.param("feedId"); const feedId = c.req.param("feedId");
const receivedAt = parseInt(c.req.param("entryId") ?? "", 10); const receivedAt = parseInt(c.req.param("entryId") ?? "", 10);
@@ -53,6 +59,26 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'", "default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
); );
const attachments = emailData.attachments ?? [];
const attachmentsSection = attachments.length
? html`<section class="attachments">
<h2>Attachments</h2>
<ul>
${attachments.map(
(a) =>
html`<li>
<a
href="/files/${a.id}/${encodeURIComponent(a.filename)}"
download
>${a.filename}</a
>
<span class="size">${formatBytes(a.size)}</span>
</li>`,
)}
</ul>
</section>`
: "";
return c.html( return c.html(
html`<!DOCTYPE html> html`<!DOCTYPE html>
<html> <html>
@@ -86,6 +112,28 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
display: inline; display: inline;
margin: 0 1rem 0 0.25rem; 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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -99,6 +147,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
<div class="content"> <div class="content">
${raw(processEmailContent(emailData.content))} ${raw(processEmailContent(emailData.content))}
</div> </div>
${attachmentsSection}
</body> </body>
</html>`, </html>`,
); );