Skip to content

feat(subscribe): subscriptions, post and digest emails, templates on /admin - #44

Merged
AmrMsCLL merged 2 commits into
mainfrom
feat/subscribe
Nov 6, 2026
Merged

AmrMsCLL merged 2 commits into
mainfrom
feat/subscribe

Conversation

@AmrMsCLL

@AmrMsCLL AmrMsCLL commented Nov 6, 2026

Copy link
Copy Markdown
Member

Phase 5, part 2: subscriptions and email. Plan: .claude/.Plans/as2026-11-07-graphite-hub-phase-5-subscribe.md.

What changed

Subscribing

  • /subscribe: choose everything, a project or a member; each post or a weekly digest; a live sentence says what you will get. Reached from the footer form on every page ("choose what you get"), the blog header, and rail panels on /blog, every post, every project page and every member page (prefilled scope).
  • Double opt-in. The public form is honeypotted and rate limited (5 an hour per address, 3 emails a day per subscriber address). Subscribing an address that is already confirmed sends a "manage your subscription" email and changes nothing, so the public form cannot alter someone else's preferences.
  • Signed links, three purposes: /subscribe/confirm|manage|unsubscribe/:id/:hmac. The manage page saves scope and cadence, unsubscribes and resubscribes. The unsubscribe link renders one button on GET and acts on POST; the same POST serves List-Unsubscribe-Post: List-Unsubscribe=One-Click from mail clients (no Origin, no CSRF; the HMAC is the credential). A GET never changes state.

Sending

  • Every email is a sends row (queued/sent/failed/bounced, attempts, message id). One email job in the existing jobs table drains what is due, at most email.hourly an hour (default 20, so under Gmail's daily limit), and re-arms itself at the next due row or in five minutes when the budget is spent. Post and digest sends are spaced 3600 / email.hourly seconds apart when queued.
  • Publishing a post (action or scheduled job) queues sends to confirmed "each post" subscribers whose scope matches, through a new onPublished hook on createPosts. A partial unique index makes re-publishing idempotent.
  • The digest job runs weekly at email.digest.day / email.digest.hour UTC, re-arms itself and on settings change, is skipped when nothing was published since the last digest that went out, and sends each weekly subscriber only the posts their scope covers.
  • Bounces without a webhook: a recipient rejected at RCPT TO (5xx EENVELOPE, or listed in rejected) is recorded as bounced and the address is suppressed; other 5xx fail for good; 4xx and connection errors retry with backoff up to five attempts. Subscribing a suppressed address again clears the suppression and sends a fresh confirmation.
  • Sent as "<email.from.name>" <ALERTS_FROM> through the existing SMTP_URL transport, with Reply-To from email.reply_to, List-Unsubscribe + List-Unsubscribe-Post + Precedence: bulk on post and digest emails.

Emails

  • server/emails.ts renders confirmation, manage, post and digest emails as HTML (600 px table layout, inline styles, the site's dark palette, the author's accent) and text. Subjects, intros and the footer line are settings with {{placeholders}}.
  • /admin gains an email area: sender name, reply-to, emails an hour, digest day (new choice setting kind) and hour, the templates; a live preview (iframe to the owner-only /admin/email?kind=, reloaded when an email setting changes) and "send a test" to a typed address (defaults to ALERTS_EMAIL). A subscribers band shows counts, scopes, queued/sent today, next digest, recent sends and bounces. The owner menu links to it.

Housekeeping

  • jobs.add no longer deletes the row of a job that is running when the handler re-arms its own kind (the digest and email jobs do), so the next run is never marked done by mistake.
  • Retention: unsubscribed rows after 30 days, never-confirmed rows after 7, sends after 90. /privacy gets a Subscribers entry. /subscribe in the sitemap and llms.txt; manage pages are noindex.

Migration 007

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;

Additive only. No dependency added (nodemailer was already present).

Production

  • ALERTS_FROM is now also the subscription sender (already set); SMTP_URL as today.
  • ALERTS_EMAIL only prefills the "send a test" address.

Checks

  • npm run typecheck, npx eslint ., npx prettier --check ., npm run build, npm run size (largest page 146.7 KB, unchanged; /subscribe 136.8 KB, /admin 139.5 KB).
  • Specs run: server/subscribers.test.ts, server/sends.test.ts, server/digest.test.ts, server/jobs.test.ts, server/posts.test.ts, server/settings.test.ts, server/messages.test.ts (40 passing).
  • Against Mailpit and a local Redis: footer and /subscribe forms; confirmation email (From Project Graphite <projectgraphite.org@gmail.com>, multipart/alternative); confirm link lands on the manage page; publishing a post from the editor sent the post email with List-Unsubscribe, List-Unsubscribe-Post: List-Unsubscribe=One-Click and both parts; the digest job (forced due) sent "This week on Project Graphite: 1 new post" to the weekly subscriber only; manage page saved a new scope; unsubscribe page button and an RFC 8058 style POST without Origin both unsubscribed; resubscribe worked; a forged signature is a 404; /admin preview and "send a test" delivered; a stub SMTP server answering 550 5.1.1 at RCPT TO produced a bounced send, suppressed the address and showed under bounces on /admin.

Out of scope, noticed

Every post page logs a hydration error from the "about" rail panel (<a> inside <a> in the mini window), from part 1.

@github-actions github-actions Bot added the size/xl Over 600 changed lines, excluding lockfiles label Nov 6, 2026
@AmrMsCLL
AmrMsCLL merged commit a792d3c into main Nov 6, 2026
11 checks passed
@AmrMsCLL
AmrMsCLL deleted the feat/subscribe branch November 6, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl Over 600 changed lines, excluding lockfiles

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant