---
url: /docs/agentbuff-stack/marketing-subscriptions.md
---
# 邮件订阅与候补名单

## 当前交付范围

I11 已提供匿名表单、独立用途同意、确认邮件、主动确认 / 退订，并在第二阶段接入 Resend 联系人 / 主题同步、签名退订回调和故障恢复。产品页面默认英文，可随 `i18n` 开启西班牙语；维护教程使用中文。名单入口和联系人同步默认关闭；本任务主预览数据库仍停留在 0012，后续可选迁移只在独立测试数据库运行，不会通过刷新页面自动升级。

**仍待推进：** 工具 / 收入转化事件、真实 Resend 环境与邮箱投递验收。没有批量营销发送器。当前本地证明只涵盖本站状态、受控供应商契约、实际 Worker 和数据库；不代表真实账户配置、真实退订页或邮箱已验收。

## 配置入口

唯一公开配置在 `packages/core/website.ts`：

```ts
marketing: {
  newsletter: false,
  waitlist: false,
  contactsSync: false,
  consentVersion: "2026-10-10",
  confirmationHours: 24,
  resendMinutes: 15,
},
```

| 字段 | 范围与含义 |
| --- | --- |
| `newsletter` | 开启邮件订阅申请，生成 `/newsletter` 并展示页脚入口 |
| `waitlist` | 开启候补名单申请，生成 `/waitlist` 并展示页脚入口 |
| `contactsSync` | 独立开启联系人 / 主题同步和签名回调；关闭申请页面不会自动关闭此项 |
| `consentVersion` | 1—80 字符。修改同意文案时同步更新此版本 |
| `confirmationHours` | 1—24 小时；每一代确认链接的有效期 |
| `resendMinutes` | 1—60 分钟；同一邮箱、同一用途再次申请的最短间隔 |

同意文案统一定义在 `packages/core/marketing.ts`，表单展示和服务端记录使用同一份内容。确认邮件复用现有 `APP_ORIGIN`、`BETTER_AUTH_SECRET`、`RESEND_API_KEY`、`RESEND_EMAIL_FROM`；联系人配置见下文。启用 Turnstile 时，还需已有的公开站点 key 和服务端 secret，新增申请使用 `marketing_signup` 动作。

当前版本启用名单前，审查并应用包含 0023 的完整增量迁移，再一起重建产品公共页与 API。0014 保存订阅与限流；0015 保存同一邮箱的同步状态和已验证回调的去重记录，0016 记录已成功同步的各用途同意代次；0023 保存同意语言；即使只开启本站名单，也需应用包含这些结构的完整历史。生成器不会替你执行迁移。

## 访客流程

1. 访客输入邮箱，并主动勾选当前用途。复选框没有预先勾选，不要求登录，也不会创建账号。
2. 页面提交成功只显示申请已收到。服务端先记为待确认，再尝试发送确认邮件；重复地址和发信失败使用同样的公开响应，不暴露此地址是否已订阅。
3. 邮件中的链接打开英文 `/email-preferences` 或西班牙语 `/es/email-preferences`。只打开页面、邮件预览或读取状态都不会修改订阅。
4. 访客点击确认按钮，才把待确认记录改为已确认。重复确认保留第一次确认时间。
5. 退订链接先展示该用途的当前状态，再由访客点击退订按钮。待确认申请也可以撤回。重复退订保留第一次退订时间。

同一邮箱可以分别申请邮件订阅和候补名单；确认或退订其中一个用途，不改变另一个用途。账号验证、团队邀请、密码恢复和安全邮件不依赖营销状态。

## 同意与确认邮件语言

开启 `i18n.locales: ["en", "es"]` 后，已启用的名单生成 `/es/newsletter` 和 `/es/waitlist`，页脚指向对应语言。表单展示该语言的同意文案，并在显式提交时传递 `locale`；服务端保存文案原文、版本、语言与同意代次，不从浏览器或账号猜测。修改原文或译文时同步更新 `consentVersion`。未传语言的旧客户端新申请默认为英文；未知语言拒绝，关闭的语言不接受新申请。

