Originally published at mrsaynothing.dev.
This site's newsletter runs on a server I wrote myself: 1,258 lines of TypeScript, Svelte and SQL. Double opt-in, one-click unsubscribe, a CLI for sends, a health endpoint. No platform account, no monthly invoice, and the subscriber list lives in one file I can copy with rsync.
A newsletter is three endpoints, two tables, and one email header most platforms hide from you.
That header is why the build got short. In February 2024, Google and Yahoo made one-click unsubscribe a requirement for bulk senders, implementing RFC 8058 — List-Unsubscribe plus List-Unsubscribe-Post headers on every email. The hard part of email marketing became a standardized header. Everything a paid platform does around it is either that, or the address book.
Why self-host a newsletter at all?
The honest inventory: the list is small, the volume is one email per post, and I already run the site's other services. Renting a platform for that meant trusting a third party with the one audience relationship I actually own, plus an export format with a polite name and an unclear spec. Writing it myself took an afternoon for the core and another for the edges.
| This server | Hosted platform | |
|---|---|---|
| Lines of code I can read | 1,258, all of them | 0 |
| List ownership | one SQLite file | export button |
| Unsubscribe | RFC 8058 header, self-signed | dashboard setting |
| Deliverability ceiling | ~5/10 mail-tester until DKIM aligns | their reputation |
| Time to first send | a weekend | an evening |
The table is the trade in one screen. A platform wins on deliverability reputation and wins on not-having-to-think; the file wins on ownership and on being auditable in an editor.
How does double opt-in work without a session?
Three files carry the whole flow. db.ts holds two tables — subscribers, with a CHECK constraint pinning status to pending, confirmed or unsubscribed, and sends for the audit trail. The subscribe endpoint takes one POST:
curl -X POST https://mrsaynothing.dev/letters/api/subscribe \
-H 'content-type: application/json' \
-d '{"email":"reader@example.com"}'
Before that endpoint looks at your email, it looks at a form field named website. Humans never see it, so it stays empty; bots fill everything, so a filled field buys them a fake ok and nothing else. Past the honeypot, the address sits in a token bucket — 10 requests per hour per IP, one resend per address every 10 minutes — and the responses are deliberately identical whether or not the address is already subscribed, so the endpoint can't be used to enumerate anyone.
The confirm link carries a signed token instead of a session:
import { createHmac, timingSafeEqual } from 'node:crypto';
// payload = base64url(email|action), MAC = HMAC-SHA256(payload)
export function sign(email: string, action: 'confirm' | 'unsub'): string {
const payload = Buffer.from(`${email}|${action}`).toString('base64url');
const mac = createHmac('sha256', config.hmacSecret).update(payload).digest('base64url');
return `${payload}.${mac}`;
}
Verification is timingSafeEqual against a recomputed MAC. There is no token table and no expiry to manage — the signature is the only authority, so a confirm link works even with the database mid-restart. Unsubscribe tokens never expire at all, on purpose: consent mechanics must keep working forever, and revoking them means rotating the signing key, which lives outside the repo and gets mounted read-only at runtime.
A confirm link that still works when the database is asleep is one less thing that can rot.
One receiver quirk shaped the security config: Gmail's one-click unsubscribe fires a cross-origin POST straight at the endpoint, so the usual same-origin check had to be disabled on that route — the MAC does the authenticating there, the origin header was only getting in the way of a standard.
What broke while building it
The ledger, in the order it happened:
-
The image build failed on
ERR_PNPM_IGNORED_BUILDS.pnpm install --frozen-lockfileran clean locally and died in the container because esbuild's postinstall script was never approved. The fix was shipping the smallpnpm-workspace.yamlthat declares allowed build scripts into the image. Half a day to find, one file to fix. - CSRF blocked Gmail. The one-click POST from Gmail's servers has no same-origin credentials, obviously. The route now trusts the token, and the token is unforgeable.
- Deliverability has a ceiling on the free path. Without aligned DKIM, mail-tester scores the setup around 5/10, and strict receivers may junk the mail. That is the honest cost of skipping a paid relay; the fix is known and waiting if the list ever justifies it.
What it still can't do
No open tracking — that one is a principle, since tracking pixels are the reason people distrust newsletters. One list, no segments, no scheduling beyond a CLI invoked by hand or timer, and the deliverability ceiling above. Sending is one command, cli.mjs send --subject ... --file post.md, with a --dry flag that renders the full text and HTML for review before anything moves. The backup story is the SQLite one: copy the file, restore the file, and WAL mode keeps the copy consistent while the server runs. Watching it start under systemd-class supervision is the rest of the ops burden.
215 of those lines are the CLI. The other 1,043 are consent.
Which part of your stack is a rented platform doing work you'd rather own in a file you can read? And which subscription are you still paying because migrating the data sounds worse than the invoice?
This ships daily at mrsaynothing.dev — the full archive, every piece in 21 languages, zero missed days. New posts land in the newsletter the moment they ship: join it here. Code at GitHub.
Top comments (2)
Builder's ledger note: 215 of the 1,258 lines are the CLI; the other 1,043 are consent. And the honest part — my mail-tester score sits around 5/10 because DKIM isn't aligned, and I shipped anyway. Small lists forgive; most of the SaaS markup is deliverability insurance. At what list size does that trade actually flip for you?
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support