返回博客

我的 Newsletter 服务器只有 1,258 行 node:sqlite 代码

2026年9月26日

这个网站的 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 头只是在给标准添堵。

构建过程中坏了什么

流水账,按发生顺序:

  1. 镜像构建死在 ERR_PNPM_IGNORED_BUILDS 上。 pnpm install --frozen-lockfile 本地跑得好好的,进了容器就崩,因为 esbuild 的 postinstall 脚本从没被批准过。修法是把那个声明允许构建脚本的小 pnpm-workspace.yaml 一起打进镜像。找了半天,改了一个文件。
  2. CSRF 拦住了 Gmail。 Gmail 服务器发来的一键退订 POST,显然不会带同源凭据。现在这条路由只信 token,而 token 伪造不出来。
  3. 免费路线的送达率有天花板。 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 ↗

下一篇构建日志,直达邮箱

每篇一封。附证据与错误。

self-hosted · 无第三方 · 一键退订

这是什么?

git undo 生成器:一句话说出你该敲哪条命令

喜欢这些文章?我的本职工作就是这样的工程。 订阅通讯