邮件与通知

通过 Resend 发送事务邮件、按用户 locale 本地化的模板、邮件日志,以及由 notify() 驱动的站内通知铃铛。

ShipKit 有两种方式告诉用户“发生了什么”:通过 Resend 发送的事务邮件,以及出现在应用侧边栏铃铛下的站内通知。两者都是可选的基础设施:没有 API key,或者删掉了通知功能,所有调用都只是无害的空操作。

配置 Resend

邮件通过 Resend 的 REST API 发送,用的是普通的 fetch(不引入 SDK),代码在 src/core/email/send.ts。由三个值控制:

位置作用
.dev.vars 或 Worker secret 里的 RESEND_API_KEY开启发信。没有它时每次发送都记为 skipped。
src/config/app-config.ts 的 emailFrom发件地址。请用一个已验证的发信子域名,例如 noreply@send.example.com。
src/config/app-config.ts 的 supportEmail每封邮件的 Reply-To,必须能收到信。

用发信子域名,Resend 的 SPF/DKIM 记录就不会落在根域名上,也不会和你收信服务商的记录挤在一起。修改 emailFrom 前,先在 Resend 里验证这个子域名。

没有 key 时,登录验证码会打印在服务端日志里,本地仍然可以用邮箱登录(见登录)。但在生产环境,没有 key 就意味着谁都无法用邮箱登录,部署前记得加上。

会发哪些邮件

邮件触发时机代码
登录验证码有人在登录页请求验证码src/features/auth/server/auth.ts
欢迎邮件新账号创建src/features/auth/server/auth.ts
收据支付成功src/features/billing/server/events.ts
订阅已取消订阅被取消src/features/billing/server/events.ts
扣款失败连续失败中的第一次续费失败src/features/billing/server/events.ts
账号已关闭用户删除账号src/features/auth/server/fns.ts
清除提醒已关闭账号被彻底清除前三天src/features/auth/server/jobs.ts
反馈回复管理员回复了反馈src/features/feedback/
候补名单通过管理员放行了某个用户src/features/waitlist/

账单类邮件只在状态真正改变时发送一次,webhook 重复投递不会再发一封收据。

发送邮件

模板放在 src/core/email/templates/。每个模板都是一个普通函数:接收 locale 和数据,返回 { subject, html }。layout.ts 提供共用的 HTML 外壳(只用内联样式、一个按钮、不带图片),以及 button()、keyValueTable() 和 escapeHtml()。

import { emailLocale, sendEmailSafely } from '@/core/email/send'
import { welcomeEmail } from '@/core/email/templates/welcome'

const locale = emailLocale(user.locale)
await sendEmailSafely({
  to: user.email,
  template: 'welcome',   // 邮件日志里这一行的名字
  userId: user.id,       // 关联到收件人
  locale,
  ...welcomeEmail({ name: user.name, locale }),
})

有两个发送函数:

  • sendEmailSafely 会捕获并记录所有错误。用于更重要操作的附带效果,比如注册、webhook:邮件服务出问题不能让主操作失败。
  • sendEmail 在服务商报错时抛异常,返回 { sent: boolean }。当发邮件本身就是这个操作时用它,登录验证码就是这样。

一定要传 template 和 userId,这样日志里才能看出是哪封邮件、发给了谁。

模板本地化

邮件按收件人保存的 user.locale 本地化,而不是按触发它的那个请求——webhook 根本没有读者。emailLocale() 会把这个自由格式的列转换成合法的 locale,无法识别时回退到英文。在模板里,要给每条消息显式传入 locale:

subject: m.email_welcome_subject({ app }, { locale }),

邮件文案放在 messages/{en,zh}.json 的 email_* key 下,详见国际化。

新增模板

  1. 编写 src/core/email/templates/<name>.ts,使用 layout(),并为每种语言准备好 email_* 消息。
  2. 在 src/core/email/templates/samples.ts 的 emailTemplateIds 和 renderEmailPreview 里注册它,并提供示例数据。
  3. 在 src/routes/_app/admin/emails.tsx 的 templateLabel 里给它一个名称(不加的话 typecheck 会失败)。

templates.test.ts 里的单元测试会用每种语言渲染每个已注册的模板。

预览和测试

后台 → 设置 → 邮件(/admin/emails)可以用示例数据、任选语言渲染每个已注册的模板,还能给你自己的邮箱发一封真实的测试邮件。测试邮件走的是 Resend,所以也能顺便验证 API key 和发件域名是否可用。登录验证码邮件不在预览列表里。

邮件日志

每次发送尝试都会通过 src/core/email/events.ts 上报,由 email-log 功能写入 email_log 表:模板、收件人、标题、语言、状态(sent、skipped 或 failed),以及服务商返回的 id 或错误信息。邮件正文从不保存。

  • 后台 → 开发者 → 邮件日志(/admin/email-log)列出所有发送记录,后台的用户页面也会显示该用户的记录。
  • GET /api/v1/admin/emails 把同样的数据提供给管理员 API key。
  • 超过 90 天的记录每晚清理,保留天数可以在后台设置里修改。
  • 用户的记录在其账号被清除时删除,也会包含在该用户的数据导出里。

有人说“没收到验证码”时,就去找那条带有服务商错误信息的 failed 记录。删掉 email-log 功能后发信照常工作,只是不再留记录。

站内通知

铃铛在应用侧边栏顶部(手机上在顶部栏)。有未读时会显示一个圆点;打开后列出最新 50 条,并全部标为已读。积分和反馈相关的通知会打开账号弹窗里对应的区块,其余的打开一个小的详情视图。

生产方调用 src/core/notify/events.ts 里的 notify():

import { notify } from '@/core/notify/events'

await notify({
  userId: data.userId,
  type: 'credits.adjusted',   // '<feature>.<verb>'
  params: { amount: data.amount, reason: data.reason },
})

数据库里只存 type 和少量标量 params。文案在打开铃铛时才用查看者当前的语言渲染,所以用户之后切换语言,通知也会跟着变。要新增一种通知,在你的功能里调用 notify(),再到 src/features/notifications/components/notification-bell.tsx 里为它加一个渲染函数。没有渲染函数的类型会被跳过,不会报错。

几条规则:

  • 一定要在它所描述的写操作成功之后再调用 notify()。D1 没有事务,丢一条通知可以接受,但通知一件没发生的事不行。
  • 如果写操作做了去重(比如 webhook 重复投递),通知也要用同样的方式去重。
  • params 里不要放密钥,也不要放来自其他用户的自由文本。

没有 WebSocket。铃铛每 60 秒轮询一次,标签页重新获得焦点时也会刷新。用户账号删除时,其通知一并删除。

桌面通知

src/lib/browser-notify.ts 把浏览器的 Notification 权限和一个按浏览器保存的开关封装在一起,开关显示在设置 → 偏好设置里。模板本身不会发送任何桌面通知,这个工具是留给那些需要让用户等待的功能用的。

移除

email-log 和 notifications 都是可删除的模块,见删除功能。核心的发送函数(sendEmail、notify)会保留,并且照常编译。