Skip to main content
Glama
robconery

big-mailer

by robconery

big-mailer 📬

一个 Cloudflare Worker 中的广播、滴灌序列和事务邮件。 一个自托管的付费 ESP 替代品,列表、发送和互动数据都归你所有。

CI License: MIT TypeScript Cloudflare Workers

状态: 功能完整且可在本地运行,尚未部署。它是为单一运营者构建的,不是多租户的,这是有意设计而非遗漏。 参见部署之前了解剩余事项的完整清单。

big-mailer 仪表盘,显示按范围拆分的同意情况

填充演示数据后的仪表盘。按范围查看同意才是关键面板:两个人退出了某个单独的系列,但仍然在列表中。在普通的 ESP 上,这两个数字是同一个数字。


核心理念 💡

每个 ESP 都把退订当作一个开关。有人完成了你的入门系列,点击"退订"来停止那个系列,然后悄无声息地永远离开了你的通讯。你永远不会发现。数字只是下降了。

在这里,同意是有范围的。 离开一个序列只会把你从那个序列中移除。离开通讯不会取消你明确选择加入的系列。只有明确的"退订所有内容"、硬退信或垃圾邮件投诉才会彻底移除某人。

这种不对称性是这个项目存在的原因,代码库中的其他一切都是围绕它安排的,以确保它不会被意外破坏。


Related MCP server: Resend MCP Server

运行它 🚀

bun install
bun run db:migrate     # applies migrations to the local D1 database
bun run dev            # http://localhost:8787

打开 http://localhost:8787 并点击填充演示数据:十二个人、两个活跃系列、一封已发送的广播。然后打开发件箱阅读"已发出"的邮件。

没有任何内容离开你的机器。EMAIL_PROVIDER=console 是本地默认值,它把完整渲染的邮件写入应用内的发件箱而不是发送。无需 API 密钥,也不会在你摆弄它时意外给真人发邮件。

试试它存在的意义 🎯

  1. 订阅者 → 选择某人 → 打开他们的偏好中心

  2. 在该 URL 后添加 ?scope=sequence:2。这就是序列邮件中链接的样子。

  3. 点击仅停止此系列

  4. 回到他们的订阅者页面:仍然是 active,仍然在通讯列表中,只退出了一个系列

  5. 同意面板显示所有退出单个系列的人,而(空的)列表显示完全离开的人

之后发送一封广播,他们仍然会收到。这就是全部论点。

序列延迟以天为单位,第一步默认为 0(加入时到达),后续步骤默认为 1。这意味着填充的系列不会在你观看时完成,所以仪表盘有快进时钟(仅本地):它将所有待处理步骤拉到当前时间并运行一次 tick。使用它,你会看到第 2 步跳过了那些退出该系列的人。


里面有什么 🗂

src/
  worker.tsx      fetch + scheduled + queue handlers — the whole entry point, 151 lines
  core/           domain logic: consent, sending, sequences, segments, rendering
  db/             Drizzle schema (24 tables) and the D1 client
  web/            server-rendered admin console (Hono + JSX, no frontend framework)
  api/            transactional send API, signup forms, media upload, bearer-key auth
  mcp/            MCP server — 93 tools, 4 resources, 4 prompts
  providers/      EmailProvider port + console and Resend adapters
  client/         the only browser JS in the project: the TipTap editor bundle
migrations/       drizzle-kit generated, applied by wrangler
docs/             problem brief, architecture, spec, and a decision log

大约 16k 行 TypeScript。bun run typecheck 分别覆盖 Worker 和浏览器包,且完全干净。


架构概览 🧱

Cloudflare Workers · 通过 Drizzle 使用 D1 (SQLite) · 用于发送扇出的 Queues · 用于调度的 Cron Triggers · 用于媒体的 R2 · Hono + JSX 服务端渲染的管理后台 · 用于认证的 Cloudflare Access · 一个可插拔的 EmailProvider 端口,带有 console 和 Resend 适配器。

值得了解的决策及其原因:

