diff --git a/src/lib/email-processor.test.ts b/src/lib/email-processor.test.ts
index 01fd5be..3ada63b 100644
--- a/src/lib/email-processor.test.ts
+++ b/src/lib/email-processor.test.ts
@@ -220,8 +220,6 @@ describe("processEmail", () => {
JSON.stringify({
title: "Test",
language: "en",
- site_url: "https://example.com",
- feed_url: `https://example.com/rss/${VALID_FEED_ID}`,
created_at: Date.now(),
}),
);
diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx
index f865d84..c751011 100644
--- a/src/routes/admin.tsx
+++ b/src/routes/admin.tsx
@@ -8,6 +8,7 @@ import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
import { logger } from "../lib/logger";
import { Layout, clampText } from "./admin/ui";
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
+import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
import { feedsRouter } from "./admin/feeds";
import { emailsRouter } from "./admin/emails";
import { dashboardScript } from "../scripts/generated/dashboard";
@@ -601,9 +602,9 @@ app.get("/", async (c) => {
{feedsWithConfig.map((feed) => {
- const emailAddress = `${feed.id}@${env.DOMAIN}`;
- const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
- const atomUrl = `https://${env.DOMAIN}/atom/${feed.id}`;
+ const emailAddress = feedEmailAddress(feed.id, env);
+ const rssUrl = feedRssUrl(feed.id, env);
+ const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 160);
const titleHover = clampText(feed.title, 1000);
const sortTitle = titleHover.toLowerCase();
@@ -712,9 +713,9 @@ app.get("/", async (c) => {
{feedsWithConfig.map((feed) => {
- const emailAddress = `${feed.id}@${env.DOMAIN}`;
- const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
- const atomUrl = `https://${env.DOMAIN}/atom/${feed.id}`;
+ const emailAddress = feedEmailAddress(feed.id, env);
+ const rssUrl = feedRssUrl(feed.id, env);
+ const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 140);
const titleHover = clampText(feed.title, 1000);
const descDisplay = clampText(feed.description || "", 240);
diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx
index eca9ae0..b8639ba 100644
--- a/src/routes/admin/emails.tsx
+++ b/src/routes/admin/emails.tsx
@@ -9,6 +9,7 @@ import {
import { logger } from "../../lib/logger";
import { Layout, clampText } from "./ui";
import { deleteKeysWithConcurrency } from "./helpers";
+import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
import { emailsPageScript } from "../../scripts/generated/emails-page";
type AppEnv = { Bindings: Env };
@@ -91,9 +92,9 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
return c.text("Feed not found", 404);
}
- const emailAddress = `${feedId}@${env.DOMAIN}`;
- const rssUrl = `https://${env.DOMAIN}/rss/${feedId}`;
- const atomUrl = `https://${env.DOMAIN}/atom/${feedId}`;
+ const emailAddress = feedEmailAddress(feedId, env);
+ const rssUrl = feedRssUrl(feedId, env);
+ const atomUrl = feedAtomUrl(feedId, env);
return c.html(
@@ -426,7 +427,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx
index 0e8711d..4715510 100644
--- a/src/routes/admin/feeds.tsx
+++ b/src/routes/admin/feeds.tsx
@@ -3,6 +3,7 @@ import { z } from "zod";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types";
import { generateFeedId } from "../../utils/id-generator";
import { waitUntilSafe } from "../../utils/worker";
+import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
import { logger } from "../../lib/logger";
import { Layout } from "./ui";
import {
@@ -192,8 +193,6 @@ feedsRouter.post("/create", async (c) => {
title: parsedData.title,
description: parsedData.description,
language: parsedData.language,
- site_url: `https://${env.DOMAIN}/rss/${feedId}`,
- feed_url: `https://${env.DOMAIN}/rss/${feedId}`,
allowed_senders: parsedData.allowedSenders,
created_at: Date.now(),
updated_at: Date.now(),
@@ -216,8 +215,8 @@ feedsRouter.post("/create", async (c) => {
if (isJson) {
return c.json({
feedId,
- email: `${feedId}@${env.DOMAIN}`,
- feedUrl: feedConfig.feed_url,
+ email: feedEmailAddress(feedId, env),
+ feedUrl: feedRssUrl(feedId, env),
});
}
diff --git a/src/routes/atom.test.ts b/src/routes/atom.test.ts
index 07dafc8..d465978 100644
--- a/src/routes/atom.test.ts
+++ b/src/routes/atom.test.ts
@@ -81,8 +81,6 @@ describe("Atom Feed Route", () => {
JSON.stringify({
title: "Atom Test Feed",
description: "Integration test",
- site_url: "https://test.getmynews.app/rss/test-feed-atom",
- feed_url: "https://test.getmynews.app/rss/test-feed-atom",
language: "en",
created_at: 1700000000000,
}),
diff --git a/src/routes/atom.ts b/src/routes/atom.ts
index dcde4be..5bf318e 100644
--- a/src/routes/atom.ts
+++ b/src/routes/atom.ts
@@ -2,6 +2,7 @@ import { Context } from "hono";
import { Env } from "../types";
import { generateAtomFeed } from "../utils/feed-generator";
import { fetchFeedData } from "../utils/feed-fetcher";
+import { baseUrl, feedAtomUrl } from "../utils/urls";
export async function handle(c: Context<{ Bindings: Env }>): Promise {
try {
@@ -10,23 +11,23 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise {
return new Response("Feed ID is required", { status: 400 });
}
- const feedData = await fetchFeedData(feedId, c.env, "atom");
+ const feedData = await fetchFeedData(feedId, c.env);
if (!feedData) {
return new Response("Feed not found", { status: 404 });
}
- const baseUrl = `https://${c.env.DOMAIN}`;
+ const base = baseUrl(c.env);
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
const atomXml = generateAtomFeed(
feedData.feedConfig,
feedData.emails,
- baseUrl,
+ base,
feedId,
selfUrl,
);
const linkHeader = [
- `<${baseUrl}/hub>; rel="hub"`,
- `<${baseUrl}/atom/${feedId}>; rel="self"`,
+ `<${base}/hub>; rel="hub"`,
+ `<${feedAtomUrl(feedId, c.env)}>; rel="self"`,
].join(", ");
return new Response(atomXml, {
diff --git a/src/routes/hub.ts b/src/routes/hub.ts
index 461c300..ac7bcc3 100644
--- a/src/routes/hub.ts
+++ b/src/routes/hub.ts
@@ -6,6 +6,7 @@ import {
} from "../utils/websub";
import { waitUntilSafe } from "../utils/worker";
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
+import { feedTopicPattern } from "../utils/urls";
type AppEnv = { Bindings: Env };
@@ -60,9 +61,7 @@ hubRouter.post("/", async (c) => {
}
// Validate that topic matches a known RSS or Atom feed on this hub
- const topicPattern = new RegExp(
- `^https://${env.DOMAIN.replaceAll(".", "\\.")}/(rss|atom)/([^/]+)$`,
- );
+ const topicPattern = feedTopicPattern(env);
const match = topic.match(topicPattern);
if (!match) {
return c.text(
diff --git a/src/routes/rss.ts b/src/routes/rss.ts
index 87a0acf..ef7dbcf 100644
--- a/src/routes/rss.ts
+++ b/src/routes/rss.ts
@@ -2,6 +2,7 @@ import { Context } from "hono";
import { Env } from "../types";
import { generateRssFeed } from "../utils/feed-generator";
import { fetchFeedData } from "../utils/feed-fetcher";
+import { baseUrl, feedRssUrl } from "../utils/urls";
export async function handle(c: Context<{ Bindings: Env }>): Promise {
try {
@@ -10,23 +11,23 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise {
return new Response("Feed ID is required", { status: 400 });
}
- const feedData = await fetchFeedData(feedId, c.env, "rss");
+ const feedData = await fetchFeedData(feedId, c.env);
if (!feedData) {
return new Response("Feed not found", { status: 404 });
}
- const baseUrl = `https://${c.env.DOMAIN}`;
+ const base = baseUrl(c.env);
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
const rssXml = generateRssFeed(
feedData.feedConfig,
feedData.emails,
- baseUrl,
+ base,
feedId,
selfUrl,
);
const linkHeader = [
- `<${baseUrl}/hub>; rel="hub"`,
- `<${baseUrl}/rss/${feedId}>; rel="self"`,
+ `<${base}/hub>; rel="hub"`,
+ `<${feedRssUrl(feedId, c.env)}>; rel="self"`,
].join(", ");
return new Response(rssXml, {
diff --git a/src/types/index.ts b/src/types/index.ts
index 919430d..3bee2a4 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -3,6 +3,7 @@ export interface Env {
EMAIL_STORAGE: KVNamespace;
ADMIN_PASSWORD: string;
DOMAIN: string;
+ EMAIL_DOMAIN?: string;
ATTACHMENT_BUCKET?: R2Bucket;
FEED_MAX_SIZE_BYTES?: string;
PROXY_TRUSTED_IPS?: string;
@@ -33,8 +34,6 @@ export interface FeedConfig {
description?: string;
allowed_senders?: string[];
language: string;
- site_url: string;
- feed_url: string;
author?: string;
created_at: number;
updated_at?: number;
diff --git a/src/utils/feed-fetcher.ts b/src/utils/feed-fetcher.ts
index 6e1c18f..dddbf32 100644
--- a/src/utils/feed-fetcher.ts
+++ b/src/utils/feed-fetcher.ts
@@ -9,7 +9,6 @@ export interface FeedData {
export async function fetchFeedData(
feedId: string,
env: Env,
- feedPath: "rss" | "atom",
): Promise {
const storage = env.EMAIL_STORAGE;
@@ -26,8 +25,6 @@ export async function fetchFeedData(
)) as FeedConfig | null) ?? {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
- site_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
- feed_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
language: "en",
created_at: Date.now(),
};
diff --git a/src/utils/feed-generator.test.ts b/src/utils/feed-generator.test.ts
index 4f67980..c8a20dd 100644
--- a/src/utils/feed-generator.test.ts
+++ b/src/utils/feed-generator.test.ts
@@ -9,8 +9,6 @@ import { FeedConfig, EmailData } from "../types";
const mockFeedConfig: FeedConfig = {
title: "Test Newsletter",
description: "A test feed",
- site_url: "https://test.getmynews.app/rss/abc123",
- feed_url: "https://test.getmynews.app/rss/abc123",
language: "en",
created_at: 1700000000000,
};
diff --git a/src/utils/urls.ts b/src/utils/urls.ts
new file mode 100644
index 0000000..955b953
--- /dev/null
+++ b/src/utils/urls.ts
@@ -0,0 +1,30 @@
+import { Env } from "../types";
+
+export function baseUrl(env: Env): string {
+ return `https://${env.DOMAIN}`;
+}
+
+export function feedRssUrl(feedId: string, env: Env): string {
+ return `${baseUrl(env)}/rss/${feedId}`;
+}
+
+export function feedAtomUrl(feedId: string, env: Env): string {
+ return `${baseUrl(env)}/atom/${feedId}`;
+}
+
+export function feedUrl(
+ format: "rss" | "atom",
+ feedId: string,
+ env: Env,
+): string {
+ return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
+}
+
+export function feedEmailAddress(feedId: string, env: Env): string {
+ return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`;
+}
+
+export function feedTopicPattern(env: Env): RegExp {
+ const escaped = env.DOMAIN.replaceAll(".", "\\.");
+ return new RegExp(`^https://${escaped}/(rss|atom)/([^/]+)$`);
+}
diff --git a/src/utils/websub.test.ts b/src/utils/websub.test.ts
index fc521e6..abf8717 100644
--- a/src/utils/websub.test.ts
+++ b/src/utils/websub.test.ts
@@ -101,8 +101,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
- site_url: "https://example.com",
- feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -141,8 +139,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
- site_url: "https://example.com",
- feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -181,8 +177,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
- site_url: "https://example.com",
- feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -222,8 +216,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
- site_url: "https://example.com",
- feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -269,8 +261,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
- site_url: "https://example.com",
- feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
diff --git a/src/utils/websub.ts b/src/utils/websub.ts
index 64d007b..274d200 100644
--- a/src/utils/websub.ts
+++ b/src/utils/websub.ts
@@ -6,6 +6,7 @@ import {
WebSubSubscription,
} from "../types";
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
+import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
const KV_PREFIX = "websub:subs:";
@@ -67,12 +68,10 @@ async function buildFeedXml(
const feedMetadata = rawMetadata as FeedMetadata | null;
if (!feedMetadata) return null;
- const baseUrl = `https://${env.DOMAIN}`;
+ const base = baseUrl(env);
const feedConfig = (rawConfig as FeedConfig | null) ?? {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
- site_url: `${baseUrl}/rss/${feedId}`,
- feed_url: `${baseUrl}/rss/${feedId}`,
language: "en",
created_at: Date.now(),
};
@@ -91,12 +90,12 @@ async function buildFeedXml(
return generateAtomFeed(
feedConfig,
emailsData,
- baseUrl,
+ base,
feedId,
- `${baseUrl}/atom/${feedId}`,
+ feedAtomUrl(feedId, env),
);
}
- return generateRssFeed(feedConfig, emailsData, baseUrl, feedId);
+ return generateRssFeed(feedConfig, emailsData, base, feedId);
}
export async function notifySubscribers(
@@ -124,7 +123,7 @@ export async function notifySubscribers(
if (!rssFeed && !atomFeed) return;
- const baseUrl = `https://${env.DOMAIN}`;
+ const base = baseUrl(env);
const deliver = async (
sub: WebSubSubscription,
@@ -132,7 +131,7 @@ export async function notifySubscribers(
contentType: string,
selfPath: string,
) => {
- const linkHeader = `<${baseUrl}/hub>; rel="hub", <${baseUrl}${selfPath}>; rel="self"`;
+ const linkHeader = `<${base}/hub>; rel="hub", <${base}${selfPath}>; rel="self"`;
const headers: Record = {
"Content-Type": contentType,
Link: linkHeader,
@@ -173,6 +172,26 @@ export async function notifySubscribers(
}
}
+async function verifyCallback(
+ callbackUrl: string,
+ params: Record,
+): Promise {
+ const challenge = crypto.randomUUID().replace(/-/g, "");
+ const url = new URL(callbackUrl);
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
+ url.searchParams.set("hub.challenge", challenge);
+
+ let res: Response;
+ try {
+ res = await fetch(url.toString());
+ } catch {
+ return false;
+ }
+
+ if (!res.ok) return false;
+ return (await res.text()).trim() === challenge;
+}
+
export async function verifyAndStoreSubscription(
feedId: string,
callbackUrl: string,
@@ -181,24 +200,12 @@ export async function verifyAndStoreSubscription(
format: "rss" | "atom",
env: Env,
): Promise {
- const challenge = crypto.randomUUID().replace(/-/g, "");
- const topicUrl = `https://${env.DOMAIN}/${format}/${feedId}`;
- const verifyUrl = new URL(callbackUrl);
- verifyUrl.searchParams.set("hub.mode", "subscribe");
- verifyUrl.searchParams.set("hub.topic", topicUrl);
- verifyUrl.searchParams.set("hub.challenge", challenge);
- verifyUrl.searchParams.set("hub.lease_seconds", String(leaseSeconds));
-
- let res: Response;
- try {
- res = await fetch(verifyUrl.toString());
- } catch {
- return false;
- }
-
- if (!res.ok) return false;
- const body = await res.text();
- if (body.trim() !== challenge) return false;
+ const verified = await verifyCallback(callbackUrl, {
+ "hub.mode": "subscribe",
+ "hub.topic": feedUrl(format, feedId, env),
+ "hub.lease_seconds": String(leaseSeconds),
+ });
+ if (!verified) return false;
const subs = await getSubscriptions(feedId, env);
const idx = subs.findIndex((s) => s.callbackUrl === callbackUrl);
@@ -222,23 +229,11 @@ export async function verifyAndDeleteSubscription(
callbackUrl: string,
env: Env,
): Promise {
- const challenge = crypto.randomUUID().replace(/-/g, "");
- const topicUrl = `https://${env.DOMAIN}/rss/${feedId}`;
- const verifyUrl = new URL(callbackUrl);
- verifyUrl.searchParams.set("hub.mode", "unsubscribe");
- verifyUrl.searchParams.set("hub.topic", topicUrl);
- verifyUrl.searchParams.set("hub.challenge", challenge);
-
- let res: Response;
- try {
- res = await fetch(verifyUrl.toString());
- } catch {
- return false;
- }
-
- if (!res.ok) return false;
- const body = await res.text();
- if (body.trim() !== challenge) return false;
+ const verified = await verifyCallback(callbackUrl, {
+ "hub.mode": "unsubscribe",
+ "hub.topic": feedRssUrl(feedId, env),
+ });
+ if (!verified) return false;
const subs = await getSubscriptions(feedId, env);
await saveSubscriptions(
diff --git a/wrangler-example.toml b/wrangler-example.toml
index 8555f91..2ff9d3b 100644
--- a/wrangler-example.toml
+++ b/wrangler-example.toml
@@ -26,7 +26,11 @@ invocation_logs = true
# Global Environment variables
[vars]
-DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Your custom domain for emails
+DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin UI)
+
+# Optional: email domain when it differs from the web domain
+# Example: DOMAIN = "demo.example.com" but emails are @example.com
+# EMAIL_DOMAIN = "REPLACE_WITH_YOUR_EMAIL_DOMAIN"
# Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB)
# FEED_MAX_SIZE_BYTES = "524288"
@@ -83,6 +87,7 @@ routes = [
[env.demo.vars]
DOMAIN = "demo.kill-the.news"
+EMAIL_DOMAIN = "kill-the.news" # Optional: email domain when it differs from the web domain
# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean
[env.demo.triggers]