跳转到正文

Read this guide in English

邮件订阅与候补名单 ​

当前交付范围 ​

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独立开启联系人 / 主题同步和签名回调;关闭申请页面不会自动关闭此项
consentVersion1—80 字符。修改同意文案时同步更新此版本
confirmationHours1—24 小时;每一代确认链接的有效期
resendMinutes1—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 小时,因此确认有效期最多配置为 24 小时。网络结果不确定、租约超时和本地捕获重启仍需要排查,不能承诺所有环境下绝对只收到一封邮件。

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

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

接口核对日期:2026-10-10。采用当前 Contacts 与 Topics,不使用旧 Audience。名单用途分别映射到两个独立主题;同步前实际读取主题配置,要求二者的 default_subscription 都为 opt_out,防止默认授权另一用途。主题 ID 相同或配置不完整会被离线检查拒绝,远端配置不符则保留 TOPIC_CONFIGURATION 并停止本次写入。

服务端变量规则
RESEND_API_KEY复用邮件密钥;联系人操作需要 Full access 权限,仅 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 在原始请求体上验签,验证时间窗后再访问营销表;最大请求体 64 KiB。只保存事件 ID、负载摘要、类型、时间和是否应用,不保存原始签名、邮箱或完整负载。相同 ID 去重;同 ID 不同负载报冲突。无关邮箱和其他事件不会创建账号或订阅。

主题回调可能是本站创建时的退订回声。接收 主题更新事件 时读取供应商当前主题状态,再结合最新本站确认时间判断;未完成首次同步会返回可重投的 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 或真实邮箱客户端已通过。

供语言模型读取:llms.txt · llms-full.txt
采用 MIT 许可证。