Email and notifications

Transactional email through Resend, templates localized by each user's locale, the email log, and the in-app notification bell fed by notify().

ShipKit has two ways to tell a user something happened: a transactional email, sent through Resend, and an in-app notification that shows up under the bell in the app sidebar. Both are optional infrastructure — with no API key, or with the notifications feature deleted, every call is a harmless no-op.

Setting up Resend

Email goes through the Resend REST API with a plain fetch (no SDK), in src/core/email/send.ts. Three values control it:

WhereWhat
RESEND_API_KEY in .dev.vars / a Worker secretTurns sending on. Without it every send is logged as skipped.
emailFrom in src/config/app-config.tsThe From address. Use a verified sending subdomain such as noreply@send.example.com.
supportEmail in src/config/app-config.tsSet as Reply-To on every email. It must reach a real inbox.

A sending subdomain keeps Resend's SPF/DKIM records off your root domain, so your inbound mail provider's records do not have to share it. Verify the subdomain in Resend before changing emailFrom.

Without a key, sign-in codes are printed to the server log so local sign-in still works (see Authentication). In production a missing key means nobody can sign in by email — add it before you deploy.

What gets sent

EmailSent whenCode
Sign-in codeSomeone asks for one on the login pagesrc/features/auth/server/auth.ts
WelcomeA new account is createdsrc/features/auth/server/auth.ts
ReceiptA payment succeedssrc/features/billing/server/events.ts
Subscription canceledA subscription is canceledsrc/features/billing/server/events.ts
Payment failedThe first failed renewal in a rowsrc/features/billing/server/events.ts
Account closedThe user deletes their accountsrc/features/auth/server/fns.ts
Erasure reminderThree days before a closed account is erasedsrc/features/auth/server/jobs.ts
Feedback replyAn admin replies to feedbacksrc/features/feedback/
Waitlist approvedAn admin lets someone insrc/features/waitlist/

The billing emails fire once per real state change, so a webhook redelivery does not send a second receipt.

Sending an email

Templates live in src/core/email/templates/. Each is a plain function that takes a locale and data and returns { subject, html }. layout.ts provides the shared HTML shell (inline styles only, one button, no images) plus button(), keyValueTable() and 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',   // names the row in the email log
  userId: user.id,       // links it to the recipient
  locale,
  ...welcomeEmail({ name: user.name, locale }),
})

Two send functions:

  • sendEmailSafely catches and logs any failure. Use it for side effects of something more important — a sign-up, a webhook — where a mail outage must not fail the main action.
  • sendEmail throws on a provider error and returns { sent: boolean }. Use it when the email is the action; the sign-in code uses it.

Always pass template and userId, so the delivery is attributable in the log.

Localizing a template

Emails are localized by the recipient's stored user.locale, never by the request that happened to trigger them — a webhook has no reader at all. emailLocale() turns the free-form column into a valid locale, falling back to English. Inside a template, pass the locale to each message explicitly:

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

Email copy lives in messages/{en,zh}.json under email_* keys. See Internationalization.

Adding a template

  1. Write src/core/email/templates/<name>.ts using layout() and email_* messages in every language.
  2. Add it to emailTemplateIds and renderEmailPreview in src/core/email/templates/samples.ts with sample data.
  3. Give it a label in templateLabel in src/routes/_app/admin/emails.tsx (typecheck fails until you do).

The unit test in templates.test.ts renders every registered template in every locale.

Previewing and testing

Admin → Settings → Emails (/admin/emails) renders each registered template with sample data in any language, and can send a real test to your own address. That test goes through Resend, so it also proves the API key and sender domain work. The sign-in code email is not in the preview list.

The email log

Every send attempt is reported through src/core/email/events.ts, and the email-log feature stores it in the email_log table: template, recipient, subject, locale, status (sent, skipped or failed) and the provider id or error. Bodies are never stored.

  • Admin → Developers → Email log (/admin/email-log) lists the attempts, and a user's page in the console shows theirs.
  • GET /api/v1/admin/emails serves the same rows to an admin API key.
  • Rows older than 90 days are pruned nightly. The window is an operator setting in the console.
  • A user's rows are deleted when their account is erased, and included in their data export.

When someone says "no code arrived", a failed row with the provider's error is where to look. Delete the email-log feature and sending keeps working; nothing is recorded.

In-app notifications

The bell sits at the top of the app sidebar (the mobile top bar on phones). A dot means something is unread; opening it lists the latest 50 and marks all of them read. Rows that belong to credits or feedback open the matching section of the account dialog; the rest open a small detail view.

Producers call notify() from src/core/notify/events.ts:

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

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

The row stores only the type and small scalar params. The copy is rendered when the bell opens, in the viewer's current language — so a notification follows the user if they switch languages later. To add a type, call notify() from your feature and add a renderer for it in src/features/notifications/components/notification-bell.tsx. A type with no renderer is skipped, never an error.

Rules that keep it honest:

  • Call notify() after the write it describes has succeeded. D1 has no transactions, so a lost notification is acceptable; a notification about something that did not happen is not.
  • If the write is deduplicated (a webhook redelivery), deduplicate the notification the same way.
  • Never put secrets or free text from other users in params.

There are no sockets. The bell polls every 60 seconds and refetches when the tab regains focus. Notifications are deleted with the user's account.

Desktop notifications

src/lib/browser-notify.ts wraps the browser's Notification permission together with a per-browser on/off switch shown in Settings → Preferences. Nothing in the template sends a desktop notification itself; the helper is there for features that make the user wait.

Removing them

Both email-log and notifications are deletable modules — see Deleting features. The core senders (sendEmail, notify) stay and keep compiling.