每封新确认邮件的主题、HTML / 纯文本、按钮、到期说明和确认 / 退订链接使用记录语言。页面切换与 Worker 重启不会改写该代请求；发送失败后的同键重试保持原语言。匿名重复提交在冷却期内不会修改待确认代次，也不能修改已确认的订阅；需要更换语言时先退订，再按冷却期限用新语言主动申请和确认，不提供无需验证的邮箱偏好修改入口。名单之间的语言分别保存。

0023 仅增加可为空的语言及约束，旧记录保持空值。空值投递继续使用原英文模板、链接与幂等 key，不能自动改成新版英文模板，避免已尝试请求的载荷改变。关闭西班牙语后不再领取对应语言的确认邮件，也不把它改发英文；未过期且未超过尝试上限的记录在重新开启后可继续领取。关闭语言后的旧西班牙语页面不再发布，仍可将原 `#mkt_...` 片段用于根路径 `/email-preferences` 退订。语言不参与签名校验或名单权限，不会阻止历史退订。领取后已经发出的邮件仍可能到达。

确认与退订页保持 `noindex`、不进入 sitemap、不添加公开语言替代链接，不加载营销统计；访问令牌留在 URL 片段，只有显式按钮提交才修改状态，成功后清除片段。切换路径本身不能确认、退订或改变订阅语言。隐私政策仍为英文，西班牙语入口明确标注。译文来自现有英文流程的代码字典翻译，人工语言审校与真实邮箱显示仍待验收。

## 数据与状态

`marketing_subscription` 按邮箱和用途唯一，邮箱去掉首尾空格并转为小写。它记录当前同意版本、文案、时间、入口，以及确认 / 退订时间和发信尝试。

| 状态           | 可证明的事实                                   |
| -------------- | ---------------------------------------------- |
| `pending`      | 当前用途有一条申请，尚未完成确认               |
| `confirmed`    | 有效确认链接经明确操作，本站已记录确认         |
| `unsubscribed` | 该用途已在本站撤回或退订；旧确认链接不能恢复它 |

再次申请已确认的地址不会降回待确认，也不会重复发送确认邮件。待确认 / 已退订地址过了冷却时间后，可以通过新的明确同意创建新一代申请；旧确认链接失效，仍需重新确认。该表保存当前申请状态和时间，不是完整的历次同意事件档案。

新一代申请复用该名单的退订能力，先前尚未过期的退订链接仍能撤回该名单，避免重发邮件使旧退订入口失效。确认链接按配置过期，退订链接有效期为签发时起一年。后续营销邮件必须提供当时有效的退订入口；当前没有批量营销发送器，真实提供商侧退订仍需外部验收。

## 链接与页面边界

链接是绑定站点、用途、操作、记录和有效期的签名能力，不含邮箱。确认和退订不能互换。确认 / 退订操作使用同源 POST；前台不自动重试修改请求。有效链接可以由持有人使用，应当像其他邮箱确认链接一样保密。

签名放在 URL 的片段中，普通网页请求和来源头不携带它；页面不加载统计 beacon，并声明不发送来源。偏好页面禁止搜索索引。链接只在当前页面内存中使用，不写入持久化浏览器缓存；修改成功后移除当前地址栏中的片段，这不保证删除浏览器之前的历史。

同一标签页打开另一封邮件的链接，会重置页面状态并读取新链接。键盘跳过导航不会覆盖邮件片段。无 JavaScript 时页面不执行确认或退订。

## 重复与失败处理

申请在同一邮箱 / 用途的事务锁内串行，并对已有记录加行锁。确认、退订和再次申请竞争时，写入使用最新的实际状态，已确认的记录不能被较早读到的申请覆盖。

确认邮件通过数据库原子领取发送权，发送时不持有数据库锁；回写只匹配原申请代次和发送租约。租约为 45 秒，重试间隔 15 分钟，同一代最多三次，过期、退订或关闭该入口后不再领取。

| 发信状态 | 实际含义 |
| --- | --- |
| `pending` | 尚未获得一次成功发送响应 |
| `accepted` | 本地捕获或发送接口已经接受请求；不代表邮箱实际收到，更不代表订阅已确认 |
| `failed` | 本次响应未能确认成功，保留通用错误码和下一次尝试时间 |

