A newsletter, and the review that took it apart
2026-09-07
The first version shipped in one push, returned 500 on every route for two unrelated reasons, and then turned out to be an email bomb aimed at anyone.
An email newsletter went live on the blog and then got attacked on purpose. Commit 7f04dde added the whole first version in one push, 1,054 insertions across ten files: api/newsletter.ts at 199 lines, src/lib/newsletterCore.ts at 216, scripts/newsletter-send.ts at 189, src/components/Subscribe.tsx at 104, and the remainder in its test, the doc, a stylesheet, the blog page and config. The subscriber list lives in a GitHub gist reached with the GITHUB_TOKEN the CMS already uses, so the store needed no new service. Mail did not survive first contact. The first version sent through Resend, which nobody had an account for, and 2b1f3d5 replaced it with SMTP to MXroute, the owner's existing mail host, whose account, sending reputation and management credentials were already in place. Provisioned on 2026-09-07: the domain added to that account after proving ownership with a _da-verify TXT record at Porkbun, and a newsletter@retroportingtoolkit.com mailbox capped at 500 messages a day. DNS at Porkbun got DKIM at x._domainkey, SPF extended additively from include:_spf.porkbun.com to also carry include:mxroute.com, and DMARC at p=none. MX was deliberately left pointing at Porkbun forwarding, because this domain sends mail and does not receive it, and repointing it would have broken existing forwarding. The cost of that choice is recorded: bounces and DMARC rua reports go to forwarding nobody is watching.
The endpoint returned 500 on every route for two unrelated reasons, each costing a full deploy-and-read-the-logs cycle. First, api/newsletter.ts imported "../src/lib/newsletterCore" with no extension. The module was bundled into the lambda, vercel build writes src/lib/newsletterCore.js right beside the function, but the package is type: module, so Node's ESM resolver will not guess an extension the way TypeScript's bundler resolution does; 9953262 added the .js. Second, the function used export default, which Vercel routes through the legacy Node launcher where req.url is a bare path and new URL throws ERR_INVALID_URL. Named GET and POST exports get the Web-standard Request, whose url is absolute, which is why api/cms.ts had worked all along; 2b1f3d5 carried that fix alongside the transport change. Separately, Vercel environment variables turned out to be write-only: vercel env pull returns project variables empty, so NEWSLETTER_SECRET had to be rotated because the original was piped into Vercel and nowhere else, which meant the send script could never have signed an unsubscribe link.
Once it was live it was reviewed as a stranger would attack it, and three defects were real. Every POST to /subscribe mailed whatever address it named, uncapped, which is an email bomb aimed at anyone, sent from this domain, draining a 500-a-day mailbox and the GITHUB_TOKEN rate limit that /admin shares. Commit 2327deb added subscribeDecision as a pure function in newsletterCore.ts: CONFIRM_COOLDOWN_MS of 15 minutes per address, never a second mail to an address already confirmed, and MAX_PENDING of 500 unconfirmed records, a ceiling that counts only records an attacker can create. The escalation that made it permanent was that readList ignored GitHub's truncated flag, so about a megabyte of junk addresses would have left every route unable to parse the list until a human edited the gist by hand; 3dca3ad made that flag throw. Confirm and unsubscribe also changed state on GET, so a victim's own mail scanner could complete their double opt-in or silently unsubscribe them. Both now render a button and act only on POST, which is what RFC 8058 One-Click wants anyway. Confirm and unsubscribe lifetimes were split, CONFIRM_MAX_AGE_MS at 14 days against UNSUB_MAX_AGE_MS at 10 years, because an unsubscribe link has to outlive the archive it sits in.