Notifications (Email + Scheduled)
Status: Shipped — Resend-backed transactional email + nestjs/schedule cron jobs. Last reviewed: 2026-08-21
What
Scheduled outbound email notifications via Resend:
- Class reminders — daily 08:00 UTC cron picks tomorrow’s sessions and emails confirmed bookings.
- Subscription expiration warnings — daily 09:00 UTC cron warns members whose subscription expires in 6–7 days.
- Payment reminders — payment-related transactional emails.
- Cancellation notifications — booking / class cancellation emails (separate service).
Distinct from:
../push-notifications/— mobile push, separate stack (Expo + BullMQ).../announcements/— coach broadcasts.../messages-comments/— in-app chat.../event-tracking/— analytics, not user notifications.
Why
Email is the lowest-common-denominator nudge channel for not-yet-installed mobile members and a parallel backup to push for installed members. Class reminders are required for booking retention.
Personas
| Persona | Surface | Capabilities |
|---|---|---|
| System (cron) | n/a | Auto-sends reminders |
| Member | Inbox | Receives the email |
No user-facing notification center exists today — notifications are write-once email events, not stored in a table for in-app retrieval.
Capabilities
-
Resend client — global
RESEND_CLIENTtoken;from = RESEND_FROM_ADDRESSenv or'Taikan <noreply@usetaikan.com>'. -
Send wrapper —
EmailService.send({to, subject, html, ctx})swallows Resend errors and logs them. Passingctx(anEmailContext) is how a member-facing send picks up the org’s From display name and Reply-To; it also derives atext/plainalternative from the HTML so nothing goes out HTML-only. -
Org branding resolver —
EmailBrandingServiceturns an org id (or an already-loaded org row) into anEmailContext: name, a fetchable logo URL, contact email/phone/site, the recipient’s locale and the org’s timezone. Memoized per org for 60s so a roster-wide send is one lookup. -
Design system —
templates/theme.tscarries Taikan’s own product tokens (teal#0E8C8C, ocean ground#F6F8FA, ink#0D1B2A, deep band#07202B), lifted fromapps/web/src/app/globals.cssand converted from oklch to the hex email needs, so a member who taps through doesn’t land somewhere that looks like a different company.templates/base.tsrenders the letterhead — brand rule, org mark paired with a document-type tag, divider, body, the gym’s sign-off, then Taikan’s own credit band.templates/components.tsholds the blocks (heading, paragraph, detail card, amount block, callout, button, step list, schedule list). Templates compose blocks; none of them hand-write markup. -
Document-type tag — each template declares a
docType(booking/reminder/payment/receipt/membership/account) which renders as a tag in the letterhead. It mirrors theEmailCategoryunion inemail-types.tsso it can’t drift from how the registry classifies each mail, and it answers the one question a member scanning a crowded inbox has before the heading: is this about a class, or about money. -
Typography — Manrope and Heebo (the product’s own faces) are linked from Google Fonts, which upgrades Apple Mail, iOS Mail and Outlook for Mac. Gmail ignores the link and gets the fallback stack, so the stack after them is a real one. Clash Grotesk (the product’s display face) is not on Google Fonts, so display weight comes from Manrope 800 rather than a face that would silently fail to load.
-
Localization — every string lives under
emails.*in the shared dictionaries (en/he/ru). Subjects are localized too, so no caller hardcodes one. -
Test sink — in
NODE_ENV=test,EmailServiceretains sent emails in-memory for spec assertions. -
Cron jobs —
NotificationSchedulerServiceregisters@Cron('0 8 * * *')(class reminders),@Cron('0 9 * * *')(expiration warnings), more incancellation-notifications.service. -
HTML templates —
apps/api/src/notifications/templates/, grouped by domain:booking-emails,payment-emails,membership-emails, plus the standalonewelcome,export-ready,terminal-activated,entitlement-sweep,plan-change-scheduled,check-in-reminder,form-signing-link. Each exports(params) => { subject, html }. -
Preview harness —
pnpm email:previewrenders every template in every locale (plus the no-logo and no-org states) to.preview/, HTML and text side by side, from the shared fixtures intools/email-preview/samples.ts. Run it before shipping a template change; a unit test cannot tell you the layout holds at 600px in RTL.Two caveats the preview cannot cover, both learned the hard way: it renders a full document, so it will not show you a subject defect (a subject is a header, not body) or anything that depends on the
<html>/<body>wrapper Gmail discards. For those, dry-run the sender and read the RTL specs inlocalization.unit.spec.ts. -
Retry wrapper —
runWithRetryincommon/cron-retry.tsfor transient failures. -
Cron gate —
cronsEnabled()toggle env-driven; off in dev by default.
Sender identity + language
Member-facing mail always carries the gym’s identity, unconditionally:
- From / Reply-To. Mail goes out as
<Org Name> <noreply@usetaikan.com>withReply-To: <organizations.contact_email>, so a member who hits reply reaches their gym rather than an unmonitored mailbox. An org with nocontact_emailon file gets no Reply-To and the footer omits the “just reply to this email” line, so the mail never promises a channel that isn’t there. - Language. Copy renders in the recipient’s locale — their last device
locale, else the org’s
localecolumn, else English. The roster-wide senders (class-cancelled, entitlement sweeps) resolve once per org rather than per member, so a full roster is one lookup, not N.
Platform mail (welcome, data export) keeps the Taikan sender by design: the recipient there is our customer, not their member.
Operational note: contact_email is now a live reply destination, not just
a display field. A gym that put a personal or unmonitored address in it will
start receiving member replies there.
Capabilities (gaps)
- No in-app notification feed / notification center.
- No per-user email opt-out for transactional categories (only push prefs exist).
- No SMS / WhatsApp channel.
- No batching / digest support.
- Sending domain is still
usetaikan.comfor every org — only the display name is the gym’s. Per-org sending domains would need DKIM per customer.
Related
../scheduling-bookings/— class reminders read fromclass_sessions+bookings.../subscriptions-plans/— expiration source.../push-notifications/— parallel channel.../legal/— TOS update could trigger an email broadcast (not yet wired).
Source code
- API:
apps/api/src/notifications/email.service.ts— Resend wrapper, sender identity, text/plain partemail-branding.service.ts— org identity + recipient locale + logo URLnotification-scheduler.service.ts— cron jobscancellation-notifications.service.ts— cancellation + refund emailstemplates/—theme,base,components,email-context,html-to-text, and one module per domain (see Capabilities)
- Copy:
libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.jsonunderemails.* - Preview:
tools/email-preview/render.ts(pnpm email:preview) - DB: no notifications table; persistence lives in the source domain (bookings, subscriptions)