一个独立开发者把网站的订阅系统换成了自己写的服务:1258行 TypeScript、Svelte 和 SQL 代码。双确认订阅、一键退订、发送用命令行工具、一个健康检查接口。没有平台账号,没有月度账单,订阅者名单就躺在一个文件里,用 rsync 就能拷走。

这套东西的核心其实只有三样:三个接口、两张表、一个邮件头。而那个邮件头,恰恰是大多数平台藏起来不给你看的部分。

打开网易新闻 查看精彩图片

一个标准化邮件头,把整件事变简单了

2024年2月,Google 和 Yahoo 对批量发件人提出了一键退订的强制要求,落地的是 RFC 8058——每封邮件都要带上 List-Unsubscribe 和 List-Unsubscribe-Post 这两个头。邮件营销里最难啃的那块,变成了一个标准化的邮件头。

付费平台围绕它做的一切,要么就是这个头,要么就是通讯录本身。想清楚这一点,自建的边界就清楚了。

为什么要自建?把家底盘一盘:名单很小,发信频率是每篇文章一封,而且网站的其他服务本来就在自己跑。为了这点需求去租一个平台,等于把唯一真正属于自己的读者关系交给第三方,还要接受一个名字很客气、规格却说不清楚的导出格式。核心部分花了一个下午,边角部分又花了一个下午。

平台赢在送达率声誉,也赢在不用动脑子;文件赢在所有权,以及能在编辑器里逐行审计。

没有会话,双确认怎么跑通

三个文件撑起整个流程。db.ts 里放两张表:subscribers 表用 CHECK 约束把 status 钉死在 pending、confirmed、unsubscribed 三个值上;sends 表用来留审计记录。订阅接口只收一个 POST 请求。

在接口看你的邮箱之前,它先看一个叫 website 的表单字段。真人根本看不到这个字段,所以它一直是空的;机器人什么都填,填了就换来一个假的 ok,别的什么也拿不到。过了这个蜜罐,地址会进一个令牌桶——每个 IP 每小时 10 次请求,每个地址每 10 分钟只能重发一次。而且不管这个地址是否已经订阅过,返回的响应都刻意做成一样的,这样接口就没法被用来枚举用户。

确认链接里带的是签名令牌,不是会话。用 node:crypto 的 createHmac 和 timingSafeEqual 做签名与校验,payload 是邮箱和动作拼起来再做 base64url 编码,MAC 用 HMAC-SHA256 算出来。校验时用 timingSafeEqual 跟重新算出的 MAC 比对。

没有令牌表,也没有过期时间要管——签名本身就是唯一凭据,所以就算数据库正在重启,确认链接照样能用。退订令牌则故意永不过期:同意的机制必须永远有效,想作废它们只能轮换签名密钥,而密钥放在仓库外面,运行时只读挂载。

一个在数据库睡着时还能用的确认链接,就少了一样会烂掉的东西。

还有一个收件端的怪癖塑造了安全配置:Gmail 的一键退订会直接朝接口发一个跨域 POST,所以那条路由上常规的同源检查必须关掉——在那里负责认证的是 MAC,origin 头只是在挡一个标准的路。

建的过程中坏了什么

按发生顺序记账:

  • 镜像构建挂在 ERR_PNPM_IGNORED_BUILDS 上。pnpm install --frozen-lockfile 在本地跑得好好的,进容器就死,原因是 esbuild 的 postinstall 脚本从来没被批准过。修法是把声明允许构建脚本的那个小小的 pnpm-workspace.yaml 一起打进镜像。找了半天,改一个文件。
  • CSRF 挡住了 Gmail。来自 Gmail 服务器的一键 POST 显然没有同源凭据。这条路由现在信任令牌,而令牌是不可伪造的。
  • 免费路线上送达率有天花板。没有对齐的 DKIM,mail-tester 给这套配置打的分在 5/10 左右,严格的收件方可能把邮件丢进垃圾箱。这是跳过付费中继的诚实代价;修法是已知的,等名单规模撑得起再说。

它仍然做不到的事

没有打开率追踪——这一条是原则问题,追踪像素正是人们不信任订阅邮件的原因。只有一个名单,没有分组,除了手动或定时器触发的命令行工具之外没有排期,还有上面说的送达率天花板。

发送就是一条命令:cli.mjs send --subject ... --file post.md,带一个 --dry 参数,可以在任何东西发出去之前把纯文本和 HTML 全文渲染出来审一遍。备份走的是 SQLite 那套:拷文件、还原文件,WAL 模式保证服务器运行期间拷贝出来的一致性。剩下的运维负担,就是在 systemd 这类监管下看着它启动。

这 1258 行里,有 215 行是命令行工具。另外 1043 行,全是关于同意的。

你的技术栈里,哪一部分是租来的平台在干着你宁愿自己拥有、放进一个能读的文件的活?又有哪个订阅,你还在付钱,只是因为迁移数据听起来比账单更糟?