每条 messages 行在发出任何一封邮件之前就被物化。 广播会预先解析其完整收件人列表,为每个预期发送写入一行,然后才扇出到队列。这使得广播在崩溃后可恢复、在队列重试间幂等、事后可审计。在发送时惰性解析收件人更便宜,但会把任何广播中途的失败变成不可恢复的混乱。

同意在调用提供商之前立即重新检查,而不是在入队时。 队列可能在消息创建后几分钟才投递,而在此期间可能有人选择退出。在入队时检查仍然会把邮件发给他们。

队列并发被固定为 6。 一个批次就是一个提供商请求,所以批次并发就是请求速率。如果不设置,Cloudflare Queues 会自动扩展到 250 个并发消费者,把 Resend 的 10 req/s 限制淹没在 429 错误中,烧掉全部三次重试,然后把完全正常的邮件丢进死信队列。六个 100 封的批次在保持在限制内的同时,留出约 600 封/秒的余量。

抑制按电子邮件地址而不是按订阅者键控。 事务收件人和退信地址通常根本没有订阅者行,所以 subscribers.status 标志会静默地漏掉它们。

任何可观察的东西都是 D1 中的一行,而不是日志行。 Workers 日志在 3–7 天后过期。一周保留期的审计追踪不算是审计追踪。mcp_calls 记录每个代理操作,包括拒绝;sync_runs 记录每次 Stripe 拉取。

管理控制台没有密码。 Cloudflare Access 在边缘终止身份验证,src/web/auth.ts 正确验证转发的 JWT:使用团队的实时 JWKS 验证签名(按 isolate 缓存,在未知 key id 时强制重新获取),alg 固定为 RS256,加上 audience、issuer、exp 和 nbf。头部存在本身证明不了什么,也永远不会被视为证明。配置错误时中间件默认拒绝并锁定所有人,包括你自己。这是正确的失败方向。

邮件 HTML 渲染器是手写的(core/render-doc.ts),而不是使用 @tiptap/html,后者的服务端入口需要 happy-dom 且无法在 workerd 内运行。结果证明它无论如何都是更好的答案:遍历器内联每个样式(Gmail 会剥离 <style>)并为按钮生成嵌套表格(Outlook 忽略 <a> 上的 padding),这是通用 HTML 序列化做不到的。

完整的决策日志,包括被拒绝的替代方案及其原因,在 docs/MEMORY.md 中。


同意模型 🔐

三个独立的范围。窄选择永远不会升级为宽选择。

范围

存储方式

效果

序列

sequence_optouts 行

退出那一个系列。其他一切继续。

广播

subscribers.status

退出通讯。系列继续运行。

全局

suppressions 行

退出所有内容。法律逃生舱口。

只有明确的"退订所有内容"、硬退信或投诉才会写入全局抑制。

序列发送故意忽略 status = 'unsubscribed',因为该标志是广播范围的:离开通讯的人仍然会收到他们要求的入门系列。事务邮件(收据、下载)完全忽略营销同意,只被死地址或垃圾邮件投诉阻止。收据不是营销,退订的客户仍然需要他们的下载。


编辑器 ✍️

块式富文本,TipTap v3,原生(无 React)。打开填充的草稿**"草稿:编辑器能做的一切"**来查看全部功能。

  • 在行上按 / → 块菜单:标题、列表、复选框、引用、代码、表格、折叠块、分隔线、图片、YouTube、CTA 按钮

  • @ → 个性化字段作为真实节点,所以 first_name 不可能拼错

  • 拖动左边距中的手柄重新排序;shift 选择可跨多个块

  • 在任何位置拖放或粘贴图片 → 上传到 R2,URL 返回时插入

  • 代码块有语法高亮(15 种语言,包括 Ruby、Elixir、TS、SQL)

  • 选择文本显示气泡菜单;选择按钮后气泡菜单变为其 URL 和颜色选择器

按钮和合并标签是为邮件专门构建的自定义节点。 CTA 渲染为嵌套表格,每个样式都被内联。合并标签是节点而不是原始的 {{first_name}} 文本,因为原始文本中的拼写错误会把"Hi {{frist_name}}"发给整个列表。

