Email Notifications#

Three Layers#

LayerEntryPurpose
mailer.sendMail@/server/shared/mailerSend arbitrary HTML email, no template
sendTemplate@/server/modules/notification/email-templates.serviceRender a DB template (email_templates) with variables, then send
notification.sendXxxEmail@/server/modules/notification/notification.serviceConvenient wrappers for common emails (welcome, captcha, lifetime, etc.)

All three eventually go through mailer.sendMail, which dispatches to AWS SES or Cloudflare Email Sending based on MAIL_PROVIDER.

1. Direct Send (no template)#

Simplest path — for ad-hoc notifications that don't need to be reused:

typescriptimport { mailer } from "@/server/shared/mailer"

await mailer.sendMail({
  to: "user@example.com",
  subject: "Your invoice is ready",
  html: "<h1>Hi</h1><p>Your invoice #1234 is attached.</p>",
})

Signature:

typescriptmailer.sendMail({ to: string, subject: string, html: string }):
  Promise<{ success: boolean; messageId?: string; error?: string }>

Use templates when content is reused, the owner edits copy in admin, or you need variable interpolation:

typescriptimport { sendTemplate } from "@/server/modules/notification/email-templates.service"

await sendTemplate("welcome", "user@example.com", {
  email: "user@example.com",
  app_name: "Your App",
})

Signature:

typescriptsendTemplate(
  templateId: string,
  to: string,
  variables: Record<string, string>,
): Promise<{ success: boolean; message: string }>

Templates use {{name}} placeholders (simple string replacement — no conditionals or loops). E.g. in template body:

html<p>Hi {{email}},</p>
<p>Welcome to {{app_name}}!</p>

3. Business Helpers#

The notification object wraps the project's common emails — call directly from business code:

typescriptimport { notification } from "@/server/modules/notification/notification.service"

await notification.sendWelcomeEmail(email)
await notification.sendCaptcha(email, code)
await notification.sendLifetimeEmail(email)
await notification.sendSubscriptionCreatedEmail(email)
await notification.sendSubscriptionRenewedEmail(email)
await notification.sendCreditEmail(email)
await notification.sendAccountDeletionEmail(email)

To add a new common email, append a method in notification.service.ts:

typescriptasync sendOrderShippedEmail(email: string, order_no: string) {
  await sendTemplate("order-shipped", email, { email, order_no })
},

Built-in Templates#

Template IDTrigger
welcomeUser registration succeeded
captchaVerification code sent
user-deleteAccount deletion confirmation
subscription-createdSubscription created
subscription-renewedSubscription renewed
creditCredit pack purchased
lifetimeLifetime deal purchased

Admin Panel#

/owner/email-templates lets you:

  • List all templates
  • Edit subject / body / variables
  • Create new templates
  • Send Test — sends the current draft to the logged-in account via mailer.sendMail

Unsubscribe#

  • Users visit /en/unsubscribe or /zh/unsubscribe to opt out of marketing emails (the old /unsubscribe redirects to the user's preferred language)
  • Preferences stored in user_email_preferences
  • Per category: marketing, product_updates, payment_receipts

Dev Mode#

When MAIL_SEND_ENABLED=false, no real email is sent — verification codes are printed to server logs. Set true in production.