邮件与通知
通过 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 下,详见国际化。
新增模板
- 编写
src/core/email/templates/<name>.ts,使用layout(),并为每种语言准备好email_*消息。 - 在
src/core/email/templates/samples.ts的emailTemplateIds和renderEmailPreview里注册它,并提供示例数据。 - 在
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)会保留,并且照常编译。