,
+) {
+ const what = describeScope(scope, directory);
+ return cadence === 'weekly'
+ ? `${what}, gathered into one email a week`
+ : `${what}, as it publishes`;
+}
diff --git a/site/server/app.ts b/site/server/app.ts
index 932685f..26ee954 100644
--- a/site/server/app.ts
+++ b/site/server/app.ts
@@ -29,6 +29,7 @@ import type { Share } from './share.ts';
import type { Reads } from './reads.ts';
import type { Settings } from './settings.ts';
import type { Snapshot } from './snapshot.ts';
+import type { Subscribers } from './subscribers.ts';
import type { Sync } from './sync.ts';
export interface Services {
@@ -46,6 +47,7 @@ export interface Services {
writeback: Writeback;
uploads: Uploads;
posts: Posts;
+ subscribers: Subscribers;
collab: Collab;
auth: Auth;
live: Live;
@@ -200,6 +202,22 @@ export function createApp(
app.use(auth.router);
app.get('/live', live.handler);
+ app.get('/admin/email', async (request, response) => {
+ const signedIn = await auth.current(request, response);
+ const kind = String(request.query.kind ?? '');
+ if (!signedIn?.session.owner || !['confirm', 'post', 'digest'].includes(kind)) {
+ response.status(404).end();
+ return;
+ }
+ response.setHeader(
+ 'Content-Security-Policy',
+ "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' https: data:",
+ );
+ response.setHeader('Cache-Control', 'no-store');
+ response
+ .type('html')
+ .send(services.subscribers.preview(kind as 'confirm' | 'post' | 'digest').html);
+ });
app.use(
'/uploads',
express.static(uploadsDirectory, {
@@ -248,6 +266,7 @@ export function createApp(
writeback: services.writeback,
uploads: services.uploads,
posts: services.posts,
+ subscribers: services.subscribers,
collab: services.collab,
address: clientAddress(request),
origin: new URL(config.appUrl).origin,
diff --git a/site/server/digest.test.ts b/site/server/digest.test.ts
new file mode 100644
index 0000000..3eaf5c3
--- /dev/null
+++ b/site/server/digest.test.ts
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { digestFor, nextDigestAt, spreadDue } from './digest.ts';
+
+describe('digest timing', () => {
+ it('finds the next digest day and hour in UTC', () => {
+ const friday = Date.parse('2026-11-06T12:00:00.000Z');
+ assert.equal(nextDigestAt(friday, 'monday', 8), '2026-11-09T08:00:00.000Z');
+ assert.equal(nextDigestAt(friday, 'friday', 13), '2026-11-06T13:00:00.000Z');
+ assert.equal(nextDigestAt(friday, 'friday', 8), '2026-11-13T08:00:00.000Z');
+ assert.equal(nextDigestAt(friday, 'sunday', 0), '2026-11-08T00:00:00.000Z');
+ });
+
+ it('spreads sends across the hour at the configured rate', () => {
+ const now = Date.parse('2026-11-09T08:00:00.000Z');
+ assert.equal(spreadDue(now, 0, 20), '2026-11-09T08:00:00.000Z');
+ assert.equal(spreadDue(now, 1, 20), '2026-11-09T08:03:00.000Z');
+ assert.equal(spreadDue(now, 20, 20), '2026-11-09T09:00:00.000Z');
+ });
+});
+
+describe('digest contents', () => {
+ const posts = [
+ { id: 'a', author: 'amrmscll', project: 'strata' },
+ { id: 'b', author: 'omar', project: null },
+ { id: 'c', author: 'amrmscll', project: null },
+ ];
+
+ it('keeps the posts a scope covers and nothing for a scope it cannot read', () => {
+ assert.deepEqual(digestFor('all', posts), posts);
+ assert.deepEqual(
+ digestFor('project:strata', posts).map((post) => post.id),
+ ['a'],
+ );
+ assert.deepEqual(
+ digestFor('member:amrmscll', posts).map((post) => post.id),
+ ['a', 'c'],
+ );
+ assert.deepEqual(digestFor('member:nobody', posts), []);
+ assert.deepEqual(digestFor('garbage', posts), []);
+ });
+});
diff --git a/site/server/digest.ts b/site/server/digest.ts
new file mode 100644
index 0000000..92c4052
--- /dev/null
+++ b/site/server/digest.ts
@@ -0,0 +1,26 @@
+import { WEEKDAYS } from '../app/settings.ts';
+import { parseScope, scopeMatches } from '../app/subscribe.ts';
+
+const DAY_MS = 86_400_000;
+
+export function nextDigestAt(now: number, day: string, hourUtc: number) {
+ const wanted = (WEEKDAYS.indexOf(day as (typeof WEEKDAYS)[number]) + 1) % 7;
+ const next = new Date(now);
+ next.setUTCHours(hourUtc, 0, 0, 0);
+ while (next.getUTCDay() !== wanted || next.getTime() <= now) {
+ next.setTime(next.getTime() + DAY_MS);
+ }
+ return next.toISOString();
+}
+
+export function digestFor(
+ scope: string,
+ posts: P[],
+) {
+ const parsed = parseScope(scope);
+ return parsed ? posts.filter((post) => scopeMatches(parsed, post)) : [];
+}
+
+export function spreadDue(now: number, index: number, perHour: number) {
+ return new Date(now + Math.round((index * 3_600_000) / perHour)).toISOString();
+}
diff --git a/site/server/emails.ts b/site/server/emails.ts
new file mode 100644
index 0000000..fd8ec69
--- /dev/null
+++ b/site/server/emails.ts
@@ -0,0 +1,256 @@
+import type { Values } from '../app/settings.ts';
+import { escapeXml as escape } from './feeds.ts';
+
+export interface EmailLinks {
+ manage: string;
+ unsubscribe: string;
+}
+
+export interface EmailPost {
+ title: string;
+ summary: string;
+ excerpt: string;
+ url: string;
+ coverUrl: string | null;
+ author: { name: string; url: string; accent: string };
+ project: { name: string; url: string } | null;
+ publishedAt: string;
+ readingMinutes: number;
+}
+
+export type EmailInput =
+ | { kind: 'confirm'; what: string; confirmUrl: string }
+ | { kind: 'manage'; what: string; links: EmailLinks }
+ | { kind: 'post'; what: string; post: EmailPost; links: EmailLinks }
+ | { kind: 'digest'; what: string; posts: EmailPost[]; since: string; links: EmailLinks };
+
+export interface RenderedEmail {
+ subject: string;
+ html: string;
+ text: string;
+}
+
+export interface EmailSite {
+ name: string;
+ url: string;
+}
+
+const PALETTE = {
+ paper: '#0c0c0e',
+ surface: '#131316',
+ line: '#26262c',
+ ink: '#e8e8ea',
+ muted: '#8b8b95',
+ faint: '#7d7d87',
+ live: '#46b97a',
+};
+const SANS = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
+const MONO = "ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+export function fill(template: string, values: Record) {
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => values[key] ?? match);
+}
+
+export function excerptOf(html: string, words = 70) {
+ const paragraphs = [...html.matchAll(/]*)?>([\s\S]*?)<\/p>/g)].map((match) => match[1]);
+ const text = (paragraphs.length > 0 ? paragraphs.join(' ') : html)
+ .replace(/<(pre|script|style)[\s\S]*?<\/\1>/g, ' ')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim();
+ const parts = text.split(' ');
+ return parts.length > words ? `${parts.slice(0, words).join(' ')}…` : text;
+}
+
+function day(iso: string) {
+ const date = new Date(iso);
+ return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
+}
+
+function button(label: string, url: string, accent = PALETTE.ink) {
+ return `
`;
+}
+
+function paragraph(text: string, color = PALETTE.muted) {
+ return `${escape(text)}
`;
+}
+
+function meta(parts: string[]) {
+ return `${parts.map(escape).join(' · ')}
`;
+}
+
+function postCard(post: EmailPost, full: boolean) {
+ const cover = post.coverUrl
+ ? `
`
+ : '';
+ const about = post.project
+ ? [
+ `about ${escape(post.project.name)}`,
+ ]
+ : [];
+ return `|
+${full ? cover : ''}
+${meta([day(post.publishedAt), `${post.readingMinutes} min read`])}
+
+ by ${escape(post.author.name)}${about.length ? ` ${about[0]}` : ''}
+${post.summary ? paragraph(post.summary, full ? PALETTE.ink : PALETTE.muted) : ''}
+${full && post.excerpt ? paragraph(post.excerpt) : ''}
+${full ? button('Read the post', post.url, post.author.accent) : `read it →`}
+ |
`;
+}
+
+function postText(post: EmailPost, full: boolean) {
+ return [
+ post.title,
+ `by ${post.author.name}${post.project ? ` about ${post.project.name}` : ''} · ${day(post.publishedAt)} · ${post.readingMinutes} min read`,
+ post.summary,
+ full ? post.excerpt : '',
+ post.url,
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+function shell(site: EmailSite, title: string, body: string, footer: string, accent: string) {
+ return `
+
+
+
+
+
+
+${escape(title)}
+
+
+${escape(title)}
+
+
+`;
+}
+
+function footerLinks(links: EmailLinks) {
+ return `preferences · unsubscribe`;
+}
+
+export function renderEmail(input: EmailInput, settings: Values, site: EmailSite): RenderedEmail {
+ const common = { site: site.name, what: input.what };
+ const footerLine = fill(settings['email.footer'], common);
+ switch (input.kind) {
+ case 'confirm': {
+ const subject = fill(settings['email.confirm.subject'], common);
+ const intro = fill(settings['email.confirm.body'], common);
+ return {
+ subject,
+ html: shell(
+ site,
+ subject,
+ `${escape(subject)}
${paragraph(intro)}${button('Confirm', input.confirmUrl, PALETTE.live)}`,
+ `${escape(site.name)} · ${escape(site.url.replace(/^https?:\/\//, ''))}`,
+ PALETTE.live,
+ ),
+ text: [subject, '', intro, '', `Confirm: ${input.confirmUrl}`, '', site.url].join('\n'),
+ };
+ }
+ case 'manage': {
+ const subject = `Your subscription to ${site.name}`;
+ const intro = `This address already gets ${input.what}. Change what you get, or stop, from the links below.`;
+ return {
+ subject,
+ html: shell(
+ site,
+ subject,
+ `${escape(subject)}
${paragraph(intro)}${button('Your preferences', input.links.manage)}`,
+ `${escape(footerLine)}
${footerLinks(input.links)}`,
+ PALETTE.ink,
+ ),
+ text: [
+ subject,
+ '',
+ intro,
+ '',
+ `Preferences: ${input.links.manage}`,
+ `Unsubscribe: ${input.links.unsubscribe}`,
+ '',
+ footerLine,
+ ].join('\n'),
+ };
+ }
+ case 'post': {
+ const values = {
+ ...common,
+ title: input.post.title,
+ author: input.post.author.name,
+ about: input.post.project ? ` about ${input.post.project.name}` : '',
+ };
+ const subject = fill(settings['email.post.subject'], values);
+ const intro = fill(settings['email.post.body'], values);
+ return {
+ subject,
+ html: shell(
+ site,
+ subject,
+ `${paragraph(intro)}${postCard(input.post, true)}`,
+ `${escape(footerLine)}
${footerLinks(input.links)}`,
+ input.post.author.accent,
+ ),
+ text: [
+ intro,
+ '',
+ postText(input.post, true),
+ '',
+ footerLine,
+ `Preferences: ${input.links.manage}`,
+ `Unsubscribe: ${input.links.unsubscribe}`,
+ ].join('\n'),
+ };
+ }
+ case 'digest': {
+ const values = {
+ ...common,
+ count: String(input.posts.length),
+ posts: input.posts.length === 1 ? 'post' : 'posts',
+ };
+ const subject = fill(settings['email.digest.subject'], values);
+ const intro = fill(settings['email.digest.body'], values);
+ return {
+ subject,
+ html: shell(
+ site,
+ subject,
+ `${meta([`since ${day(input.since)}`, `${input.posts.length} ${values.posts}`])}${escape(subject)}
${paragraph(intro)}${input.posts.map((post) => postCard(post, false)).join('')}`,
+ `${escape(footerLine)}
${footerLinks(input.links)}`,
+ input.posts[0]?.author.accent ?? PALETTE.ink,
+ ),
+ text: [
+ subject,
+ '',
+ intro,
+ '',
+ ...input.posts.flatMap((post) => [postText(post, false), '']),
+ footerLine,
+ `Preferences: ${input.links.manage}`,
+ `Unsubscribe: ${input.links.unsubscribe}`,
+ ].join('\n'),
+ };
+ }
+ }
+}
diff --git a/site/server/jobs.test.ts b/site/server/jobs.test.ts
index 40b1425..0cfd8e0 100644
--- a/site/server/jobs.test.ts
+++ b/site/server/jobs.test.ts
@@ -40,6 +40,32 @@ describe('jobs', () => {
jobs.stop();
});
+ it('lets a running job arm its own next run without losing either row', async () => {
+ const db = memoryDatabase();
+ const jobs = createJobs(
+ db,
+ {
+ digest: async () => {
+ jobs.add('digest', 'weekly', '2026-01-08T00:00:00.000Z');
+ },
+ },
+ () => {},
+ );
+ jobs.add('digest', 'weekly', '2026-01-01T00:00:00.000Z');
+ assert.deepEqual(await jobs.runDue(Date.parse('2026-01-01T00:00:01.000Z')), ['digest:weekly']);
+ assert.deepEqual(
+ db
+ .prepare('SELECT due_at, done_at IS NOT NULL AS done FROM jobs ORDER BY id')
+ .all()
+ .map((row) => [row.due_at, row.done]),
+ [
+ ['2026-01-01T00:00:00.000Z', 1],
+ ['2026-01-08T00:00:00.000Z', 0],
+ ],
+ );
+ jobs.stop();
+ });
+
it('is re-read from SQLite by a fresh executor', async () => {
const db = memoryDatabase();
const first = createJobs(db, {}, () => {});
diff --git a/site/server/jobs.ts b/site/server/jobs.ts
index dbe8146..6e59f64 100644
--- a/site/server/jobs.ts
+++ b/site/server/jobs.ts
@@ -13,6 +13,7 @@ export function createJobs(
let timer: ReturnType | undefined;
let running = false;
let stopped = false;
+ const active = new Set();
function arm(now = Date.now()) {
clearTimeout(timer);
@@ -34,20 +35,24 @@ export function createJobs(
.all(MAX_ATTEMPTS, new Date(now).toISOString());
const ran: string[] = [];
for (const job of due) {
- db.prepare('UPDATE jobs SET attempts = attempts + 1 WHERE id = ?').run(job.id as number);
+ const id = job.id as number;
+ db.prepare('UPDATE jobs SET attempts = attempts + 1 WHERE id = ?').run(id);
+ active.add(id);
try {
await handlers[job.kind as string]?.(job.ref as string);
db.prepare('UPDATE jobs SET done_at = ?, last_error = NULL WHERE id = ?').run(
new Date().toISOString(),
- job.id as number,
+ id,
);
ran.push(`${job.kind as string}:${job.ref as string}`);
} catch (error) {
db.prepare('UPDATE jobs SET last_error = ? WHERE id = ?').run(
error instanceof Error ? error.message : String(error),
- job.id as number,
+ id,
);
capture(error, { route: `job:${job.kind as string}` });
+ } finally {
+ active.delete(id);
}
}
return ran;
@@ -68,7 +73,9 @@ export function createJobs(
return {
add(kind: string, ref: string, dueAt: string) {
- db.prepare('DELETE FROM jobs WHERE kind = ? AND ref = ? AND done_at IS NULL').run(kind, ref);
+ db.prepare(
+ 'DELETE FROM jobs WHERE kind = ? AND ref = ? AND done_at IS NULL AND id NOT IN (SELECT value FROM json_each(?))',
+ ).run(kind, ref, JSON.stringify([...active]));
db.prepare('INSERT INTO jobs (kind, ref, due_at) VALUES (?, ?, ?)').run(kind, ref, dueAt);
arm();
},
diff --git a/site/server/lifecycle.ts b/site/server/lifecycle.ts
index 0b96c16..f80453f 100644
--- a/site/server/lifecycle.ts
+++ b/site/server/lifecycle.ts
@@ -53,6 +53,12 @@ export function applyRetention(db: Database, now = Date.now()) {
.prepare("DELETE FROM posts WHERE status = 'trashed' AND trashed_at < ?")
.run(before(30, now)).changes,
jobs: db.prepare('DELETE FROM jobs WHERE done_at < ?').run(before(30, now)).changes,
+ subscribers: db
+ .prepare(
+ 'DELETE FROM subscribers WHERE unsubscribed_at < ? OR (confirmed_at IS NULL AND created_at < ?)',
+ )
+ .run(before(30, now), before(7, now)).changes,
+ sends: db.prepare('DELETE FROM sends WHERE created_at < ?').run(before(90, now)).changes,
};
});
}
diff --git a/site/server/mail.ts b/site/server/mail.ts
index ecbeaa5..5292aef 100644
--- a/site/server/mail.ts
+++ b/site/server/mail.ts
@@ -3,6 +3,45 @@ import { config } from './config.ts';
const transport = config.smtpUrl ? createTransport(config.smtpUrl) : undefined;
+export interface Outgoing {
+ from: { name: string };
+ replyTo: string | null;
+ to: string;
+ subject: string;
+ html: string;
+ text: string;
+ unsubscribeUrl: string | null;
+}
+
+export async function sendEmail(message: Outgoing) {
+ if (!transport || !config.alertsFrom) {
+ throw new Error(transport ? 'ALERTS_FROM is not set' : 'SMTP_URL is not set');
+ }
+ const info = await transport.sendMail({
+ from: { name: message.from.name, address: config.alertsFrom },
+ replyTo: message.replyTo ?? undefined,
+ to: message.to,
+ subject: message.subject,
+ html: message.html,
+ text: message.text,
+ headers: message.unsubscribeUrl
+ ? {
+ 'List-Unsubscribe': `<${message.unsubscribeUrl}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
+ Precedence: 'bulk',
+ }
+ : undefined,
+ });
+ if (info.rejected.length > 0) {
+ throw Object.assign(new Error(`Rejected: ${info.response}`), {
+ code: 'EENVELOPE',
+ responseCode: 550,
+ permanent: true,
+ });
+ }
+ return info.messageId;
+}
+
export async function alertOwner(subject: string, text: string) {
if (!transport || !config.alertsEmail) {
return false;
diff --git a/site/server/main.ts b/site/server/main.ts
index 3ca9a9c..aea0fc5 100644
--- a/site/server/main.ts
+++ b/site/server/main.ts
@@ -17,7 +17,7 @@ import { createErrors } from './errors.ts';
import { runLifecycle } from './lifecycle.ts';
import { createLive } from './live.ts';
import { log } from './log.ts';
-import { alertOwner } from './mail.ts';
+import { alertOwner, sendEmail } from './mail.ts';
import { createMetrics } from './metrics.ts';
import { connectRedis, slidingLimit } from './redis.ts';
import { createSessions } from './sessions.ts';
@@ -29,6 +29,7 @@ import { createReads } from './reads.ts';
import { createUploads } from './uploads.ts';
import { createSettings } from './settings.ts';
import { createSnapshot } from './snapshot.ts';
+import { createSubscribers } from './subscribers.ts';
import { createSync } from './sync.ts';
import { pingAll } from './uptime.ts';
@@ -55,10 +56,25 @@ const jobs = createJobs(
publish: async (id) => {
await posts.publishDue(id);
},
+ email: async () => {
+ await subscribers.sends.drain();
+ },
+ digest: async () => {
+ subscribers.runDigest();
+ },
},
capture,
);
-const posts = createPosts(db, bus, jobs);
+const subscribers = createSubscribers(db, jobs, bus, {
+ secret: config.sessionSecret,
+ origin: new URL(config.appUrl).origin,
+ allow: (address) => slidingLimit(redis, `subscribe:${address}`, 5, HOUR),
+ settings: settings.get,
+ directory: snapshot.get,
+ deliver: sendEmail,
+ testAddress: config.alertsEmail,
+});
+const posts = createPosts(db, bus, jobs, subscribers.queuePost);
const collab = createCollab(db, posts, sessions);
process.on('unhandledRejection', (reason) => capture(reason, { route: 'process' }));
@@ -102,6 +118,7 @@ const app = createApp(
config.sessionSecret,
),
posts,
+ subscribers,
collab,
auth,
live,
@@ -115,6 +132,7 @@ const app = createApp(
);
const server = app.listen(config.port, () => log.info({ port: config.port }, 'listening'));
server.on('upgrade', collab.upgrade);
+subscribers.start();
jobs.start();
function every(name: string, ms: number, job: () => Promise) {
diff --git a/site/server/migrations/007-subscribers.sql b/site/server/migrations/007-subscribers.sql
new file mode 100644
index 0000000..7433c24
--- /dev/null
+++ b/site/server/migrations/007-subscribers.sql
@@ -0,0 +1,30 @@
+CREATE TABLE subscribers (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ scope TEXT NOT NULL DEFAULT 'all',
+ cadence TEXT NOT NULL DEFAULT 'each' CHECK (cadence IN ('each', 'weekly')),
+ token TEXT NOT NULL,
+ confirmed_at TEXT,
+ unsubscribed_at TEXT,
+ suppressed_at TEXT,
+ suppressed_reason TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE sends (
+ id INTEGER PRIMARY KEY,
+ subscriber TEXT NOT NULL REFERENCES subscribers (id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK (kind IN ('confirm', 'manage', 'post', 'digest')),
+ ref TEXT,
+ due_at TEXT NOT NULL,
+ sent_at TEXT,
+ status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'failed', 'bounced')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ error TEXT,
+ message_id TEXT,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX sends_queue ON sends (status, due_at);
+CREATE INDEX sends_subscriber ON sends (subscriber, created_at);
+CREATE UNIQUE INDEX sends_once ON sends (subscriber, kind, ref) WHERE ref IS NOT NULL;
diff --git a/site/server/posts.ts b/site/server/posts.ts
index 3b73bfa..363f259 100644
--- a/site/server/posts.ts
+++ b/site/server/posts.ts
@@ -92,7 +92,12 @@ export function readPublished(db: Database, pathOf: (author: string) => string |
});
}
-export function createPosts(db: Database, bus: Bus, jobs: Jobs) {
+export function createPosts(
+ db: Database,
+ bus: Bus,
+ jobs: Jobs,
+ onPublished: (post: Post) => void = () => {},
+) {
const known = {
upload: (id: string) => Boolean(db.prepare('SELECT 1 FROM uploads WHERE id = ?').get(id)),
member: (handle: string) =>
@@ -214,6 +219,7 @@ export function createPosts(db: Database, bus: Bus, jobs: Jobs) {
},
at,
});
+ onPublished(published);
return published;
}
diff --git a/site/server/sends.test.ts b/site/server/sends.test.ts
new file mode 100644
index 0000000..004255a
--- /dev/null
+++ b/site/server/sends.test.ts
@@ -0,0 +1,139 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { createJobs } from './jobs.ts';
+import type { Outgoing } from './mail.ts';
+import { classify, createSends, EMAIL_JOB, EMAIL_QUEUE } from './sends.ts';
+import { memoryDatabase } from './testing.ts';
+
+const T0 = Date.parse('2026-11-09T08:00:00.000Z');
+
+function message(to: string): Outgoing {
+ return {
+ from: { name: 'Project Graphite' },
+ replyTo: null,
+ to,
+ subject: 'hi',
+ html: 'hi
',
+ text: 'hi',
+ unsubscribeUrl: null,
+ };
+}
+
+function setup(deliver: (message: Outgoing) => Promise, perHour = 20) {
+ const db = memoryDatabase();
+ for (const id of ['s1', 's2', 's3']) {
+ db.prepare(
+ "INSERT INTO subscribers (id, email, token, confirmed_at, created_at, updated_at) VALUES (?, ?, 't', '2026-01-01', '2026-01-01', '2026-01-01')",
+ ).run(id, `${id}@example.com`);
+ }
+ const jobs = createJobs(db, {}, () => {});
+ const bounced: string[] = [];
+ const sends = createSends(db, jobs, {
+ deliver,
+ compose: async (send) =>
+ send.ref === 'skip' ? null : message(`${send.subscriber}@example.com`),
+ perHour: () => perHour,
+ bounced: (subscriber) => bounced.push(subscriber),
+ });
+ return { db, jobs, sends, bounced };
+}
+
+describe('send classification', () => {
+ it('tells a recipient rejection from a temporary failure', () => {
+ assert.deepEqual(classify({ code: 'EENVELOPE', responseCode: 550, message: 'User unknown' }), {
+ bounce: true,
+ permanent: true,
+ message: 'User unknown',
+ });
+ assert.equal(classify({ code: 'EENVELOPE', responseCode: 450, message: 'busy' }).bounce, false);
+ assert.equal(classify({ code: 'ECONNECTION', message: 'refused' }).permanent, false);
+ assert.equal(classify({ code: 'EMESSAGE', responseCode: 552 }).permanent, true);
+ assert.equal(classify({ code: 'EMESSAGE', responseCode: 552 }).bounce, false);
+ });
+});
+
+describe('sends', () => {
+ it('sends what is due, records the message id and finishes the job', async () => {
+ const delivered: string[] = [];
+ const { db, jobs, sends } = setup(async (message) => {
+ delivered.push(message.to);
+ return `<${message.to}>`;
+ });
+ sends.queue('s1', 'post', 'p1', '2026-11-09T07:00:00.000Z');
+ sends.queue('s2', 'post', 'p1', '2026-11-09T09:00:00.000Z');
+ assert.equal(sends.queue('s1', 'post', 'p1', '2026-11-09T07:00:00.000Z'), false);
+
+ assert.deepEqual(await sends.drain(T0), { sent: 1, failed: 0, bounced: 0, deferred: 0 });
+
+ assert.deepEqual(delivered, ['s1@example.com']);
+ const sent = db.prepare("SELECT status, message_id FROM sends WHERE subscriber = 's1'").get()!;
+ assert.equal(sent.status, 'sent');
+ assert.equal(sent.message_id, '');
+ assert.equal(jobs.pending(EMAIL_JOB, EMAIL_QUEUE), true);
+ assert.equal(
+ db.prepare('SELECT due_at FROM jobs WHERE kind = ?').get(EMAIL_JOB)!.due_at,
+ '2026-11-09T09:00:00.000Z',
+ );
+ jobs.stop();
+ });
+
+ it('keeps to the hourly budget and comes back in five minutes', async () => {
+ const { db, jobs, sends } = setup(async () => 'id', 2);
+ for (const id of ['s1', 's2', 's3']) {
+ sends.queue(id, 'post', 'p1', '2026-11-09T07:00:00.000Z');
+ }
+ assert.equal((await sends.drain(T0)).sent, 2);
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sends WHERE status = 'queued'").get()!.n, 1);
+ assert.equal(
+ db.prepare('SELECT due_at FROM jobs WHERE kind = ?').get(EMAIL_JOB)!.due_at,
+ '2026-11-09T08:05:00.000Z',
+ );
+ assert.equal((await sends.drain(T0 + 5 * 60_000)).sent, 0);
+ assert.equal((await sends.drain(T0 + 61 * 60_000)).sent, 1);
+ jobs.stop();
+ });
+
+ it('records a rejected address as a bounce and suppresses it through the callback', async () => {
+ const { db, jobs, sends, bounced } = setup(async () => {
+ throw Object.assign(new Error('550 5.1.1 User unknown'), {
+ code: 'EENVELOPE',
+ responseCode: 550,
+ });
+ });
+ sends.queue('s1', 'post', 'p1', '2026-11-09T07:00:00.000Z');
+ assert.deepEqual(await sends.drain(T0), { sent: 0, failed: 0, bounced: 1, deferred: 0 });
+ const row = db.prepare("SELECT status, error FROM sends WHERE subscriber = 's1'").get()!;
+ assert.equal(row.status, 'bounced');
+ assert.match(row.error as string, /User unknown/);
+ assert.deepEqual(bounced, ['s1']);
+ jobs.stop();
+ });
+
+ it('retries temporary failures with backoff and gives up after five', async () => {
+ let calls = 0;
+ const { db, jobs, sends } = setup(async () => {
+ calls += 1;
+ throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNECTION' });
+ });
+ sends.queue('s1', 'confirm', null, '2026-11-09T07:00:00.000Z');
+ assert.equal((await sends.drain(T0)).deferred, 1);
+ assert.equal(db.prepare('SELECT due_at FROM sends').get()!.due_at, '2026-11-09T08:05:00.000Z');
+ assert.equal((await sends.drain(T0)).deferred, 0);
+ let at = T0;
+ for (let attempt = 2; attempt <= 5; attempt += 1) {
+ at += 2 * 60 * 60_000;
+ await sends.drain(at);
+ }
+ assert.equal(calls, 5);
+ assert.equal(db.prepare('SELECT status FROM sends').get()!.status, 'failed');
+ jobs.stop();
+ });
+
+ it('fails a send that has nothing left to say', async () => {
+ const { db, jobs, sends } = setup(async () => 'id');
+ sends.queue('s1', 'post', 'skip', '2026-11-09T07:00:00.000Z');
+ assert.equal((await sends.drain(T0)).failed, 1);
+ assert.equal(db.prepare('SELECT error FROM sends').get()!.error, 'nothing to send');
+ jobs.stop();
+ });
+});
diff --git a/site/server/sends.ts b/site/server/sends.ts
new file mode 100644
index 0000000..daa50fa
--- /dev/null
+++ b/site/server/sends.ts
@@ -0,0 +1,194 @@
+import type { Database } from './db.ts';
+import type { Jobs } from './jobs.ts';
+import type { Outgoing } from './mail.ts';
+
+export const EMAIL_JOB = 'email';
+export const EMAIL_QUEUE = 'queue';
+const MAX_ATTEMPTS = 5;
+const HOUR_MS = 3_600_000;
+const PAUSE_MS = 5 * 60_000;
+
+export type SendKind = 'confirm' | 'manage' | 'post' | 'digest';
+export type SendStatus = 'queued' | 'sent' | 'failed' | 'bounced';
+
+export interface SendRow {
+ id: number;
+ subscriber: string;
+ kind: SendKind;
+ ref: string | null;
+ dueAt: string;
+ sentAt: string | null;
+ status: SendStatus;
+ attempts: number;
+ error: string | null;
+}
+
+export type Deliver = (message: Outgoing) => Promise;
+
+export interface Failure {
+ bounce: boolean;
+ permanent: boolean;
+ message: string;
+}
+
+export function classify(error: unknown): Failure {
+ const { code, responseCode, permanent, message } = (error ?? {}) as {
+ code?: unknown;
+ responseCode?: unknown;
+ permanent?: unknown;
+ message?: unknown;
+ };
+ const status = typeof responseCode === 'number' ? responseCode : 0;
+ const hard = status >= 500 || permanent === true;
+ return {
+ bounce: code === 'EENVELOPE' && status >= 500,
+ permanent: hard,
+ message: typeof message === 'string' ? message.slice(0, 300) : String(error).slice(0, 300),
+ };
+}
+
+function fromRow(row: Record): SendRow {
+ return {
+ id: row.id as number,
+ subscriber: row.subscriber as string,
+ kind: row.kind as SendKind,
+ ref: row.ref as string | null,
+ dueAt: row.due_at as string,
+ sentAt: row.sent_at as string | null,
+ status: row.status as SendStatus,
+ attempts: row.attempts as number,
+ error: row.error as string | null,
+ };
+}
+
+export function createSends(
+ db: Database,
+ jobs: Jobs,
+ deps: {
+ deliver: Deliver;
+ compose: (send: SendRow) => Promise;
+ perHour: () => number;
+ bounced: (subscriber: string, reason: string, at: string) => void;
+ },
+) {
+ const columns = 'id, subscriber, kind, ref, due_at, sent_at, status, attempts, error FROM sends';
+
+ function arm(now = Date.now()) {
+ const next = db.prepare("SELECT min(due_at) AS due FROM sends WHERE status = 'queued'").get()!
+ .due as string | null;
+ if (!next) {
+ jobs.cancel(EMAIL_JOB, EMAIL_QUEUE);
+ return;
+ }
+ const room = deps.perHour() - sentSince(now - HOUR_MS);
+ jobs.add(
+ EMAIL_JOB,
+ EMAIL_QUEUE,
+ room > 0 ? next : new Date(Math.max(Date.parse(next), now + PAUSE_MS)).toISOString(),
+ );
+ }
+
+ function sentSince(since: number) {
+ return db
+ .prepare("SELECT count(*) AS count FROM sends WHERE status = 'sent' AND sent_at > ?")
+ .get(new Date(since).toISOString())!.count as number;
+ }
+
+ function finish(id: number, status: SendStatus, error: string | null, at: string) {
+ db.prepare('UPDATE sends SET status = ?, error = ?, sent_at = ? WHERE id = ?').run(
+ status,
+ error,
+ status === 'sent' ? at : null,
+ id,
+ );
+ }
+
+ async function attempt(send: SendRow, now: number) {
+ const at = new Date(now).toISOString();
+ const attempts = send.attempts + 1;
+ db.prepare('UPDATE sends SET attempts = ? WHERE id = ?').run(attempts, send.id);
+ const message = await deps.compose(send).catch((error: unknown) => {
+ throw Object.assign(new Error(classify(error).message), { permanent: true });
+ });
+ if (!message) {
+ finish(send.id, 'failed', 'nothing to send', at);
+ return 'failed';
+ }
+ try {
+ const messageId = await deps.deliver(message);
+ db.prepare(
+ "UPDATE sends SET status = 'sent', sent_at = ?, error = NULL, message_id = ? WHERE id = ?",
+ ).run(at, messageId, send.id);
+ return 'sent';
+ } catch (error) {
+ const failure = classify(error);
+ if (failure.bounce) {
+ finish(send.id, 'bounced', failure.message, at);
+ deps.bounced(send.subscriber, failure.message, at);
+ return 'bounced';
+ }
+ if (failure.permanent || attempts >= MAX_ATTEMPTS) {
+ finish(send.id, 'failed', failure.message, at);
+ return 'failed';
+ }
+ db.prepare('UPDATE sends SET error = ?, due_at = ? WHERE id = ?').run(
+ failure.message,
+ new Date(now + PAUSE_MS * 2 ** (attempts - 1)).toISOString(),
+ send.id,
+ );
+ return 'deferred';
+ }
+ }
+
+ return {
+ queue(subscriber: string, kind: SendKind, ref: string | null, dueAt: string) {
+ const { changes } = db
+ .prepare(
+ 'INSERT OR IGNORE INTO sends (subscriber, kind, ref, due_at, created_at) VALUES (?, ?, ?, ?, ?)',
+ )
+ .run(subscriber, kind, ref, dueAt, new Date().toISOString());
+ return changes > 0;
+ },
+
+ arm,
+
+ async drain(now = Date.now()) {
+ const room = Math.max(0, deps.perHour() - sentSince(now - HOUR_MS));
+ const due = db
+ .prepare(
+ `SELECT ${columns} WHERE status = 'queued' AND due_at <= ? ORDER BY due_at, id LIMIT ?`,
+ )
+ .all(new Date(now).toISOString(), room)
+ .map(fromRow);
+ const outcome = { sent: 0, failed: 0, bounced: 0, deferred: 0 };
+ for (const send of due) {
+ outcome[await attempt(send, now)] += 1;
+ }
+ if (db.prepare("SELECT 1 FROM sends WHERE status = 'queued'").get()) {
+ arm(now);
+ }
+ return outcome;
+ },
+
+ recent(limit: number): SendRow[] {
+ return db.prepare(`SELECT ${columns} ORDER BY id DESC LIMIT ?`).all(limit).map(fromRow);
+ },
+
+ counts() {
+ const row = db
+ .prepare(
+ `SELECT sum(status = 'queued') AS queued, sum(status = 'sent' AND sent_at > ?) AS today,
+ sum(status = 'bounced') AS bounced, sum(status = 'failed') AS failed FROM sends`,
+ )
+ .get(new Date(Date.now() - 24 * HOUR_MS).toISOString())!;
+ return {
+ queued: (row.queued as number | null) ?? 0,
+ today: (row.today as number | null) ?? 0,
+ bounced: (row.bounced as number | null) ?? 0,
+ failed: (row.failed as number | null) ?? 0,
+ };
+ },
+ };
+}
+
+export type Sends = ReturnType;
diff --git a/site/server/subscribers.test.ts b/site/server/subscribers.test.ts
new file mode 100644
index 0000000..9efc4ad
--- /dev/null
+++ b/site/server/subscribers.test.ts
@@ -0,0 +1,352 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { defaults } from '../app/settings.ts';
+import type { Directory } from '../app/types.ts';
+import { createJobs } from './jobs.ts';
+import type { Outgoing } from './mail.ts';
+import { createPosts } from './posts.ts';
+import { createSubscribers, DIGEST_JOB, DIGEST_REF } from './subscribers.ts';
+import { memoryDatabase, recordingBus } from './testing.ts';
+
+const ORIGIN = 'https://hub.test';
+
+const directory: Directory = {
+ members: [
+ {
+ handle: 'amrmscll',
+ alias: 'amr',
+ github: 'AmrMsCLL',
+ name: 'Amr',
+ avatarUrl: '',
+ coverUrl: null,
+ listed: true,
+ headline: null,
+ bio: null,
+ stack: [],
+ links: [],
+ publicRepos: null,
+ followers: null,
+ joined: null,
+ accent: '#6f8fd1',
+ path: '/amr',
+ },
+ ],
+ projects: [
+ {
+ slug: 'strata',
+ parent: null,
+ kind: 'project',
+ name: 'Strata',
+ summary: '',
+ tags: [],
+ repo: 'project-graphite/strata',
+ repoUrl: 'https://github.com/project-graphite/strata',
+ owner: 'amrmscll',
+ authors: [],
+ status: 'live',
+ pinned: false,
+ showcase: null,
+ url: null,
+ path: '/strata',
+ coverUrl: null,
+ accent: null,
+ layout: 'window',
+ feature: { note: '', screenshotUrl: null, posts: [] },
+ pending: [],
+ pullRequest: null,
+ },
+ ],
+ releases: [],
+ posts: [],
+};
+
+function setup(options: { allowed?: boolean; fail?: boolean } = {}) {
+ const db = memoryDatabase();
+ db.prepare(
+ "INSERT INTO members (handle, login, name, avatar_url, role, joined_at, synced_at) VALUES ('amrmscll', 'AmrMsCLL', 'Amr', '', 'owner', '2026-01-01', '2026-01-01')",
+ ).run();
+ db.prepare(
+ "INSERT INTO projects (slug, repo, entry, synced_at) VALUES ('strata', 'project-graphite/strata', '{}', '2026-01-01')",
+ ).run();
+ const { bus, published } = recordingBus();
+ let clock = Date.parse('2026-11-06T12:00:00.000Z');
+ const delivered: Outgoing[] = [];
+ const settings = defaults();
+ const jobs = createJobs(db, {}, () => {});
+ const subscribers = createSubscribers(db, jobs, bus, {
+ secret: 'secret',
+ origin: ORIGIN,
+ allow: async () => options.allowed ?? true,
+ settings: () => settings,
+ directory: () => directory,
+ deliver: async (message) => {
+ if (options.fail) {
+ throw Object.assign(new Error('550 5.1.1 The email account does not exist'), {
+ code: 'EENVELOPE',
+ responseCode: 550,
+ });
+ }
+ delivered.push(message);
+ return `<${delivered.length}@hub.test>`;
+ },
+ now: () => clock,
+ });
+ const posts = createPosts(db, bus, jobs, subscribers.queuePost);
+ const subscribe = (
+ email: string,
+ extra: Partial[0]> = {},
+ ) =>
+ subscribers.subscribe({
+ email,
+ scope: 'all',
+ cadence: 'each',
+ trap: '',
+ address: '203.0.113.7',
+ ...extra,
+ });
+ const linkOf = (message: Outgoing, purpose: string) =>
+ message.text.match(new RegExp(`${ORIGIN}/subscribe/${purpose}/([^/\\s]+)/([^/\\s]+)`))!;
+ const advance = (ms: number) => {
+ clock += ms;
+ };
+ return {
+ db,
+ jobs,
+ bus,
+ published,
+ settings,
+ subscribers,
+ posts,
+ subscribe,
+ delivered,
+ linkOf,
+ advance,
+ now: () => clock,
+ };
+}
+
+async function publishPost(
+ posts: ReturnType,
+ title: string,
+ project: string | null = null,
+) {
+ const id = await posts.create('amrmscll', 'amrmscll', null);
+ await posts.save(
+ id,
+ {
+ title,
+ slug: '',
+ summary: `About ${title}.`,
+ tags: [],
+ project,
+ coverUpload: null,
+ editors: [],
+ },
+ 'amrmscll',
+ );
+ return posts.publish(id, 'amrmscll');
+}
+
+describe('subscribing', () => {
+ it('double opts in: a confirmation email with a signed link, then the post email', async () => {
+ const { subscribers, subscribe, delivered, linkOf, posts, now, db } = setup();
+ assert.deepEqual(await subscribe('Reader@Example.com'), { ok: true });
+ assert.equal(subscribers.count(), 0);
+ await subscribers.sends.drain(now());
+ assert.equal(delivered.length, 1);
+ assert.equal(delivered[0].to, 'reader@example.com');
+ assert.match(delivered[0].subject, /Confirm your subscription/);
+ assert.match(delivered[0].html, /Confirm →/);
+ assert.equal(delivered[0].unsubscribeUrl, null);
+
+ const [, id, signature] = linkOf(delivered[0], 'confirm');
+ assert.equal(subscribers.verify('confirm', id, 'nope'), undefined);
+ assert.equal(subscribers.verify('manage', id, signature), undefined);
+ await subscribers.confirm(subscribers.verify('confirm', id, signature)!.id);
+ assert.equal(subscribers.count(), 1);
+
+ await publishPost(posts, 'Hello, world', 'strata');
+ await subscribers.sends.drain(now());
+ assert.equal(delivered.length, 2);
+ const post = delivered[1];
+ assert.equal(post.subject, 'Amr published: Hello, world');
+ assert.match(post.html, /href="https:\/\/hub\.test\/amr\/blog\/hello-world"/);
+ assert.match(post.html, /About Hello, world\./);
+ assert.match(post.text, /https:\/\/hub\.test\/amr\/blog\/hello-world/);
+ assert.match(post.unsubscribeUrl!, /\/subscribe\/unsubscribe\//);
+ assert.equal(
+ db.prepare("SELECT count(*) AS n FROM sends WHERE kind = 'post' AND status = 'sent'").get()!
+ .n,
+ 1,
+ );
+ });
+
+ it('sends only to scopes that match and never twice for one post', async () => {
+ const { subscribers, subscribe, delivered, linkOf, posts, now, db } = setup();
+ await subscribe('strata@example.com', { scope: 'project:strata' });
+ await subscribe('amr@example.com', { scope: 'member:amrmscll' });
+ await subscribe('weekly@example.com', { cadence: 'weekly' });
+ await subscribers.sends.drain(now());
+ for (const message of delivered.splice(0)) {
+ const [, id, signature] = linkOf(message, 'confirm');
+ await subscribers.confirm(subscribers.verify('confirm', id, signature)!.id);
+ }
+
+ const post = await publishPost(posts, 'No project');
+ await subscribers.sends.drain(now());
+ assert.deepEqual(
+ delivered.map((message) => message.to),
+ ['amr@example.com'],
+ );
+ assert.equal(subscribers.queuePost(post), 0);
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sends WHERE kind = 'post'").get()!.n, 1);
+ });
+
+ it('refuses bad input, honours the honeypot and the limits', async () => {
+ const { subscribers, subscribe, delivered, now, db } = setup();
+ assert.equal((await subscribe('not an email')).ok, false);
+ assert.equal((await subscribe('a@b.co', { scope: 'project:nope' })).ok, false);
+ assert.equal((await subscribe('a@b.co', { cadence: 'hourly' })).ok, false);
+ assert.deepEqual(await subscribe('spam@example.com', { trap: 'http://x' }), { ok: true });
+ assert.equal(db.prepare('SELECT count(*) AS n FROM subscribers').get()!.n, 0);
+ assert.equal((await setup({ allowed: false }).subscribe('a@b.co')).ok, false);
+
+ for (let n = 0; n < 3; n += 1) {
+ assert.equal((await subscribe('eager@example.com')).ok, true);
+ }
+ assert.equal((await subscribe('eager@example.com')).ok, false);
+ await subscribers.sends.drain(now());
+ assert.equal(delivered.length, 3);
+ });
+
+ it('does not let the public form change a confirmed subscription', async () => {
+ const { subscribers, subscribe, delivered, linkOf, now } = setup();
+ await subscribe('reader@example.com', { scope: 'project:strata' });
+ await subscribers.sends.drain(now());
+ const [, id, signature] = linkOf(delivered[0], 'confirm');
+ await subscribers.confirm(subscribers.verify('confirm', id, signature)!.id);
+
+ await subscribe('reader@example.com', { scope: 'all', cadence: 'weekly' });
+ await subscribers.sends.drain(now());
+ assert.equal(subscribers.read(id)!.scope, 'project:strata');
+ assert.equal(delivered.length, 2);
+ assert.match(delivered[1].subject, /Your subscription/);
+ assert.match(delivered[1].text, /posts about Strata/);
+ });
+
+ it('changes preferences, unsubscribes and comes back from the signed links', async () => {
+ const { subscribers, subscribe, delivered, linkOf, posts, now, published } = setup();
+ await subscribe('reader@example.com');
+ await subscribers.sends.drain(now());
+ const [, id, signature] = linkOf(delivered[0], 'confirm');
+ await subscribers.confirm(subscribers.verify('confirm', id, signature)!.id);
+
+ assert.equal((await subscribers.update(id, 'member:amrmscll', 'weekly')).ok, true);
+ assert.equal((await subscribers.update(id, 'member:ghost', 'weekly')).ok, false);
+ assert.equal(subscribers.read(id)!.cadence, 'weekly');
+
+ await subscribers.unsubscribe(id);
+ assert.equal(subscribers.count(), 0);
+ await publishPost(posts, 'Missed');
+ await subscribers.sends.drain(now());
+ assert.equal(delivered.length, 1);
+
+ await subscribers.resubscribe(id);
+ assert.equal(subscribers.count(), 1);
+ assert.deepEqual(
+ published.filter((event) => event.topic === 'subscribers').map((event) => event.kind),
+ ['asked', 'confirmed', 'changed', 'unsubscribed', 'confirmed'],
+ );
+ });
+
+ it('suppresses an address after a hard bounce and clears it when it subscribes again', async () => {
+ const failing = setup({ fail: true });
+ await failing.subscribe('gone@example.com');
+ await failing.subscribers.sends.drain(failing.now());
+ const subscriber = failing.db.prepare('SELECT * FROM subscribers').get()!;
+ assert.match(subscriber.suppressed_reason as string, /does not exist/);
+ assert.equal(failing.subscribers.overview().bounces[0].email, 'gone@example.com');
+ assert.equal(failing.subscribers.overview().sends.bounced, 1);
+
+ assert.deepEqual(await failing.subscribe('gone@example.com'), { ok: true });
+ assert.equal(
+ failing.db.prepare('SELECT suppressed_at FROM subscribers').get()!.suppressed_at,
+ null,
+ );
+ });
+});
+
+describe('digest', () => {
+ it('is skipped with nothing new, then gathers a week of matching posts into one email', async () => {
+ const { subscribers, subscribe, delivered, linkOf, posts, now, advance, jobs, db } = setup();
+ subscribers.start();
+ assert.equal(jobs.pending(DIGEST_JOB, DIGEST_REF), true);
+ assert.equal(subscribers.nextDigestAt(), '2026-11-09T08:00:00.000Z');
+
+ await subscribe('weekly@example.com', { cadence: 'weekly' });
+ await subscribe('strata@example.com', { cadence: 'weekly', scope: 'project:strata' });
+ await subscribe('each@example.com');
+ await subscribers.sends.drain(now());
+ for (const message of delivered.splice(0)) {
+ const [, id, signature] = linkOf(message, 'confirm');
+ await subscribers.confirm(subscribers.verify('confirm', id, signature)!.id);
+ }
+ assert.deepEqual(subscribers.runDigest(), { skipped: true, queued: 0 });
+
+ advance(60_000);
+ await publishPost(posts, 'First');
+ await publishPost(posts, 'Second');
+ await subscribers.sends.drain(now());
+ assert.deepEqual(
+ delivered.splice(0).map((message) => message.to),
+ ['each@example.com', 'each@example.com'],
+ );
+
+ advance(3 * 86_400_000);
+ assert.deepEqual(subscribers.runDigest(), { skipped: false, queued: 1 });
+ advance(3_600_000);
+ await subscribers.sends.drain(now());
+ assert.equal(delivered.length, 1);
+ assert.equal(delivered[0].to, 'weekly@example.com');
+ assert.equal(delivered[0].subject, 'This week on Project Graphite: 2 new posts');
+ assert.match(delivered[0].html, /First/);
+ assert.match(delivered[0].html, /Second/);
+ assert.match(delivered[0].text, /Preferences: https:\/\/hub\.test\/subscribe\/manage\//);
+
+ assert.deepEqual(subscribers.runDigest(), { skipped: true, queued: 0 });
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sends WHERE kind = 'digest'").get()!.n, 1);
+ assert.equal(subscribers.nextDigestAt(), '2026-11-16T08:00:00.000Z');
+ jobs.stop();
+ });
+
+ it('re-arms when the digest day or hour changes', async () => {
+ const { subscribers, settings, bus, jobs } = setup();
+ subscribers.start();
+ settings['email.digest.day'] = 'wednesday';
+ settings['email.digest.hour'] = 18;
+ await bus.publish({
+ topic: 'settings',
+ key: 'email',
+ kind: 'settings',
+ public: true,
+ summary: 'email.digest.day changed',
+ detail: { key: 'email.digest.day' },
+ at: new Date().toISOString(),
+ });
+ assert.equal(subscribers.nextDigestAt(), '2026-11-11T18:00:00.000Z');
+ jobs.stop();
+ });
+});
+
+describe('previews', () => {
+ it('renders every email kind with placeholders filled and both parts', () => {
+ const { subscribers } = setup();
+ for (const kind of ['confirm', 'post', 'digest'] as const) {
+ const email = subscribers.preview(kind);
+ assert.doesNotMatch(email.subject, /\{\{/);
+ assert.doesNotMatch(email.html, /\{\{/);
+ assert.match(email.html, //);
+ assert.match(email.text, /hub\.test/);
+ }
+ });
+});
diff --git a/site/server/subscribers.ts b/site/server/subscribers.ts
new file mode 100644
index 0000000..ab4d4bc
--- /dev/null
+++ b/site/server/subscribers.ts
@@ -0,0 +1,574 @@
+import { createHmac, randomBytes } from 'node:crypto';
+import { postPath } from '../app/posts.ts';
+import { uploadUrl } from '../app/profile.ts';
+import type { Values } from '../app/settings.ts';
+import {
+ describeScope,
+ isCadence,
+ knownScope,
+ parseScope,
+ scopeKey,
+ scopeMatches,
+ validEmail,
+ type Cadence,
+} from '../app/subscribe.ts';
+import type { Directory } from '../app/types.ts';
+import type { Bus } from './bus.ts';
+import type { Database } from './db.ts';
+import { digestFor, nextDigestAt, spreadDue } from './digest.ts';
+import { excerptOf, renderEmail, type EmailInput, type EmailPost } from './emails.ts';
+import type { Jobs } from './jobs.ts';
+import type { Outgoing } from './mail.ts';
+import { sameSecret } from './sessions.ts';
+import { createSends, type Deliver, type SendRow } from './sends.ts';
+
+export const DIGEST_JOB = 'digest';
+export const DIGEST_REF = 'weekly';
+const DIGEST_SINCE = 'email.digest.since';
+const DAY_MS = 86_400_000;
+const MAIL_PER_ADDRESS_PER_DAY = 3;
+
+export type Purpose = 'confirm' | 'manage' | 'unsubscribe';
+export const PURPOSES: readonly Purpose[] = ['confirm', 'manage', 'unsubscribe'];
+
+export interface Subscriber {
+ id: string;
+ email: string;
+ scope: string;
+ cadence: Cadence;
+ token: string;
+ confirmedAt: string | null;
+ unsubscribedAt: string | null;
+ suppressedAt: string | null;
+ suppressedReason: string | null;
+ createdAt: string;
+}
+
+export interface Subscribing {
+ email: string;
+ scope: string;
+ cadence: string;
+ trap: string;
+ address: string;
+}
+
+export type Subscribed = { ok: true } | { ok: false; reason: string };
+
+export type PreviewKind = 'confirm' | 'post' | 'digest';
+
+function fromRow(row: Record): Subscriber {
+ return {
+ id: row.id as string,
+ email: row.email as string,
+ scope: row.scope as string,
+ cadence: row.cadence as Cadence,
+ token: row.token as string,
+ confirmedAt: row.confirmed_at as string | null,
+ unsubscribedAt: row.unsubscribed_at as string | null,
+ suppressedAt: row.suppressed_at as string | null,
+ suppressedReason: row.suppressed_reason as string | null,
+ createdAt: row.created_at as string,
+ };
+}
+
+export function active(subscriber: Subscriber) {
+ return Boolean(subscriber.confirmedAt && !subscriber.unsubscribedAt && !subscriber.suppressedAt);
+}
+
+export function createSubscribers(
+ db: Database,
+ jobs: Jobs,
+ bus: Bus,
+ deps: {
+ secret: string;
+ origin: string;
+ allow: (address: string) => Promise;
+ settings: () => Values;
+ directory: () => Directory;
+ deliver: Deliver;
+ testAddress?: string;
+ now?: () => number;
+ },
+) {
+ const now = deps.now ?? Date.now;
+ const columns =
+ 'SELECT id, email, scope, cadence, token, confirmed_at, unsubscribed_at, suppressed_at, suppressed_reason, created_at FROM subscribers';
+
+ function read(id: string) {
+ const row = db.prepare(`${columns} WHERE id = ?`).get(id);
+ return row && fromRow(row);
+ }
+
+ function byEmail(email: string) {
+ const row = db.prepare(`${columns} WHERE email = ?`).get(email);
+ return row && fromRow(row);
+ }
+
+ function sign(purpose: Purpose, id: string, token: string) {
+ return createHmac('sha256', deps.secret)
+ .update(`${purpose}:${id}:${token}`)
+ .digest('base64url')
+ .slice(0, 32);
+ }
+
+ function link(purpose: Purpose, subscriber: Pick) {
+ return `${deps.origin}/subscribe/${purpose}/${subscriber.id}/${sign(purpose, subscriber.id, subscriber.token)}`;
+ }
+
+ async function changed(kind: string, subscriber: Subscriber, summary: string, at: string) {
+ await bus.publish({
+ topic: 'subscribers',
+ kind,
+ public: false,
+ summary,
+ detail: { id: subscriber.id, scope: subscriber.scope, cadence: subscriber.cadence },
+ at,
+ });
+ }
+
+ function site() {
+ return { name: deps.settings()['site.name'], url: deps.origin };
+ }
+
+ function emailPost(row: Record, directory: Directory): EmailPost | null {
+ const author = directory.members.find((member) => member.handle === row.author);
+ if (!author) {
+ return null;
+ }
+ const project = directory.projects.find((entry) => entry.slug === row.project);
+ return {
+ title: row.title as string,
+ summary: row.summary as string,
+ excerpt: excerptOf((row.html as string | null) ?? ''),
+ url: `${deps.origin}${postPath(author.path, row.slug as string)}`,
+ coverUrl: row.cover_upload
+ ? `${deps.origin}${uploadUrl(row.cover_upload as string, 'cover')}`
+ : null,
+ author: { name: author.name, url: `${deps.origin}${author.path}`, accent: author.accent },
+ project: project ? { name: project.name, url: `${deps.origin}${project.path}` } : null,
+ publishedAt: row.published_at as string,
+ readingMinutes: (row.reading_minutes as number | null) ?? 1,
+ };
+ }
+
+ const postColumns =
+ "SELECT id, author, slug, title, summary, html, cover_upload, project, published_at, reading_minutes FROM posts WHERE status = 'published'";
+
+ function publishedBetween(since: string, until: string) {
+ return db
+ .prepare(
+ `${postColumns} AND published_at > ? AND published_at <= ? ORDER BY published_at DESC`,
+ )
+ .all(since, until);
+ }
+
+ function rendered(input: EmailInput) {
+ return renderEmail(input, deps.settings(), site());
+ }
+
+ function outgoing(to: string, input: EmailInput, unsubscribeUrl: string | null): Outgoing {
+ const settings = deps.settings();
+ return {
+ from: { name: settings['email.from.name'] },
+ replyTo: settings['email.reply_to'] || null,
+ to,
+ ...rendered(input),
+ unsubscribeUrl,
+ };
+ }
+
+ async function compose(send: SendRow): Promise {
+ const subscriber = read(send.subscriber);
+ if (!subscriber || subscriber.suppressedAt) {
+ return null;
+ }
+ const directory = deps.directory();
+ const what = describeScope(parseScope(subscriber.scope) ?? { kind: 'all' }, directory);
+ const links = {
+ manage: link('manage', subscriber),
+ unsubscribe: link('unsubscribe', subscriber),
+ };
+ switch (send.kind) {
+ case 'confirm':
+ return subscriber.confirmedAt && !subscriber.unsubscribedAt
+ ? null
+ : outgoing(
+ subscriber.email,
+ { kind: 'confirm', what, confirmUrl: link('confirm', subscriber) },
+ null,
+ );
+ case 'manage':
+ return outgoing(subscriber.email, { kind: 'manage', what, links }, null);
+ case 'post': {
+ const row = db.prepare(`${postColumns} AND id = ?`).get(send.ref ?? '');
+ const post = row && active(subscriber) ? emailPost(row, directory) : null;
+ return post
+ ? outgoing(subscriber.email, { kind: 'post', what, post, links }, links.unsubscribe)
+ : null;
+ }
+ case 'digest': {
+ const [since, until] = (send.ref ?? '').split('/');
+ const posts = digestFor(
+ subscriber.scope,
+ publishedBetween(since, until).map((row) => ({
+ author: row.author as string,
+ project: row.project as string | null,
+ row,
+ })),
+ ).flatMap(({ row }) => emailPost(row, directory) ?? []);
+ return posts.length > 0 && active(subscriber)
+ ? outgoing(
+ subscriber.email,
+ { kind: 'digest', what, posts, since, links },
+ links.unsubscribe,
+ )
+ : null;
+ }
+ }
+ }
+
+ const sends = createSends(db, jobs, {
+ deliver: deps.deliver,
+ compose,
+ perHour: () => deps.settings()['email.hourly'],
+ bounced(id, reason, at) {
+ db.prepare(
+ 'UPDATE subscribers SET suppressed_at = ?, suppressed_reason = ?, updated_at = ? WHERE id = ?',
+ ).run(at, reason, at, id);
+ const subscriber = read(id);
+ if (subscriber) {
+ changed('bounced', subscriber, 'An address bounced and was suppressed', at).catch(() => {});
+ }
+ },
+ });
+
+ function mailedToday(subscriber: string) {
+ return db
+ .prepare(
+ "SELECT count(*) AS count FROM sends WHERE subscriber = ? AND kind IN ('confirm', 'manage') AND created_at > ?",
+ )
+ .get(subscriber, new Date(now() - DAY_MS).toISOString())!.count as number;
+ }
+
+ function armDigest() {
+ const settings = deps.settings();
+ jobs.add(
+ DIGEST_JOB,
+ DIGEST_REF,
+ nextDigestAt(now(), settings['email.digest.day'], settings['email.digest.hour']),
+ );
+ }
+
+ function nextDigest() {
+ return db
+ .prepare('SELECT due_at FROM jobs WHERE kind = ? AND ref = ? AND done_at IS NULL')
+ .get(DIGEST_JOB, DIGEST_REF)?.due_at as string | undefined;
+ }
+
+ bus.listen(({ event }) => {
+ if (event.topic === 'settings' && String(event.detail.key).startsWith('email.digest.')) {
+ armDigest();
+ }
+ });
+
+ function activeWith(cadence: Cadence) {
+ return db
+ .prepare(
+ `${columns} WHERE cadence = ? AND confirmed_at IS NOT NULL AND unsubscribed_at IS NULL AND suppressed_at IS NULL ORDER BY created_at`,
+ )
+ .all(cadence)
+ .map(fromRow);
+ }
+
+ function samplePosts(count: number) {
+ const directory = deps.directory();
+ const posts = db
+ .prepare(`${postColumns} ORDER BY published_at DESC LIMIT ?`)
+ .all(count)
+ .flatMap((row) => emailPost(row, directory) ?? []);
+ if (posts.length > 0) {
+ return posts;
+ }
+ const author = directory.members[0];
+ return [
+ {
+ title: 'A post that does not exist yet',
+ summary: 'The summary of the post goes here, in one or two lines.',
+ excerpt:
+ 'The first paragraphs of the post follow the summary, so a reader can decide whether to open it. This is what the email will look like once the members publish.',
+ url: `${deps.origin}/blog`,
+ coverUrl: null,
+ author: {
+ name: author?.name ?? 'A member',
+ url: `${deps.origin}${author?.path ?? '/members'}`,
+ accent: author?.accent ?? '#6f8fd1',
+ },
+ project: null,
+ publishedAt: new Date(now()).toISOString(),
+ readingMinutes: 3,
+ },
+ ];
+ }
+
+ function sample(kind: PreviewKind): EmailInput {
+ const what = 'every post';
+ const links = {
+ manage: `${deps.origin}/subscribe/manage/preview/preview`,
+ unsubscribe: `${deps.origin}/subscribe/unsubscribe/preview/preview`,
+ };
+ switch (kind) {
+ case 'confirm':
+ return { kind, what, confirmUrl: `${deps.origin}/subscribe/confirm/preview/preview` };
+ case 'post':
+ return { kind, what, post: samplePosts(1)[0], links };
+ case 'digest':
+ return {
+ kind,
+ what,
+ posts: samplePosts(3),
+ since: new Date(now() - 7 * DAY_MS).toISOString(),
+ links,
+ };
+ }
+ }
+
+ return {
+ read,
+ link,
+ sends,
+ armDigest,
+
+ start() {
+ if (!jobs.pending(DIGEST_JOB, DIGEST_REF)) {
+ armDigest();
+ }
+ sends.arm();
+ },
+
+ count() {
+ return db
+ .prepare(
+ 'SELECT count(*) AS count FROM subscribers WHERE confirmed_at IS NOT NULL AND unsubscribed_at IS NULL AND suppressed_at IS NULL',
+ )
+ .get()!.count as number;
+ },
+
+ async subscribe(subscribing: Subscribing): Promise {
+ if (subscribing.trap) {
+ return { ok: true };
+ }
+ const email = subscribing.email.trim().toLowerCase();
+ const scope = parseScope(subscribing.scope);
+ if (!validEmail(email)) {
+ return { ok: false, reason: 'That does not look like an email address.' };
+ }
+ if (!scope || !knownScope(scope, deps.directory()) || !isCadence(subscribing.cadence)) {
+ return { ok: false, reason: 'Pick what to follow and how often.' };
+ }
+ if (!(await deps.allow(subscribing.address))) {
+ return { ok: false, reason: 'That is a lot of subscribing; try again in a while.' };
+ }
+ const at = new Date(now()).toISOString();
+ const existing = byEmail(email);
+ if (existing && mailedToday(existing.id) >= MAIL_PER_ADDRESS_PER_DAY) {
+ return { ok: false, reason: 'Three emails went to that address today; look for them.' };
+ }
+ let subscriber: Subscriber;
+ if (existing && active(existing)) {
+ subscriber = existing;
+ sends.queue(existing.id, 'manage', null, at);
+ } else if (existing) {
+ db.prepare(
+ `UPDATE subscribers SET scope = ?, cadence = ?, token = ?, confirmed_at = NULL, unsubscribed_at = NULL,
+ suppressed_at = NULL, suppressed_reason = NULL, updated_at = ? WHERE id = ?`,
+ ).run(
+ scopeKey(scope),
+ subscribing.cadence,
+ randomBytes(24).toString('base64url'),
+ at,
+ existing.id,
+ );
+ subscriber = read(existing.id)!;
+ sends.queue(subscriber.id, 'confirm', null, at);
+ } else {
+ const id = randomBytes(8).toString('hex');
+ db.prepare(
+ 'INSERT INTO subscribers (id, email, scope, cadence, token, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ ).run(
+ id,
+ email,
+ scopeKey(scope),
+ subscribing.cadence,
+ randomBytes(24).toString('base64url'),
+ at,
+ at,
+ );
+ subscriber = read(id)!;
+ sends.queue(id, 'confirm', null, at);
+ }
+ sends.arm(now());
+ await changed('asked', subscriber, 'Someone asked to subscribe', at);
+ return { ok: true };
+ },
+
+ verify(purpose: string, id: string, signature: string) {
+ const subscriber = read(id);
+ if (!PURPOSES.includes(purpose as Purpose) || !subscriber) {
+ return undefined;
+ }
+ return sameSecret(sign(purpose as Purpose, id, subscriber.token), signature)
+ ? subscriber
+ : undefined;
+ },
+
+ async confirm(id: string) {
+ const at = new Date(now()).toISOString();
+ db.prepare(
+ 'UPDATE subscribers SET confirmed_at = coalesce(confirmed_at, ?), unsubscribed_at = NULL, updated_at = ? WHERE id = ?',
+ ).run(at, at, id);
+ const subscriber = read(id)!;
+ await changed('confirmed', subscriber, 'A subscriber confirmed', at);
+ return subscriber;
+ },
+
+ async update(id: string, scopeValue: string, cadence: string) {
+ const scope = parseScope(scopeValue);
+ if (!scope || !knownScope(scope, deps.directory()) || !isCadence(cadence)) {
+ return { ok: false as const, reason: 'Pick what to follow and how often.' };
+ }
+ const at = new Date(now()).toISOString();
+ db.prepare('UPDATE subscribers SET scope = ?, cadence = ?, updated_at = ? WHERE id = ?').run(
+ scopeKey(scope),
+ cadence,
+ at,
+ id,
+ );
+ await changed('changed', read(id)!, 'A subscriber changed their preferences', at);
+ return { ok: true as const };
+ },
+
+ async unsubscribe(id: string) {
+ const at = new Date(now()).toISOString();
+ db.prepare(
+ 'UPDATE subscribers SET unsubscribed_at = coalesce(unsubscribed_at, ?), updated_at = ? WHERE id = ?',
+ ).run(at, at, id);
+ db.prepare(
+ "UPDATE sends SET status = 'failed', error = 'unsubscribed' WHERE subscriber = ? AND status = 'queued' AND kind IN ('post', 'digest')",
+ ).run(id);
+ await changed('unsubscribed', read(id)!, 'A subscriber left', at);
+ },
+
+ async resubscribe(id: string) {
+ const at = new Date(now()).toISOString();
+ db.prepare(
+ 'UPDATE subscribers SET unsubscribed_at = NULL, updated_at = ? WHERE id = ? AND confirmed_at IS NOT NULL',
+ ).run(at, id);
+ await changed('confirmed', read(id)!, 'A subscriber came back', at);
+ },
+
+ queuePost(post: { id: string; author: string; project: string | null }) {
+ const at = now();
+ const perHour = deps.settings()['email.hourly'];
+ let queued = 0;
+ for (const subscriber of activeWith('each')) {
+ const scope = parseScope(subscriber.scope);
+ if (
+ scope &&
+ scopeMatches(scope, post) &&
+ sends.queue(subscriber.id, 'post', post.id, spreadDue(at, queued, perHour))
+ ) {
+ queued += 1;
+ }
+ }
+ if (queued > 0) {
+ sends.arm(at);
+ }
+ return queued;
+ },
+
+ runDigest() {
+ const at = now();
+ const until = new Date(at).toISOString();
+ const since =
+ (db.prepare('SELECT value FROM state WHERE key = ?').get(DIGEST_SINCE)?.value as
+ string | undefined) ?? new Date(at - 7 * DAY_MS).toISOString();
+ const posts = publishedBetween(since, until).map((row) => ({
+ author: row.author as string,
+ project: row.project as string | null,
+ }));
+ armDigest();
+ if (posts.length === 0) {
+ return { skipped: true, queued: 0 };
+ }
+ const perHour = deps.settings()['email.hourly'];
+ let queued = 0;
+ for (const subscriber of activeWith('weekly')) {
+ if (
+ digestFor(subscriber.scope, posts).length > 0 &&
+ sends.queue(subscriber.id, 'digest', `${since}/${until}`, spreadDue(at, queued, perHour))
+ ) {
+ queued += 1;
+ }
+ }
+ db.prepare(
+ 'INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
+ ).run(DIGEST_SINCE, until);
+ sends.arm(at);
+ return { skipped: false, queued };
+ },
+
+ nextDigestAt: nextDigest,
+
+ preview(kind: PreviewKind) {
+ return rendered(sample(kind));
+ },
+
+ sendTest(kind: PreviewKind, to: string) {
+ return deps.deliver(outgoing(to, sample(kind), null));
+ },
+
+ overview() {
+ const rows = db.prepare(columns).all().map(fromRow);
+ const live = rows.filter(active);
+ const scopes = new Map();
+ for (const subscriber of live) {
+ scopes.set(subscriber.scope, (scopes.get(subscriber.scope) ?? 0) + 1);
+ }
+ const emails = new Map(rows.map((row) => [row.id, row.email]));
+ return {
+ testTo: deps.testAddress ?? '',
+ nextDigestAt: nextDigest() ?? null,
+ confirmed: live.length,
+ pending: rows.filter((row) => !row.confirmedAt).length,
+ unsubscribed: rows.filter((row) => row.confirmedAt && row.unsubscribedAt).length,
+ weekly: live.filter((row) => row.cadence === 'weekly').length,
+ each: live.filter((row) => row.cadence === 'each').length,
+ scopes: [...scopes]
+ .map(([scope, count]) => ({ scope, count }))
+ .sort((a, b) => b.count - a.count),
+ sends: sends.counts(),
+ recent: sends.recent(20).map((send) => ({
+ id: send.id,
+ email: emails.get(send.subscriber) ?? 'gone',
+ kind: send.kind,
+ status: send.status,
+ at: send.sentAt ?? send.dueAt,
+ attempts: send.attempts,
+ error: send.error,
+ })),
+ bounces: rows
+ .filter((row) => row.suppressedAt)
+ .sort((a, b) => b.suppressedAt!.localeCompare(a.suppressedAt!))
+ .slice(0, 20)
+ .map((row) => ({
+ id: row.id,
+ email: row.email,
+ at: row.suppressedAt!,
+ reason: row.suppressedReason ?? '',
+ })),
+ };
+ },
+ };
+}
+
+export type Subscribers = ReturnType;
diff --git a/site/server/testing.ts b/site/server/testing.ts
index 8519c1e..d9ea3aa 100644
--- a/site/server/testing.ts
+++ b/site/server/testing.ts
@@ -8,6 +8,7 @@ export function memoryDatabase() {
export function recordingBus() {
const recorded: HubEvent[] = [];
const published: HubEvent[] = [];
+ const listeners = new Set<(delivery: { event: HubEvent }) => void>();
const bus = {
ready: Promise.resolve(),
record: async (event: HubEvent) => {
@@ -16,9 +17,15 @@ export function recordingBus() {
},
publish: async (event: HubEvent) => {
published.push(event);
+ for (const listener of listeners) {
+ listener({ event });
+ }
},
since: async () => [],
- listen: () => () => true,
+ listen: (listener: (delivery: { event: HubEvent }) => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
} as unknown as Bus;
return { bus, recorded, published };
}
From e20ab2b1f38e3b9c24062db1a4bf8c8cb21e7834 Mon Sep 17 00:00:00 2001
From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com>
Date: Sat, 7 Nov 2026 01:26:16 +0200
Subject: [PATCH 2/2] feat(subscribe): the subscribe page, footer form, rail
panels and signed preference links
---
site/app/components/AccountMenu.tsx | 1 +
site/app/components/Shell.tsx | 18 ++-
site/app/components/kit/subscribe.tsx | 219 ++++++++++++++++++++++++++
site/app/meta.ts | 17 +-
site/app/pages/member-page.tsx | 5 +
site/app/pages/project-page.tsx | 5 +
site/app/routes.ts | 2 +
site/app/routes/blog.tsx | 3 +
site/app/routes/llms.ts | 1 +
site/app/routes/post.tsx | 7 +
site/app/routes/privacy.tsx | 5 +
site/app/routes/sitemap.ts | 1 +
site/app/routes/subscribe.tsx | 103 ++++++++++++
site/app/routes/subscription.tsx | 201 +++++++++++++++++++++++
14 files changed, 582 insertions(+), 6 deletions(-)
create mode 100644 site/app/components/kit/subscribe.tsx
create mode 100644 site/app/routes/subscribe.tsx
create mode 100644 site/app/routes/subscription.tsx
diff --git a/site/app/components/AccountMenu.tsx b/site/app/components/AccountMenu.tsx
index 7ab062e..b9e929a 100644
--- a/site/app/components/AccountMenu.tsx
+++ b/site/app/components/AccountMenu.tsx
@@ -111,6 +111,7 @@ export default function AccountMenu() {
{ to: '/admin', label: 'admin' },
{ to: `${pathname}?edit`, label: 'edit this page' },
{ to: '/messages', label: 'moderate messages' },
+ { to: '/admin#subscribers', label: 'subscribers' },
...(import.meta.env.DEV ? [{ to: '/kit', label: 'kit' }] : []),
]
: [];
diff --git a/site/app/components/Shell.tsx b/site/app/components/Shell.tsx
index 485fff1..eeb40ce 100644
--- a/site/app/components/Shell.tsx
+++ b/site/app/components/Shell.tsx
@@ -7,6 +7,7 @@ import { defaults, visible, type NAV } from '../settings';
import AccountMenu from './AccountMenu';
import { Editable, useEditing } from './kit/Editable';
import { openSearch, Palette } from './kit/Palette';
+import { SubscribeForm, SubscribeLink } from './kit/subscribe';
const NAV_TARGET: Record<(typeof NAV)[number], { to: string; label: string }> = {
members: { to: '/members', label: 'members' },
@@ -81,11 +82,18 @@ export default function Shell({ children }: { children: ReactNode }) {