mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat: add HTML view for individual email entries at /entries/:feedId/:receivedAt
Serves each email as a standalone HTML page with a Content-Security-Policy header, useful for reading emails outside a feed reader and for debugging. Also updates RSS item links to point to this route. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { handle as handleInbound } from "./routes/inbound";
|
import { handle as handleInbound } from "./routes/inbound";
|
||||||
import { handle as handleRSS } from "./routes/rss";
|
import { handle as handleRSS } from "./routes/rss";
|
||||||
import { handle as handleAdmin } from "./routes/admin";
|
import { handle as handleAdmin } from "./routes/admin";
|
||||||
|
import { handle as handleEntry } from "./routes/entries";
|
||||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||||
import { Env } from "./types";
|
import { Env } from "./types";
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ app.use("*", async (c, next) => {
|
|||||||
// Group routes by functionality
|
// Group routes by functionality
|
||||||
const api = new Hono();
|
const api = new Hono();
|
||||||
const rss = new Hono();
|
const rss = new Hono();
|
||||||
|
const entries = new Hono();
|
||||||
const admin = new Hono();
|
const admin = new Hono();
|
||||||
|
|
||||||
// Webhook security middleware for /inbound - verify ForwardEmail.net IP
|
// Webhook security middleware for /inbound - verify ForwardEmail.net IP
|
||||||
@@ -131,12 +133,16 @@ api.post("/inbound", handleInbound);
|
|||||||
// RSS feed routes (public)
|
// RSS feed routes (public)
|
||||||
rss.get("/:feedId", handleRSS);
|
rss.get("/:feedId", handleRSS);
|
||||||
|
|
||||||
|
// Email entry HTML view (public)
|
||||||
|
entries.get("/:feedId/:entryId", handleEntry);
|
||||||
|
|
||||||
// Admin routes (protected)
|
// Admin routes (protected)
|
||||||
admin.route("/", handleAdmin);
|
admin.route("/", handleAdmin);
|
||||||
|
|
||||||
// Mount the route groups
|
// Mount the route groups
|
||||||
app.route("/api", api);
|
app.route("/api", api);
|
||||||
app.route("/rss", rss);
|
app.route("/rss", rss);
|
||||||
|
app.route("/entries", entries);
|
||||||
app.route("/admin", admin);
|
app.route("/admin", admin);
|
||||||
|
|
||||||
// Root path redirects to admin dashboard
|
// Root path redirects to admin dashboard
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Env, FeedMetadata, EmailData } from "../types";
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handle(c: Context): Promise<Response> {
|
||||||
|
const env = c.env as unknown as Env;
|
||||||
|
const feedId = c.req.param("feedId");
|
||||||
|
const receivedAt = parseInt(c.req.param("entryId"), 10);
|
||||||
|
|
||||||
|
if (!feedId || isNaN(receivedAt)) {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
const feedMetadata = (await emailStorage.get(
|
||||||
|
`feed:${feedId}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as FeedMetadata | null;
|
||||||
|
if (!feedMetadata) {
|
||||||
|
return new Response("Feed not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaEntry = feedMetadata.emails.find((e) => e.receivedAt === receivedAt);
|
||||||
|
if (!metaEntry) {
|
||||||
|
return new Response("Entry not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailData = (await emailStorage.get(
|
||||||
|
metaEntry.key,
|
||||||
|
"json",
|
||||||
|
)) as EmailData | null;
|
||||||
|
if (!emailData) {
|
||||||
|
return new Response("Entry not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${escapeHtml(emailData.subject)}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 1rem; }
|
||||||
|
.meta { color: #666; font-size: 0.875rem; margin-bottom: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.75rem; }
|
||||||
|
.meta dt { display: inline; font-weight: bold; }
|
||||||
|
.meta dd { display: inline; margin: 0 1rem 0 0.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escapeHtml(emailData.subject)}</h1>
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>From:</dt><dd>${escapeHtml(emailData.from)}</dd>
|
||||||
|
<dt>Date:</dt><dd>${escapeHtml(new Date(emailData.receivedAt).toUTCString())}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="content">${emailData.content}</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"Content-Security-Policy":
|
||||||
|
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user