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 ↗ +
diff --git a/src/styles/components.css b/src/styles/components.css index f07e557..8bf0b98 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -554,6 +554,20 @@ textarea:focus { border-color: transparent; } +.toggle-view-link { + margin-left: auto; + align-self: center; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + text-decoration: none; + transition: color var(--transition-fast); +} + +.toggle-view-link:hover { + color: var(--color-primary); +} + /* Email content container */ .email-content { margin-top: var(--spacing-md);