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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 23:26:16 +02:00
parent b3a979fd03
commit 2a3aeb8a18
5 changed files with 64 additions and 1 deletions
+2 -1
View File
@@ -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(
+6
View File
@@ -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,
+33
View File
@@ -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: "<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).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 {
+9
View File
@@ -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) => {
<button id="raw-button" class="toggle-button" onclick="showRaw()">
Raw HTML
</button>
<a
class="toggle-view-link"
href={entryPath(feedId, emailData.receivedAt)}
target="_blank"
rel="noopener noreferrer"
>
Public page
</a>
</div>
<div class="email-content">
+14
View File
@@ -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);