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 <noreply@usetaikan.com>'.

  • Send wrapperEmailService.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 resolverEmailBrandingService 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 systemtemplates/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 jobsNotificationSchedulerService registers @Cron('0 8 * * *') (class reminders), @Cron('0 9 * * *') (expiration warnings), more in cancellation-notifications.service.

  • HTML templatesapps/api/src/notifications/templates/, grouped by domain: booking-emails, payment-emails, membership-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 harnesspnpm 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 wrapperrunWithRetry in common/cron-retry.ts for transient failures.

  • Cron gatecronsEnabled() 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> <noreply@usetaikan.com> 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.

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.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)