fix(feed): add Atom link in emails page, fix HTML stripping, use request URL for self-link

- Add Atom Feed URL to the Feed Details card in the emails page
- Fix extractBodyContent to handle emails without a closing </body> tag
  (regex now falls back to capturing everything after the opening <body>)
- Use the actual request URL origin for atom:link rel="self" in RSS/Atom
  feeds, guaranteeing it always matches the document location regardless
  of how DOMAIN is configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-22 18:41:21 +02:00
parent bcc9640591
commit 4428f35dd4
4 changed files with 31 additions and 7 deletions
+2 -1
View File
@@ -93,7 +93,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
const emailAddress = `${feedId}@${env.DOMAIN}`; const emailAddress = `${feedId}@${env.DOMAIN}`;
const rssUrl = `https://${env.DOMAIN}/rss/${feedId}`; const rssUrl = `https://${env.DOMAIN}/rss/${feedId}`;
const atomUrl = `https://${env.DOMAIN}/atom/${feedId}`;
return c.html( return c.html(
<Layout title={`${feedConfig.title} - Emails`}> <Layout title={`${feedConfig.title} - Emails`}>
@@ -114,6 +114,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
<div> <div>
<CopyField label="Email Address:" value={emailAddress} /> <CopyField label="Email Address:" value={emailAddress} />
<CopyField label="RSS Feed:" value={rssUrl} /> <CopyField label="RSS Feed:" value={rssUrl} />
<CopyField label="Atom Feed:" value={atomUrl} />
</div> </div>
</div> </div>
+2
View File
@@ -16,11 +16,13 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
} }
const baseUrl = `https://${c.env.DOMAIN}`; const baseUrl = `https://${c.env.DOMAIN}`;
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
const atomXml = generateAtomFeed( const atomXml = generateAtomFeed(
feedData.feedConfig, feedData.feedConfig,
feedData.emails, feedData.emails,
baseUrl, baseUrl,
feedId, feedId,
selfUrl,
); );
const linkHeader = [ const linkHeader = [
`<${baseUrl}/hub>; rel="hub"`, `<${baseUrl}/hub>; rel="hub"`,
+2
View File
@@ -16,11 +16,13 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
} }
const baseUrl = `https://${c.env.DOMAIN}`; const baseUrl = `https://${c.env.DOMAIN}`;
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
const rssXml = generateRssFeed( const rssXml = generateRssFeed(
feedData.feedConfig, feedData.feedConfig,
feedData.emails, feedData.emails,
baseUrl, baseUrl,
feedId, feedId,
selfUrl,
); );
const linkHeader = [ const linkHeader = [
`<${baseUrl}/hub>; rel="hub"`, `<${baseUrl}/hub>; rel="hub"`,
+25 -6
View File
@@ -16,8 +16,12 @@ function parseFromAddress(from: string): { name: string; email?: string } {
// Email content is stored as a full HTML document. Feed readers expect only // Email content is stored as a full HTML document. Feed readers expect only
// the body fragment in <description>/<content:encoded>, not a full document. // the body fragment in <description>/<content:encoded>, not a full document.
export function extractBodyContent(html: string): string { export function extractBodyContent(html: string): string {
const match = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i); const withClose = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return match ? match[1] : html; if (withClose) return withClose[1];
// Some HTML emails omit </body>; capture everything after the opening tag
const withoutClose = html.match(/<body[^>]*>([\s\S]*)/i);
if (withoutClose) return withoutClose[1].replace(/<\/html>\s*$/i, "");
return html;
} }
function buildFeed( function buildFeed(
@@ -25,6 +29,7 @@ function buildFeed(
emails: EmailData[], emails: EmailData[],
baseUrl: string, baseUrl: string,
feedId: string, feedId: string,
selfUrl?: { rss?: string; atom?: string },
): Feed { ): Feed {
const feed = new Feed({ const feed = new Feed({
title: feedConfig.title, title: feedConfig.title,
@@ -39,8 +44,8 @@ function buildFeed(
generator: "kill-the-news", generator: "kill-the-news",
copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`, copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`,
feedLinks: { feedLinks: {
rss: `${baseUrl}/rss/${feedId}`, rss: selfUrl?.rss ?? `${baseUrl}/rss/${feedId}`,
atom: `${baseUrl}/atom/${feedId}`, atom: selfUrl?.atom ?? `${baseUrl}/atom/${feedId}`,
}, },
author: feedConfig.author author: feedConfig.author
? { ? {
@@ -80,8 +85,15 @@ export function generateRssFeed(
emails: EmailData[], emails: EmailData[],
baseUrl: string, baseUrl: string,
feedId: string, feedId: string,
selfUrl?: string,
): string { ): string {
return buildFeed(feedConfig, emails, baseUrl, feedId).rss2(); return buildFeed(
feedConfig,
emails,
baseUrl,
feedId,
selfUrl ? { rss: selfUrl } : undefined,
).rss2();
} }
export function generateAtomFeed( export function generateAtomFeed(
@@ -89,6 +101,13 @@ export function generateAtomFeed(
emails: EmailData[], emails: EmailData[],
baseUrl: string, baseUrl: string,
feedId: string, feedId: string,
selfUrl?: string,
): string { ): string {
return buildFeed(feedConfig, emails, baseUrl, feedId).atom1(); return buildFeed(
feedConfig,
emails,
baseUrl,
feedId,
selfUrl ? { atom: selfUrl } : undefined,
).atom1();
} }