Skip to main content

注册表

所有 notification type 在 src/notifications/registry.ts 注册:handler 数组拍平成 type → handlerMapPOST /notify 通过 getHandler(type) 查找。
const handlers: NotificationHandler[] = [
  dailyDigest,
  ...accountDeletionHandlers,
];
type渠道门槛 / 偏好模板i18n 前缀
daily_digestemailplan === "free"is_open_notificationdaily-digest/email.htmldaily.*
account_deletion.scheduledemailaccount-deletion/email.htmldeletion_email.scheduled.*
account_deletion.cancelledemailaccount-deletion/email.htmldeletion_email.cancelled.*
account_deletion.reminderemailaccount-deletion/email.htmldeletion_email.reminder.*
account_deletion.deletedemail无(用户已删,需 contact 兜底)account-deletion/email.htmldeletion_email.deleted.*

daily_digest

每日免费额度提醒邮件,src/notifications/daily-digest.ts

门槛

resolveUser(userId) 后依次判定:
  • 查不到用户 → NotificationSkipped("user_not_found")
  • plan !== "free"NotificationSkipped("plan_gate")
  • !isOpenNotificationNotificationSkipped("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.jsondaily):
{
  "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

stagecta_url
scheduledhttps://kira.art/setting/billing
cancelledhttps://kira.art
reminderhttps://kira.art/setting/billing
deletedhttps://kira.art
en-US 文案结构(src/messages/en-US.jsondeletion_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

1

放模板

src/templates/<type>/email.html 放 HTML 模板,正文用 {{placeholder}} 占位。
2

加文案

每个 src/messages/*.json(12 个 locale)加对应键。缺某 locale 时运行期会回退 en-US。
3

实现 handler

新建 src/notifications/<type>.ts,导出实现 NotificationHandler 的对象:type 字段 + handle(req) 方法。handle 内:resolveUser → 门槛判定(不满足抛 NotificationSkipped)→ t() + render()channel.send(payload, { template }) → 返回 DeliveryResult[]
4

注册

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 名。