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:
| Where | What |
|---|---|
RESEND_API_KEY in .dev.vars / a Worker secret | Turns sending on. Without it every send is logged as skipped. |
emailFrom in src/config/app-config.ts | The From address. Use a verified sending subdomain such as noreply@send.example.com. |
supportEmail in src/config/app-config.ts | Set 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
| Sent when | Code | |
|---|---|---|
| Sign-in code | Someone asks for one on the login page | src/features/auth/server/auth.ts |
| Welcome | A new account is created | src/features/auth/server/auth.ts |
| Receipt | A payment succeeds | src/features/billing/server/events.ts |
| Subscription canceled | A subscription is canceled | src/features/billing/server/events.ts |
| Payment failed | The first failed renewal in a row | src/features/billing/server/events.ts |
| Account closed | The user deletes their account | src/features/auth/server/fns.ts |
| Erasure reminder | Three days before a closed account is erased | src/features/auth/server/jobs.ts |
| Feedback reply | An admin replies to feedback | src/features/feedback/ |
| Waitlist approved | An admin lets someone in | src/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:
sendEmailSafelycatches 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.sendEmailthrows 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
- Write
src/core/email/templates/<name>.tsusinglayout()andemail_*messages in every language. - Add it to
emailTemplateIdsandrenderEmailPreviewinsrc/core/email/templates/samples.tswith sample data. - Give it a label in
templateLabelinsrc/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/emailsserves 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.