mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -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();
|
||||||
|
|||||||
@@ -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>`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user