From 2a3aeb8a18879232dc0be548e4a00876e604fda6 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 24 May 2026 23:26:16 +0200 Subject: [PATCH] feat(admin): link email detail to its public entry page Add a "Public page" link next to the Rendered/Raw toggle in the admin email view, opening the standalone /entries/:feedId/:entryId render. Centralize the entry route shape in a pure entryPath() builder, used by both the admin link and the RSS/Atom/JSON feed generator. Co-Authored-By: Claude Opus 4.7 --- src/infrastructure/feed-generator.ts | 3 ++- src/infrastructure/urls.ts | 6 +++++ src/routes/admin.test.ts | 33 ++++++++++++++++++++++++++++ src/routes/admin/emails.tsx | 9 ++++++++ src/styles/components.css | 14 ++++++++++++ 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/feed-generator.ts b/src/infrastructure/feed-generator.ts index 867520a..fad14f7 100644 --- a/src/infrastructure/feed-generator.ts +++ b/src/infrastructure/feed-generator.ts @@ -2,6 +2,7 @@ import { Feed } from "feed"; import { FeedConfig, EmailData } from "../types"; import { processEmailContent, htmlToText } from "./html-processor"; import { EmailAddress } from "../domain/value-objects/email-address"; +import { entryPath } from "./urls"; export { processEmailContent as extractBodyContent }; @@ -64,7 +65,7 @@ function buildFeed( }); for (const email of emails) { - const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`; + const entryUrl = `${baseUrl}${entryPath(feedId, email.receivedAt)}`; // Inline images are rendered in the body, not surfaced as an enclosure. const firstAttachment = email.attachments?.find((a) => !a.inline); const bodyContent = processEmailContent( diff --git a/src/infrastructure/urls.ts b/src/infrastructure/urls.ts index 72e494e..f73f540 100644 --- a/src/infrastructure/urls.ts +++ b/src/infrastructure/urls.ts @@ -17,6 +17,12 @@ export function feedJsonUrl(feedId: string, env: Env): string { return `${baseUrl(env)}/json/${feedId}`; } +/** Path of an email's public HTML view. The single source of truth for the + * `/entries/:feedId/:entryId` route shape (entryId = the email's receivedAt). */ +export function entryPath(feedId: string, receivedAt: number): string { + return `/entries/${feedId}/${receivedAt}`; +} + export function feedUrl( format: "rss" | "atom", feedId: string, diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index b32780b..abfb51b 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -890,6 +890,39 @@ describe("Admin Routes", () => { expect(body).not.toContain("Attachments"); }); + it("links to the public entry page using the feed id and receivedAt", async () => { + const authCookie = await loginAndGetCookie(); + const feedId = "detail-feed"; + await mockEnv.EMAIL_STORAGE.put( + `feed:${feedId}:config`, + JSON.stringify({ + title: "Detail Feed", + mailbox_id: "detail.feed.10", + language: "en", + created_at: 1, + }), + ); + const emailKey = `feed:${feedId}:2`; + await mockEnv.EMAIL_STORAGE.put( + emailKey, + JSON.stringify({ + subject: "Linkable", + 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).toContain(`href="/entries/${feedId}/2"`); + }); + 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 5b16d8e..d30b488 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -13,6 +13,7 @@ import { feedAtomUrl, feedEmailAddress, baseUrl, + entryPath, } from "../../infrastructure/urls"; import { processEmailContent } from "../../infrastructure/html-processor"; import { formatBytes } from "../../domain/format"; @@ -604,6 +605,14 @@ emailsRouter.get("/emails/:emailKey", async (c) => { + + Public page ↗ +