正文以 TipTap JSON 存储在 body_json 中。body_md 中的旧版 markdown 仍然可以渲染,并且在你于编辑器中打开它的那一刻就会被转换。没有批量迁移,因为出错的批量迁移会把存档一起毁掉。

客户端包约 226KB gzipped,只在两个编辑邮件的屏幕上加载。管理控制台中的其他一切都是服务端渲染的,零 JavaScript。

有一个浏览器冒烟测试(bun run smoke,33 项检查)驱动真实的 Chromium,因为重命名的扩展选项在浏览器中会静默失败,而正文字段只是永远不会保存。服务端没有任何东西能捕获这一点。


从 Claude Code 驱动它 🤖

Worker 在 POST /mcp/<secret> 提供 MCP 服务器:93 个工具覆盖整个邮件系统,代理可以切分受众、起草和发送广播、构建序列、读取活动表现以及核对 Stripe。

# 1. a path secret (this is what makes the endpoint exist at all)
openssl rand -hex 24                      # → put in .dev.vars as MCP_PATH_SECRET

# 2. an admin-scoped key — POST /seed prints one, or use apikey_create

# 3. point Claude Code at it
claude mcp add --transport http --scope local \
  --header "Authorization: Bearer $BIG_MAILER_KEY" \
  big-mailer "http://localhost:8787/mcp/$MCP_PATH_SECRET"

三道门,从最便宜的开始。 一个不可猜测的路径密钥以恒定时间比较(未命中返回 404 而不是 403,因为没人猜到的 URL 应该看起来像什么都没有),然后是一个 admin 范围的 bearer 密钥(事务性 send 密钥无法触及它),然后是每个工具的保护。每次调用都记录在 mcp_calls 中,包括拒绝。

不可逆的发送需要预检。 没有来自 broadcast_preflight 的令牌,broadcast_send 会拒绝:一次性使用、10 分钟过期、对内容或受众的任何编辑都会使其失效。sequence_activate 也是如此。此外,生产环境中 MCP_ALLOW_SEND 为 "false",所以 MCP 可以读取和起草一切,但在你主动翻转之前无法把邮件放到线上。翻转回来就是即时关闭开关。

代理在接触同意之前应阅读 bigmailer://conventions。范围化退订不是任何在正常 ESP 上训练的模型所期望的形状,搞错它正是这个项目要避免的失败。


Stripe → 活动归因 💳

设置 STRIPE_SECRET_KEY(受限,只读 charges/refunds/customers)。每天 UTC 09:17 的 cron 拉取新费用,并将每笔费用记入买家最后一次归因触点,或者当费用带有 metadata.campaign 时记入该值。以 Stripe charge id 为幂等键,所以重新运行和重叠回填都是无害的。

stripe_sync_preview 可以试运行,sales_unattributed 是启发式无法归因的工作清单,sync_runs_list 证明夜间任务确实在运行。


命令 ▶️

bun run dev

构建客户端包,然后在 :8787 上提供服务

bun run watch:client

在变更时重建编辑器包(与 dev 一起运行)

bun run smoke

编辑器的浏览器冒烟测试。需要 dev 正在运行

bun run db:migrate

将迁移应用到本地 D1

bun run db:generate

在编辑 src/db/schema.ts 后生成迁移

bun run db:studio

针对本地数据库运行 Drizzle Studio

bun run typecheck

分别对 Worker 和浏览器包进行类型检查

bun run deploy

构建,然后 wrangler deploy --env production。先阅读下面的章节


真实发送 📮

将 .dev.vars.example 复制为 .dev.vars,添加一个 Resend 密钥,并设置 EMAIL_PROVIDER=resend。将 Resend 的 webhook 指向 /webhooks/resend,这样退信和投诉 就能被正确抑制。如果没有这个 webhook,无效地址永远不会被抑制,你的发送信誉会悄悄下降, 而这正是让整个域名慢慢失去邮件发送能力的缓慢方式。

