这个网站的 newsletter 跑在我自己写的服务器上:1,258 行 TypeScript、Svelte 和 SQL。双重确认、一键退订、发送用的 CLI、一个健康检查端点。没有平台账号,没有月账单,订阅者列表就是一份能用 rsync 复制的文件。
一套 newsletter,说穿了就是三个端点、两张表,外加一个大多数平台不肯让你看的邮件头。
这个头,正是整个工程变短的原因。2024 年 2 月,Google 和 Yahoo 把一键退订列为批量发件人的硬性要求,落地方式就是 RFC 8058——每封邮件带上 List-Unsubscribe 和 List-Unsubscribe-Post 头。邮件营销里最难的那部分,从此变成了一个标准化头部。付费平台在这之外做的事,要么是这个,要么是通讯录。
到底为什么要自建 newsletter?
老实盘一遍:列表不大,量是每篇文章一封,网站的其他服务本来就在我手上。为这点事租一个平台,等于把唯一一份真正属于我的读者关系交给第三方保管,外加一个名字起得很客气、规格却说不清的导出格式。自己写,核心用了一个下午,边边角角又用了一个下午。
| 这个服务器 | 托管平台 | |
|---|---|---|
| 我能读的代码行数 | 1,258,每一行 | 0 |
| 列表归属 | 一个 SQLite 文件 | 一个导出按钮 |
| 退订 | RFC 8058 头,自己签名 | 后台设置项 |
| 送达率天花板 | DKIM 对齐前 mail-tester 约 5/10 | 平台自己的信誉 |
| 首次发送耗时 | 一个周末 | 一个晚上 |
这张表就是这笔交易的全部。平台赢在送达信誉和「不用操心」;文件赢在归属权,以及可以用编辑器逐行审计。
没有会话,双重确认怎么跑?
三个文件撑起整个流程。db.ts 里有两张表——subscribers 用 CHECK 约束把 status 锁在 pending、confirmed 或 unsubscribed,sends 负责审计留痕。订阅端点只收一个 POST:
curl -X POST https://mrsaynothing.dev/letters/api/subscribe
-H 'content-type: application/json'
-d '{"email":"[email protected]"}' 在看你邮箱之前,这个端点先看一个叫 website 的表单字段。人类永远看不到它,所以它永远是空的;机器人什么都填,所以填了这个字段只会换来一个假的 ok。过了 honeypot,地址就进了令牌桶——每个 IP 每小时 10 次请求,同一地址 10 分钟只补发一次——而且无论地址是否已订阅,响应内容刻意保持一致,谁也没法拿这个端点去枚举用户。
确认链接里装的不是会话,是签过名的 token:
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}`;
} 校验就是拿重算出来的 MAC 做 timingSafeEqual。没有 token 表,没有过期时间要管——签名就是唯一的权威,所以数据库正在重启时确认链接照样能用。退订 token 更是永远不过期,这是刻意的:同意机制必须永远可用,要吊销它就只能换签名密钥,而密钥放在仓库之外,运行时以只读方式挂载进来。
数据库睡着的时候确认链接依然能用,这就少了一样会烂掉的东西。
有一个收件方的怪癖决定了安全配置:Gmail 的一键退订会直接朝端点发跨域 POST,所以这条路由上常规的同源校验只能关掉——那里负责认证的是 MAC,Origin 头只是在给标准添堵。
构建过程中坏了什么
流水账,按发生顺序:
- 镜像构建死在
ERR_PNPM_IGNORED_BUILDS上。pnpm install --frozen-lockfile本地跑得好好的,进了容器就崩,因为 esbuild 的 postinstall 脚本从没被批准过。修法是把那个声明允许构建脚本的小pnpm-workspace.yaml一起打进镜像。找了半天,改了一个文件。 - CSRF 拦住了 Gmail。 Gmail 服务器发来的一键退订 POST,显然不会带同源凭据。现在这条路由只信 token,而 token 伪造不出来。
- 免费路线的送达率有天花板。 DKIM 没对齐,mail-tester 给这套配置打 5/10 左右,严格的收件方可能扔垃圾箱。这就是省掉付费 relay 的诚实代价;修法是现成的,等列表规模配得上再说。
它还做不到什么
没有打开率追踪——这条是原则问题:追踪像素就是人们不信任 newsletter 的原因。只有一个列表,没有分群,除了手动或定时调 CLI 之外没有任何排期功能,再加上上面那个送达率天花板。发送就一条命令:cli.mjs send --subject ... --file post.md,带个 --dry,先把文字版和 HTML 版完整渲染出来给你过目,确认了才真正发。至于备份那点事,SQLite 的答案最朴素:复制文件,恢复文件,WAL 模式保证服务器运行时副本依然一致。让它在 systemd 那套监管下起来,运维的活就剩这些了。
这 1,258 行里有 215 行是 CLI。剩下 1,043 行,全是「同意」这两个字。
你的技术栈里,哪一块是租来的平台在替你干活,而你其实更想把它收进一个自己读得懂的文件?还有哪一笔订阅,你纯粹是因为数据迁移听起来比账单更难受才一直在续?
想订阅的话,入口在 [mrsaynothing.dev/newsletter](/en/newsletter)——它就跑在这套东西上。FAQ
自建的 newsletter 送达率行吗?
它走普通 SMTP,天花板和任何小发件人一样:DKIM 没对齐时,mail-tester 给这套配置打 5/10 左右,严格的收件方可能直接扔进垃圾箱。小列表完全够用;真在意严格的收件方,加一个 relay 就能解决。
为什么用 node:sqlite 而不是 Postgres?
全部状态就是两张表——subscribers 和 sends。SQLite 开 WAL 模式零守护进程就能扛住,备份就是复制一个文件。Postgres 是这份工作里最大的一笔依赖,干的却是最小的活。
一键退订是怎么实现的?
每封邮件都带 RFC 8058 定义的 List-Unsubscribe 和 List-Unsubscribe-Post 头。从 2024 年 2 月起,Gmail 和 Yahoo 就要求批量发件人支持一键退订——这个头本身就是全部功能。
— mrsaynothing
— mrsaynothing
构建、故障,以及真正上线的东西。
在 dev.to 上讨论这篇文章 dev.to ↗
下一篇构建日志,直达邮箱
每篇一封。附证据与错误。
这是什么?喜欢这些文章?我的本职工作就是这样的工程。 订阅通讯