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).../staff-newsletter/— the staff-facing, per-org configurable mail built on this stack. Everything here is member-facing except the retention digest, which that feature replaces for orgs on its flag.../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 <[email protected]>'. -
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@ScheduledJob(…, '0 8 * * *')(class reminders),@ScheduledJob(…, '0 9 * * *')(expiration warnings), more incancellation-notifications.service.BillingDebtNotificationsServiceruns the card-expiry reminders (0 8 * * *), sends the day-0 debt notice onSUBSCRIPTION_DEBT_OPENED/billing.card_unusable, and the last-day and cancelled notices for the hourlySubscriptionLifecycleService.tick; its mails (cardExpiringEmail,cardUnusableEmail,debtOpenedEmail,debtLastDayEmail,debtCancelledEmailinpayment-emails) are gated by the member’sbillingpreference — see payments › Member notices. -
HTML templates —
apps/api/src/notifications/templates/, grouped by domain:booking-emails,payment-emails,membership-emails,retention-member-emails,retention-staff-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> <[email protected]>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.
Cancellation & retention emails
CancellationNotificationsService (cancellation-notifications.service.ts) also carries the retention case’s mail — a member gave notice, a case is going stale, a presale spot opened back up, an opening-day charge failed. See ../subscriptions-plans/behavior.md for what opens and closes a case; this is only who hears about it and when.
Staff mail is now split by urgency, not blanket-broadcast:
- Batched staff mail — cancellation notices, cases past SLA, and cases ending within 7 days with no contact — comes from
../staff-newsletter/, where each org sets its own cadence. Its default is those sections, daily, to every active owner and admin. - Still-immediate staff mail (
presaleWithdrawnForStaff,presaleChargeFailedForStaff) goes to the case owner alone (resolveCaseOwnerRecipient— the resolvedretentionOwnerId), falling back to the org’s active owner when no case owner is resolved yet — never the whole staff list.
Every staff-facing template links back to the member’s dashboard page rather than describing what to do next — the case itself lives there, not in the inbox.
| Sender method | Template | Audience | Fires | Conditions |
|---|---|---|---|---|
presaleWithdrawnForStaff | presaleWithdrawnForStaffEmail | the case owner (falls back to the org’s active owner) | A presale (scheduled) member backed out before opening day — the seat reopened. | Always-on under case eligibility and idempotency rules. |
presaleWithdrawnForMember | presaleWithdrawnForMemberEmail | the member | Confirms their presale spot was released and nothing was charged. | Unflagged — always sent, independent of the case it may also open. |
presaleChargeFailedForStaff | presaleChargeFailedForStaffEmail | the case owner (falls back to the org’s active owner) | An opening-day charge failed, first attempt (RecurringChargeService.handlePresaleChargeFailure); urgent retry tone with next retry date. | Always-on alongside the idempotent payment_follow_up task. Member retry/final notifications remain unchanged. |
noticeWithdrawnByStaff | noticeWithdrawnByStaffEmail | the member | Staff recorded, on the member’s word, that they’re staying — names the channel (phone, in person, etc.). | Unflagged — the withdraw-notice endpoint itself is an additive endpoint. |
refundProcessing | refundProcessingEmail | the member | A manual refund task just opened (the provider needs a human in its portal) — distinct from refundCompleted, which fires once the money has actually moved. | Unflagged. |
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 for member-facing mail. Staff-facing mail
does have it — see
../staff-newsletter/, which adds configurable per-event cadences (immediate / daily / weekly). - 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)