密钥应放在 .dev.vars(已被 gitignore)中,或使用 wrangler secret put 设置。 绝不能放在 wrangler.jsonc 中,也绝不能放在 .dev.vars.example 中。

⚠️ 部署前

还没有部署,并且从这里到真正上线之间还需要进行一些实际设置:

  • DEV_AUTH_BYPASS=true 位于顶层 wrangler.jsonc 的 vars 中,以便应用可以在本地 运行。--env production 会将其设为 false。如果只运行 wrangler deploy 就会发布一个无认证的管理控制台,这正是 bun run deploy 把 --env production 写死 的原因。不要绕过这一点。

  • database_id 是占位符。 使用 wrangler d1 create 创建一个真正的 D1 数据库。

  • 创建 R2 存储桶(big-mailer-media)以及两个队列(big-mailer-send、 big-mailer-dlq)。队列需要 $5/mo 的 Workers 付费套餐。

  • 将 PUBLIC_URL 设置为真实主机。 它会在部署时被嵌入跟踪链接和图片 URL 中, 因此错误的值会把永久损坏的邮件发送给已经投递的地址。之后就无法修复了。

  • MCP_PATH_SECRET 必须是真正的密钥(通过 wrangler secret put 设置), 而不能是变量。如果未设置,MCP 端点会返回 404,这就是最安全的默认行为。 请明确地启用它,而不要无意中让它保持开放。

  • Cloudflare Access 需要一个 Allow 应用和几个 Bypass 应用。 如果仅用一个 Allow 策略保护整个主机名,那么跟踪图片、注册表单、偏好中心、webhook 和 MCP 也会被保护, 这意味着你发送的每封邮件中的每个跟踪像素都会永久重定向到登录屏幕,即使邮件已经送达 也无法避免。Access 会先匹配最具体的路径,因此 /t、/f、/p、/api、/webhooks 和 /mcp 每个都需要自己的 Bypass 应用。其中每个路径要么带有自身认证, 要么按设计就是公开的。

  • 没有服务端测试套件。 bun run smoke 覆盖了编辑器。 docs/SPEC.md 以带编号、可测试的需求形式编写, 并可以转换为可执行测试。


文档 📚

docs/PROJECT.md

问题是什么、适用人群是谁,以及哪些内容明确不属于本产品的范围

docs/ARCHITECTURE.md

系统设计、数据模型和发送管道

docs/SPEC.md

带编号的行为要求。预期行为的标准参考

docs/MEMORY.md

决策记录:选择了什么、放弃了什么,以及原因


贡献 🤝

非常欢迎 Bug 报告、正确性修复和邮件客户端渲染修复。多租户、拖拽式编辑器构建以及自建 SMTP 这些不属于本项目的目标范围。在提交 PR 之前请先阅读 CONTRIBUTING.md,如果你发现了漏洞, 请阅读 SECURITY.md(请私下报告,不要公开提 issue)。

我们也鼓励大家 fork。这个项目足够小,可以完整读完并自行修改。参与本项目则需遵守 行为准则。

许可 📄

MIT © Rob Conery

Related MCP Connectors

Related MCP Servers

  • A
    license
    C
    quality
    D
    maintenance
    MCP server that exposes the complete Libredesk REST API (54 endpoints) as tools, enabling natural language management of conversations, contacts, agents, teams, and more for the open-source customer support desk.
    54
    8 npm
    4
    MIT
  • A
    license
    A
    quality
    F
    maintenance
    An MCP server for the Resend email API, enabling AI assistants to send emails, manage contacts, audiences, and domains through natural language.
    18
    21 npm
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Remote MCP server for the Transmit email platform, enabling email sending, contact management, template and campaign operations via natural language.
    -
  • A
    license
    C
    quality
    D
    maintenance
    Comprehensive MCP server for Mailchimp Marketing API v3.0 with over 104 tools and 15+ React UI apps, enabling management of campaigns, audiences, ecommerce, automations, reports, and more via natural language.
    100
    1
    MIT