同一代重试沿用同一个供应商幂等 key。核对日期为 2026-10-10：[Resend 的幂等窗口为 24 小时](https://resend.com/docs/dashboard/emails/idempotency-keys)，因此确认有效期最多配置为 24 小时。网络结果不确定、租约超时和本地捕获重启仍需要排查，不能承诺所有环境下绝对只收到一封邮件。

确认重试使用已有 API 定时入口。先完成文件清理与任务恢复，再处理联系人同步，最后处理确认邮件与限流清理；可选营销故障分别隔离。关闭名单停止其新申请、确认和发信，历史退订仍可使用。关闭两个名单但保留 `contactsSync`，仍会处理历史退订；三项都关闭时定时处理不读取营销表。已记录供应商全局退订的地址不再领取确认发信，也不能通过新的本站确认解除阻止。

## 联系人同步与供应商退订

接口核对日期：2026-10-10。采用当前 [Contacts](https://resend.com/docs/api-reference/contacts/create-contact) 与 [Topics](https://resend.com/docs/api-reference/contacts/update-contact-topics)，不使用旧 Audience。名单用途分别映射到两个独立主题；同步前实际读取主题配置，要求二者的 `default_subscription` 都为 `opt_out`，防止默认授权另一用途。主题 ID 相同或配置不完整会被离线检查拒绝，远端配置不符则保留 `TOPIC_CONFIGURATION` 并停止本次写入。

| 服务端变量 | 规则 |
| --- | --- |
| `RESEND_API_KEY` | 复用邮件密钥；联系人操作需要 [Full access 权限](https://resend.com/changelog/new-api-key-permissions)，仅 Sending access 不足 |
| `RESEND_MARKETING_WEBHOOK_SECRET` | 本站营销回调独立的 `whsec_` 签名 secret，不能使用 Stripe secret |
| `RESEND_NEWSLETTER_TOPIC_ID` | 邮件订阅主题 UUID；每站、每环境独立 |
| `RESEND_WAITLIST_TOPIC_ID` | 候补主题 UUID；与订阅主题不同 |

后三项一起设置或一起留空；`contactsSync: true` 时全部必需。先在 Resend 创建两个默认退订的主题，为该环境配置 `POST /api/marketing-webhook`，选择 `contact.updated`、`contact.deleted`、`contact.topics.updated`。本轮没有替你创建这些云资源。复制生成站不会复制本地 secret；两个站不能共用主题与回调配置。

当前实现按 Resend 工作区的邮箱联系人操作；两个站共享同一工作区时，独立主题不隔离联系人级别的全局退订。各站的数据库、用途与确认链接仍独立。

确认与退订在同一数据库事务里更新订阅和待同步版本；两个名单按同一邮箱串行，供应商 HTTP 在事务外运行。只申请未确认的邮箱不会被新建到供应商。新联系人先以两个主题均退订创建，再应用已确认用途；创建响应丢失时先查询现有联系人，避免直接重新创建。

每个邮箱用一个同步记录合并最新状态，租约 60 秒；每次定时处理最多三个邮箱。Contacts 请求有 5 秒中止信号，同一客户端请求启动间隔至少 650 毫秒；这不是跨所有 Worker 的全局限流，遇到供应商限流仍需重试。失败记录只保存通用码，以 2、4、8 分钟逐步延后，最多间隔一小时。它不改变账号、任务、积分或本站确认结果。成功后记录各用途的确认代次，每天复核时以代次判断是否存在已确认的供应商退订；不依靠两个本地时间的大小区分新同意。0016 只为已确认同步版本回填代次，未完成同步的版本不假报完成。升级前已有的订阅按每轮最多 20 个缺失邮箱补充同步记录。

回写匹配原租约与状态版本，租约过期或较新的退订不会被标成同步完成，旧响应结束后留下待恢复标记。外部 API 与数据库没有共同事务：退订期间已经发出的请求仍可能暂时写入旧状态，后续同步与回调负责修正，不能保证两边零延迟一致。联系人删除 / 更换 ID 或全局退订会记录持续阻止状态；本站和供应商的主动加入通知都不能自动解除它，本阶段没有解除阻止的管理入口。

回调使用 Resend SDK 在原始请求体上[验签](https://resend.com/docs/webhooks/verify-webhooks-requests)，验证时间窗后再访问营销表；最大请求体 64 KiB。只保存事件 ID、负载摘要、类型、时间和是否应用，不保存原始签名、邮箱或完整负载。相同 ID 去重；同 ID 不同负载报冲突。无关邮箱和其他事件不会创建账号或订阅。

主题回调可能是本站创建时的退订回声。接收 [主题更新事件](https://resend.com/docs/webhooks/contacts/topics-updated) 时读取供应商当前主题状态，再结合最新本站确认时间判断；未完成首次同步会返回可重投的 503，而不是提前消费事件。旧通知不能覆盖较新的明确确认。真实主题退订只影响对应名单；供应商主动加入通知不作为本站同意，不能激活未确认名单。漏掉的主题退订会在每日复核中处理；全局退订保持阻止。事务邮件不依据营销状态停用。

## 匿名请求保护

申请和偏好修改分别使用一分钟窗口，每个可信来源地址最多 20 次。限流表只保存带密钥摘要，不存明文 IP 或邮箱；超过一天未更新的桶由定时维护清理。来源取 Cloudflare 提供的 `CF-Connecting-IP`；缺失时合并到共享桶。本地代理可以模拟该头，不能据此宣称实际公网抗滥用效果已验收。

请求体限制为 8 KiB，只接受约定的 JSON 字段。公开申请不能填写订阅状态、来源、账号或回调地址。开启 Turnstile 后，申请还需完成对应动作与站点的服务端验证；本轮没有使用真实 CAPTCHA 供应商。

## 本地验收

```bash
ENVIRONMENT=development bun --env-file .env --env-file .env.local \
  apps/api/local/validate-marketing.ts
```

命令只接受回环 PostgreSQL，创建独立数据库并应用完整迁移；使用实际生产 Worker 入口和本地邮件绑定，拒绝外部请求。它验证八并发申请只捕获一封确认、确认与申请竞争、用途隔离、退订后旧确认拒绝、失败发信与实际定时恢复、没有创建账号。结束后删除本次数据库与资源，不改主数据库。

增加 `--contacts` 会开启本次隔离配置与内存 Contacts 绑定，验证实际 Worker 的八并发定时领取、两主题写入、失败写入与后续恢复、SDK 原始验签、回调重放和全局阻止。`development` 的联系人服务必须提供 `LOCAL_MARKETING` 测试绑定；普通预览没有该绑定时明确失败，不会静默转成真实 Resend 请求。正式 API 请求格式另外与已安装 SDK 的受控 HTTP 请求做一致性检查。

增加 `--ui` 会构建独立公共页面，并短暂提供 `http://marketing.localhost:21311/newsletter`。使用 `browser@acceptance.example.test` 完成申请、确认和退订，再创建 `.local/marketing-ui-stop` 让验证器核对数据库并清理。捕获邮件存放在验证器显示的独立目录，不要上传或公开完整邮件链接。

实际浏览器已验证桌面、320 像素手机、黑白主题、同意门禁、确认与退订按钮、同标签页切换链接和键盘跳过导航。服务与页面测试还覆盖无同意、错误地址、未知字段、篡改 / 过期链接、代次失效、限流和失败请求不自动重试。

## 接下来继续的能力

下一阶段补真实转化事件；真实 Resend 环境、实际邮箱投递和供应商退订页面另行验收。当前受控本地接口不替代这些证据。

工具开始、成功、下载发起与购买确认将使用同一事件契约，并与收入状态去重。页面访问不算使用，下载发起不证明保存到本机，展示价格不算购买确认。完成这些能力及真实外部环境验收之前，I11 保持进行中。

### 两种语言与浏览器验收

增加 `--languages --ui`，命令使用同一独立环境开启西班牙语，实际验证同意原文、Worker 重启后的同键同载荷重试，并在 `http://marketing.localhost:21311/es/newsletter` 提供临时页面。使用合成邮箱 `browser@acceptance.example.test` 主动申请，再从该环境的 `/__local/mail/browser` 本地捕获邮件检查确认和退订。打开链接只读取，点击按钮才修改；完整流程结束后创建 `.local/marketing-ui-stop`，命令核对已退订、保留西班牙语同意且没有创建账号，然后清理本次环境。该固定邮件路径只存在于本地验收程序，不发布到产品 Worker。验收 HTML 留在忽略目录，不代表真实 Resend 或真实邮箱客户端已通过。
