注册表
所有 notification type 在 src/notifications/registry.ts 注册:handler 数组拍平成 type → handler 的 Map,POST /notify 通过 getHandler(type) 查找。
const handlers: NotificationHandler[] = [
dailyDigest,
...accountDeletionHandlers,
];
| type | 渠道 | 门槛 / 偏好 | 模板 | i18n 前缀 |
|---|
daily_digest | email | plan === "free" 且 is_open_notification | daily-digest/email.html | daily.* |
account_deletion.scheduled | email | 无 | account-deletion/email.html | deletion_email.scheduled.* |
account_deletion.cancelled | email | 无 | account-deletion/email.html | deletion_email.cancelled.* |
account_deletion.reminder | email | 无 | account-deletion/email.html | deletion_email.reminder.* |
account_deletion.deleted | email | 无(用户已删,需 contact 兜底) | account-deletion/email.html | deletion_email.deleted.* |
daily_digest
每日免费额度提醒邮件,src/notifications/daily-digest.ts。
resolveUser(userId) 后依次判定:
- 查不到用户 →
NotificationSkipped("user_not_found")
plan !== "free" → NotificationSkipped("plan_gate")
!isOpenNotification → NotificationSkipped("disabled_by_user")
即只对开启了通知的免费用户发送。
模板与渲染
模板 src/templates/daily-digest/email.html,用 render(template, vars) 替换 {{placeholder}}:
{{placeholder}} | 来源 |
|---|
{{email_title}} | t("daily.email_title", locale)(同时作为 subject) |
{{main_heading}} | t("daily.main_heading", locale) |
{{sub_heading}} | t("daily.sub_heading", locale) |
{{claim}} | t("daily.claim", locale)(CTA 按钮文案) |
{{unsubscribe}} | t("daily.unsubscribe", locale) |
{{unsubscribe_url}} | 硬编码 https://kira.art/setting/appearance |
模板内 CTA 主按钮 href 硬编码为 https://kira.art/setting/billing;退订链接 href 走 {{unsubscribe_url}}(= https://kira.art/setting/appearance)。
en-US 文案示例(src/messages/en-US.json → daily):
{
"email_title": "Your Daily Free Credits from Kira",
"main_heading": "Tap to claim your free credits for today!",
"sub_heading": "15 Daily Free Credits",
"claim": "Claim!",
"unsubscribe": "Unsubscribe"
}
account_deletion.*
账户删除生命周期邮件,src/notifications/account-deletion.ts。四个阶段共用同一个 handler 工厂(buildHandler(stage))与同一个模板,仅 i18n 键与 CTA URL 按阶段切换。
const STAGES = ["scheduled", "cancelled", "reminder", "deleted"] as const;
用户解析与兜底
let email = contact?.email;
let locale = contact?.locale ?? "en-US";
if (!email) {
const user = await resolveUser(userId);
if (!user) throw new NotificationSkipped("user_not_found");
email = user.email;
locale = user.locale;
}
- 传了
contact.email → 直接用,跳过 Supabase。
- 否则查 Supabase;查不到 →
user_not_found。
account_deletion.deleted 在用户记录已被删除后触发,此时 Supabase 查不到用户。调用方必须通过 contact: { email, locale } 提供联系方式兜底,否则会被 skip。
模板与渲染
模板 src/templates/account-deletion/email.html(四阶段共享),render 变量:
{{placeholder}} | 来源 |
|---|
{{email_title}} | t("deletion_email.<stage>.subject", locale)(同 subject) |
{{heading}} | t("deletion_email.<stage>.heading", locale) |
{{body}} | t("deletion_email.<stage>.body", locale) |
{{cta_text}} | t("deletion_email.<stage>.cta", locale) |
{{cta_url}} | CTA_URLS[stage](见下表) |
{{footer}} | t("deletion_email.footer", locale)(所有阶段共用) |
每阶段 CTA URL
| stage | cta_url |
|---|
scheduled | https://kira.art/setting/billing |
cancelled | https://kira.art |
reminder | https://kira.art/setting/billing |
deleted | https://kira.art |
en-US 文案结构(src/messages/en-US.json → deletion_email):
{
"footer": "Kira Art — We're always here if you need us.",
"scheduled": { "subject": "...", "heading": "...", "body": "...", "cta": "Manage Your Account" },
"cancelled": { "subject": "...", "heading": "...", "body": "...", "cta": "Back to Kira" },
"reminder": { "subject": "...", "heading": "...", "body": "...", "cta": "Keep My Account" },
"deleted": { "subject": "...", "heading": "...", "body": "...", "cta": "Visit Kira" }
}
body 文案可含 <br> 等内联 HTML——它原样注入模板的 <p>{{body}}</p>。
模板渲染机制
两个独立函数(src/i18n.ts):
t(key, locale, format?) —— 按点号路径取 locale JSON 里的嵌套字符串。可选 format 做 {var} 占位替换。locale 文件缺失 → 回退 en-US;键不存在 / 非字符串 → 返回原始 key(不抛错)。
render(template, vars) —— 把 HTML 模板里的 {{placeholder}} 替换为 vars 对应值。
典型 handler 用法是:先用 t() 取各文案片段,再把片段作为 vars 传给 render() 填进 HTML。
新增一个 Notification Type
放模板
在 src/templates/<type>/email.html 放 HTML 模板,正文用 {{placeholder}} 占位。
加文案
在每个 src/messages/*.json(12 个 locale)加对应键。缺某 locale 时运行期会回退 en-US。
实现 handler
新建 src/notifications/<type>.ts,导出实现 NotificationHandler 的对象:type 字段 + handle(req) 方法。handle 内:resolveUser → 门槛判定(不满足抛 NotificationSkipped)→ t() + render() → channel.send(payload, { template }) → 返回 DeliveryResult[]。
注册
在 src/notifications/registry.ts 把新 handler 加进 handlers 数组。
NotificationHandler 接口(src/notifications/types.ts):
export interface NotificationHandler<TData = Record<string, unknown>> {
type: string;
handle(req: NotificationRequest & { data?: TData }): Promise<DeliveryResult[]>;
}
channel.send 的第二个参数 { template } 是低基数的 metrics 维度(= notification type),用于 notify.email.duration 直方图按 type 切片延迟。新 handler 应把 template 设为自己的 type 名。