Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesNotificationsNotifications (Email + Scheduled)

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:

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

PersonaSurfaceCapabilities
System (cron)n/aAuto-sends reminders
MemberInboxReceives 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_CLIENT token; from = RESEND_FROM_ADDRESS env or 'Taikan <[email protected]>'.

  • Send wrapper — EmailService.send({to, subject, html, ctx}) swallows Resend errors and logs them. Passing ctx (an EmailContext) is how a member-facing send picks up the org’s From display name and Reply-To; it also derives a text/plain alternative from the HTML so nothing goes out HTML-only.

  • Org branding resolver — EmailBrandingService turns an org id (or an already-loaded org row) into an EmailContext: 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.ts carries Taikan’s own product tokens (teal #0E8C8C, ocean ground #F6F8FA, ink #0D1B2A, deep band #07202B), lifted from apps/web/src/app/globals.css and 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.ts renders 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.ts holds 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 the EmailCategory union in email-types.ts so 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, EmailService retains sent emails in-memory for spec assertions.

  • Cron jobs — NotificationSchedulerService registers @ScheduledJob(…, '0 8 * * *') (class reminders), @ScheduledJob(…, '0 9 * * *') (expiration warnings), more in cancellation-notifications.service. BillingDebtNotificationsService runs the card-expiry reminders (0 8 * * *), sends the day-0 debt notice on SUBSCRIPTION_DEBT_OPENED / billing.card_unusable, and the last-day and cancelled notices for the hourly SubscriptionLifecycleService.tick; its mails (cardExpiringEmail, cardUnusableEmail, debtOpenedEmail, debtLastDayEmail, debtCancelledEmail in payment-emails) are gated by the member’s billing preference — 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 standalone welcome, export-ready, terminal-activated, entitlement-sweep, plan-change-scheduled, check-in-reminder, form-signing-link. Each exports (params) => { subject, html }.

  • Preview harness — pnpm email:preview renders 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 in tools/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 in localization.unit.spec.ts.

  • Retry wrapper — runWithRetry in common/cron-retry.ts for 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:

  1. From / Reply-To. Mail goes out as <Org Name> <[email protected]> with Reply-To: <organizations.contact_email>, so a member who hits reply reaches their gym rather than an unmonitored mailbox. An org with no contact_email on 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.
  2. Language. Copy renders in the recipient’s locale — their last device locale, else the org’s locale column, 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 resolved retentionOwnerId), 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 methodTemplateAudienceFiresConditions
presaleWithdrawnForStaffpresaleWithdrawnForStaffEmailthe 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.
presaleWithdrawnForMemberpresaleWithdrawnForMemberEmailthe memberConfirms their presale spot was released and nothing was charged.Unflagged — always sent, independent of the case it may also open.
presaleChargeFailedForStaffpresaleChargeFailedForStaffEmailthe 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.
noticeWithdrawnByStaffnoticeWithdrawnByStaffEmailthe memberStaff 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.
refundProcessingrefundProcessingEmailthe memberA 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.com for every org — only the display name is the gym’s. Per-org sending domains would need DKIM per customer.

Source code

  • API: apps/api/src/notifications/
    • email.service.ts — Resend wrapper, sender identity, text/plain part
    • email-branding.service.ts — org identity + recipient locale + logo URL
    • notification-scheduler.service.ts — cron jobs
    • cancellation-notifications.service.ts — cancellation + refund emails
    • templates/ — 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}.json under emails.*
  • Preview: tools/email-preview/render.ts (pnpm email:preview)
  • DB: no notifications table; persistence lives in the source domain (bookings, subscriptions)