Skip to content

Communications — Email, Templates & Notifications

This is the shared communications backbone of the MAS / Mr. Mentor backend: the transactional email pipeline (Nodemailer over Gmail SMTP, driven by a BullMQ queue), the two flavours of email templates (code-defined HTML templates for system mail, and DB-backed editable templates for the Sales CRM), the email send log, and the in-app notification system (the bell badge, broadcasts, and per-student delivery preferences). Almost every other domain — meetings, payments, onboarding, CRM, LMS reminders — ends up here when it needs to reach a human.

Status: documented from source on this branch.


Overview

The communications domain covers three logically distinct delivery channels that share infrastructure:

  1. Transactional email — credentials, OTPs, meeting notifications, payment receipts, discount approvals, CRM outreach. Most are dispatched asynchronously through the emailQueue BullMQ queue and rendered by EmailService (src/services/EmailService.ts). A few latency-sensitive auth flows (signup OTP, registration credentials) bypass the queue and send inline from AuthService.
  2. In-app notifications — the student bell badge + notification feed, plus admin broadcasts. Written synchronously to the notifications table by NotificationService (src/services/NotificationService.ts); the unread count is cached in Redis. Subject to a hard 3-notifications-per-student-per-day cap.
  3. Editable email templates — two separate systems:
  4. Code templates under src/services/templates/* — TypeScript functions returning HTML strings, imported on demand by EmailService.
  5. DB templates (email_templates table) — created/edited by Sales/Admin users through the /api/sales/email-templates API, with {{variable}} interpolation and an emails_sent audit log.

Who uses it

Persona Touch points
Students / users Receive onboarding, meeting, payment, discount emails; see the in-app notification bell + daily cards
Mentors / experts Meeting request/approval emails, onboarding credentials
Sales / Batch Lead / Community Manager Compose + send CRM outreach emails from templates; receive discount-approval notifications
Admins / Superadmins Send + broadcast in-app notifications, manage shared email templates, monitor the queue via Bull Board
System (workers) Enqueue reminder emails and fire templated notifications (assignment reminders, MrLearn/MrTest sync events)

Where it sits

flowchart LR
  subgraph Producers["Producers (any domain)"]
    AUTH["AuthService"]
    MEET["Meeting / Slot services"]
    PAY["Payments / Finance"]
    CRM["Sales CRM"]
    WRK["BullMQ workers"]
    ADM["Admin controllers"]
  end

  subgraph Comms["Communications backbone"]
    QS["QueueService.addEmailJob"]
    EQ[("emailQueue (Redis)")]
    EW["email.worker"]
    ES["EmailService (Nodemailer)"]
    NS["NotificationService"]
    NDB[("notifications table")]
  end

  subgraph External["External"]
    SMTP["Gmail SMTP"]
    INBOX["Recipient inbox"]
  end

  AUTH -->|inline OTP| SMTP
  MEET --> QS
  PAY --> QS
  CRM --> QS
  WRK --> QS
  QS --> EQ --> EW --> ES --> SMTP --> INBOX
  ADM --> NS
  WRK --> NS
  NS --> NDB

Key concepts & entities

Glossary

  • Email job — a JSON payload pushed to the emailQueue with a type discriminator (meeting-approval, welcome-email, payment-invoice, crm-lead-email, …). The worker switches on type to pick a render method.
  • Code template — a function in src/services/templates/ such as WelcomeEmailTemplate.ts that takes typed props and returns an HTML string. Used for system/transactional mail. Not editable at runtime.
  • DB template (EmailTemplate) — a row created by Sales/Admin staff with a subject, body, and {{var}} placeholders. Used for CRM/recruitment outreach. Editable in the UI; interpolated via fillTemplate.
  • Email send log (EmailSent) — an audit row written for every send done through the Sales template send endpoint (status sent / failed / bounced).
  • Lead email log (LeadEmailLog) — a CRM-specific audit row for outreach to a RawLead; its status (queuedsent/failed) is flipped by the worker.
  • Notification — an in-app message row (bell feed). Has a type, category, priority, optional CTA, and isRead/readAt.
  • Notification template — an in-code registry (NotificationService.TEMPLATES) mapping a slug (assignment_due_24h, mrtest_new_score, …) to a rendered notification body. Lets non-engineers grep + edit copy in one place.
  • Daily capMAX_NOTIFICATIONS_PER_DAY = 3, enforced per student per IST calendar day across all categories.
  • Notification preference — per-student emailNotifications / whatsappAlerts flags (on student_profiles). Gate only announcement-type messages, never transactional ones.

Main TypeORM entities

Entity File Table Purpose
EmailTemplate src/entities/EmailTemplate.entity.ts email_templates DB-backed editable templates (Sales/CRM)
EmailSent src/entities/EmailSent.entity.ts emails_sent Audit log of template-driven sends
LeadEmailLog src/entities/LeadEmailLog.ts mas_crm.lead_email_logs Per-lead outreach email audit + status
Notification src/entities/Notification.ts notifications In-app notification feed
MeetingActionToken src/entities/MeetingActionToken.ts One-click approve/reject tokens embedded in meeting emails

Notification delivery preferences are read from StudentProfile (emailNotifications, whatsappAlerts) — see Student portal & profile.


Architecture

flowchart TD
  subgraph Routes
    SR["/api/sales/email-templates/*"]
    STR["/api/student/notifications/*"]
    AR["/api/admin/mas/notifications/*"]
  end

  subgraph Controllers
    ETC["EmailTemplateController"]
    NC["NotificationController"]
  end

  subgraph Services
    ETS["EmailTemplateService"]
    ES["EmailService"]
    NS["NotificationService"]
    NPS["NotificationPreferenceService"]
    LES["LeadEmailService"]
    QS["QueueService"]
  end

  subgraph Async
    EQ[("emailQueue")]
    EW["email.worker"]
  end

  subgraph DB["PostgreSQL"]
    ETBL[("email_templates")]
    ESENT[("emails_sent")]
    LLOG[("lead_email_logs")]
    NTBL[("notifications")]
    SP[("student_profiles")]
  end

  subgraph Cache["Redis"]
    UC["notifications:unread:userId"]
  end

  subgraph Ext["External"]
    SMTP["Gmail SMTP"]
  end

  SR --> ETC --> ETS
  ETS --> ETBL
  ETS --> ESENT
  ETC --> ES --> SMTP

  STR --> NC --> NS --> NTBL
  AR --> NC
  NC --> UC

  LES --> LLOG
  LES --> QS --> EQ --> EW --> ES
  EW --> NPS --> SP
  EW --> LES
  NS --> UC

Key structural notes:

  • EmailService constructs one pooled Nodemailer transport (Gmail service, pool: true, maxConnections: 3, maxMessages: 50) with connection / greeting / socket timeouts, plus a Promise.race send timeout (EMAIL_TIMEOUT_MS, default 20s) so a blocked SMTP socket can never hang a worker job indefinitely.
  • The notificationQueue is declared in QueueService, but in-app notifications are written synchronously by NotificationService from request/worker context — there is no notification.worker consuming that queue on this branch.
  • EmailService.enhanceEmailHtml post-processes every outgoing HTML body to add background-color fallbacks for CSS gradients and strip flexbox styles from <tr> rows, improving rendering in email clients that lack modern CSS.

Data model

erDiagram
  USER ||--o{ NOTIFICATION : "receives"
  USER ||--o{ EMAIL_SENT : "sent_by"
  USER ||--o{ LEAD_EMAIL_LOG : "sent_by"
  RAW_LEAD ||--o{ LEAD_EMAIL_LOG : "targeted_by"
  STUDENT_PROFILE ||--|| USER : "preferences_for"

  EMAIL_TEMPLATE {
    uuid id PK
    varchar name
    varchar trigger
    text subject
    text body
    json dynamicVars
    timestamp lastEdited
    uuid belongsTo
  }

  EMAIL_SENT {
    uuid id PK
    varchar subject
    varchar recipientEmail
    varchar recipientName
    text bodyPreview
    uuid applicationId
    uuid templateId
    varchar templateName
    uuid sentBy FK
    varchar status
    text errorMessage
    timestamp sentAt
  }

  LEAD_EMAIL_LOG {
    uuid id PK
    uuid lead_id FK
    uuid sent_by FK
    varchar template_key
    varchar track
    varchar to_email
    varchar subject
    text body
    varchar status
    text error_message
    timestamp sent_at
  }

  NOTIFICATION {
    uuid id PK
    uuid userId FK
    varchar type
    varchar category
    varchar title
    text body
    varchar ctaLabel
    varchar ctaLink
    jsonb metadata
    varchar priority
    boolean isRead
    timestamp readAt
    timestamp createdAt
    timestamp expiresAt
  }

  STUDENT_PROFILE {
    uuid userId FK
    boolean emailNotifications
    boolean whatsappAlerts
  }

Notable enums / status fields

  • EmailSent.status: sent | failed | bounced (default sent).
  • LeadEmailLog.status (LeadEmailStatus): queued | sent | failed.
  • LeadEmailLog.track (LeadEmailTrack): student | parent | custom.
  • Notification.type (NotificationType): assignment_due, quiz_result, performance_alert, attendance_warning, classroom_ready, course_update, admin_broadcast, achievement, daily_nudge.
  • Notification.category (NotificationCategory): priority, deadline, performance, system, admin.
  • Notification.priority (NotificationPriority): low, medium, high, urgent.

API surface

Paths are derived from src/routes/index.ts mount prefixes plus the route files.

Sales / CRM email templates — mounted at /api/sales

All routes require authMiddleware (applied globally in sales.routes.ts).

Method Path Auth/role Purpose
GET /api/sales/email-templates Authenticated; non-ADMIN sees own + global List templates
GET /api/sales/email-templates/:id Authenticated Get one template
POST /api/sales/email-templates Authenticated Create a template (name, trigger, subject, body required)
PUT /api/sales/email-templates/:id Owner or ADMIN Update a template
DELETE /api/sales/email-templates/:id Owner or ADMIN Delete a template
POST /api/sales/email-templates/send Authenticated Send an email (optionally fill from template) + log to emails_sent
POST /api/sales/email-templates/preview Authenticated Render a template with variables without sending
GET /api/sales/email-templates/history/:applicationId Authenticated Email send history for an application

Student notifications — mounted at /api/student

Method Path Auth/role Purpose
GET /api/student/notifications Authenticated Paginated notification list (page, limit≤50)
GET /api/student/notifications/unread-count Authenticated Unread badge count (Redis-cached 5 min)
POST /api/student/notifications/:id/read Authenticated Mark one as read
POST /api/student/notifications/read-all Authenticated Mark all as read
GET /api/student/daily-cards Authenticated Today's up-to-3 smart cards (Redis-cached)
POST /api/student/daily-cards/:id/dismiss Authenticated Dismiss a daily card

Admin notifications — mounted at /api/admin

All require authMiddleware + adminMiddleware.

Method Path Auth/role Purpose
POST /api/admin/mas/notifications/send ADMIN/SUPERADMIN Send to a specific list of userIds
POST /api/admin/mas/notifications/broadcast ADMIN/SUPERADMIN Broadcast to all / batch / custom targets
GET /api/admin/mas/notifications/stats ADMIN/SUPERADMIN Counts: total, unread, today, by type
POST /api/admin/mas/notifications/generate-daily-cards ADMIN/SUPERADMIN Manual daily-card trigger (stub; real work in worker)

CRM lead-outreach send/list endpoints live in the Sales CRM routes and delegate to LeadEmailService; see Sales CRM — leads. The Bull Board queue monitoring UI is mounted at /admin/queues.


User journeys

Journey 1 — App enqueues a transactional email (the canonical path)

The most common flow: some service decides a human needs an email, pushes a typed job to emailQueue, and returns immediately. The email.worker later renders the correct template and sends it.

sequenceDiagram
  participant SVC as Producer service
  participant QS as QueueService
  participant EQ as emailQueue via Redis
  participant EW as email.worker
  participant ES as EmailService
  participant TPL as Code template module
  participant SMTP as Gmail SMTP

  SVC->>QS: addEmailJob with type welcome-email and recipient data
  QS->>EQ: add sendEmail job
  QS-->>SVC: enqueued, return without waiting
  Note over EQ,EW: worker concurrency 5, rate limit 10 per second
  EW->>EW: switch on job.data.type
  EW->>ES: sendWelcomeEmail with userEmail and name
  ES->>TPL: dynamic import WelcomeEmailTemplate then render HTML
  TPL-->>ES: HTML string
  ES->>ES: enhanceEmailHtml adds gradient fallbacks
  ES->>SMTP: sendMail wrapped in send-timeout race
  SMTP-->>ES: accepted
  ES-->>EW: resolved
  EW-->>EQ: job completed

Alternates

  • SMTP not configured — if EMAIL_USER / EMAIL_PASS are missing, EmailService.sendEmail logs the intent and throws; the job fails and BullMQ retains it in the failed set (removeOnFail: 50).
  • Send timeout / SMTP error — the Promise.race rejects after EMAIL_TIMEOUT_MS; the worker re-throws, marking the job failed. BullMQ retries only if the producer set attempts in the job options (most callers do not, so failures are terminal but visible in Bull Board).
  • Worker not yet initialised — every case guards if (!emailService) throw; the DB-dependent services initialise asynchronously on worker boot, so a job arriving before init fails and is retried/inspected.

Journey 2 — Inline OTP / signup email (queue bypass)

Latency-sensitive auth emails are sent synchronously from AuthService (src/services/AuthService.ts) using a transport it constructs itself, so the user sees "OTP sent" only after SMTP accepts. This path does not touch the queue.

sequenceDiagram
  participant FE as Frontend
  participant AUTH as AuthService
  participant TPL as SignupOtpEmailTemplate
  participant SMTP as Gmail SMTP

  FE->>AUTH: request signup OTP
  AUTH->>AUTH: generate OTP then persist
  alt SMTP credentials missing
    AUTH-->>FE: skip send, log OTP, do not fail
  else credentials present
    AUTH->>TPL: render OTP HTML
    TPL-->>AUTH: HTML string
    AUTH->>SMTP: sendMail wrapped in timeout race
    SMTP-->>AUTH: accepted
    AUTH-->>FE: OTP sent
  end

The password-changed confirmation email is the exception inside auth — it is enqueued as a password-changed job and the worker calls AuthService.sendPasswordChangedEmail. See Identity & access.

Journey 3 — Meeting approval email with one-click action tokens

When a student books a slot, the mentor gets an email containing secure approve/reject links backed by MeetingActionToken rows (32-byte hex, 7-day expiry). Clicking acts without logging in.

sequenceDiagram
  participant MEET as Meeting service
  participant QS as QueueService
  participant EW as email.worker
  participant ES as EmailService
  participant TK as MeetingActionToken repo
  participant TPL as MeetingApprovalEmailTemplate
  participant SMTP as Gmail SMTP

  MEET->>QS: addEmailJob type meeting-approval with mentorId and slot
  QS-->>MEET: enqueued
  EW->>ES: sendMeetingApprovalNotification
  ES->>TK: create approve and reject tokens with 7 day expiry
  TK-->>ES: two tokens saved
  ES->>TPL: render with approveUrl and rejectUrl
  TPL-->>ES: HTML
  ES->>SMTP: send to mentor
  SMTP-->>ES: accepted
  Note over ES,TK: if token creation fails it falls back to dashboard links

See Mentorship & meetings for the action-token consumption flow.

The payment-invoice job builds the receipt HTML, generates a PDF via InvoicePdfService, and attaches both the PDF and the MAS logo (referenced by cid:mas-logo).

sequenceDiagram
  participant PAY as Payment flow
  participant EW as email.worker
  participant PDF as InvoicePdfService
  participant LOGO as assetLoader loadMasLogo
  participant ES as EmailService
  participant SMTP as Gmail SMTP

  PAY->>EW: payment-invoice job with amount and batchCode
  EW->>PDF: generateInvoicePdf with student details
  alt PDF generation fails
    PDF-->>EW: error, send email without attachment
  else success
    PDF-->>EW: PDF buffer
  end
  EW->>LOGO: load MAS logo as inline attachment
  LOGO-->>EW: logo buffer or null
  EW->>ES: sendEmail with HTML and attachments list
  ES->>SMTP: send receipt with logo cid and PDF
  SMTP-->>ES: accepted

The same logo-attachment helper is reused by discount-approved-student, crm-lead-email, and telecaller-lead-assignment jobs. See Payments & finance.

Journey 5 — CRM lead outreach (DB-logged, status round-trip)

A salesperson composes outreach on the lead detail page. LeadEmailService writes a LeadEmailLog row in queued state, enqueues the send, and the worker flips the row to sent or failed so the lead timeline reflects reality.

sequenceDiagram
  participant SP as Salesperson
  participant LES as LeadEmailService
  participant LOG as lead_email_logs
  participant QS as QueueService
  participant EW as email.worker
  participant ES as EmailService
  participant SMTP as Gmail SMTP

  SP->>LES: send with leadId and templateKey or custom body
  LES->>LES: resolve recipient by track and validate email
  LES->>LES: interpolate template with lead first name
  LES->>LOG: insert row status queued
  LOG-->>LES: saved id
  LES->>QS: addEmailJob type crm-lead-email with leadEmailLogId
  alt enqueue fails
    LES->>LOG: update row to failed with error
    LES-->>SP: throw
  else enqueued
    LES-->>SP: return queued log row
  end
  EW->>ES: sendEmail with compiled HTML and logo
  alt send ok
    ES-->>EW: accepted
    EW->>LOG: markSent flips status to sent
  else send fails
    ES-->>EW: error
    EW->>LOG: markFailed records error then re-throw
  end

See Sales CRM — leads.

Journey 6 — Sales template send + audit log

The generic Sales send endpoint optionally fills a DB template, sends synchronously, then writes an EmailSent audit row (and a failed row if the send throws).

sequenceDiagram
  participant FE as Sales UI
  participant ETC as EmailTemplateController
  participant ETS as EmailTemplateService
  participant ES as EmailService
  participant ESENT as emails_sent
  participant SMTP as Gmail SMTP

  FE->>ETC: POST send with to subject body and optional templateId and variables
  ETC->>ETC: validate email format
  alt templateId is UUID and variables present
    ETC->>ETS: getTemplateById
    ETS-->>ETC: template
    ETC->>ETS: fillTemplate replaces double-brace vars
  end
  ETC->>ES: sendEmail to subject html
  alt send ok
    ES-->>ETC: accepted
    ETC->>ETS: logSentEmail status sent
    ETS->>ESENT: insert audit row
    ETC-->>FE: success
  else send fails
    ES-->>ETC: error
    ETC->>ETS: logSentEmail status failed with errorMessage
    ETC-->>FE: 500
  end

Journey 7 — In-app notification fan-out with daily cap

Admins send/broadcast notifications, and workers fire templated ones. All paths go through NotificationService.createForUsers, which dedupes, enforces the 3/day cap, batch-inserts rows, and invalidates each recipient's unread-count cache.

sequenceDiagram
  participant ADM as Admin or worker
  participant NS as NotificationService
  participant NDB as notifications table
  participant REDIS as Redis unread cache

  ADM->>NS: createForUsers with userIds and rendered body
  NS->>NS: dedupe userIds
  NS->>NDB: count today notifications per user since IST midnight
  NDB-->>NS: per-user counts
  NS->>NS: split into allowed and skipped at 3 per day cap
  alt all skipped
    NS-->>ADM: sent 0 and skipped count
  else some allowed
    NS->>NDB: bulk insert notification rows for allowed users
    NS->>REDIS: delete unread cache key per allowed user
    NS-->>ADM: sent count and skipped count
  end

Broadcast specifics (broadcastNotification): resolves targets by type: all (all role=user), type: batch (enrollment join on batchId), or type: custom (explicit userIds), then chunks into batches of 500 before calling the service so a giant broadcast never loads every cap count at once.

Journey 8 — Student reads the bell and clears the badge

sequenceDiagram
  participant FE as Student UI
  participant NC as NotificationController
  participant REDIS as Redis unread cache
  participant NDB as notifications table

  FE->>NC: GET unread-count
  NC->>REDIS: get notifications unread key
  alt cache hit
    REDIS-->>NC: cached count
  else cache miss
    NC->>NDB: count where isRead false
    NDB-->>NC: count
    NC->>REDIS: set count with 5 min TTL
  end
  NC-->>FE: unread count
  FE->>NC: GET notifications page 1
  NC->>NDB: findAndCount ordered by createdAt desc
  NDB-->>NC: rows and total
  NC-->>FE: list with pagination
  FE->>NC: POST read-all
  NC->>NDB: set isRead true and readAt now for unread
  NC->>REDIS: set unread key to 0
  NC-->>FE: ok

Journey 9 — Worker-fired templated notification (assignment reminder)

The assignmentReminder.worker runs daily at 19:00 IST, finds assignments due in the next 24h, and fires the assignment_due_24h notification template to enrolled students — subject to the same 3/day cap.

sequenceDiagram
  participant CRON as assignmentReminderQueue schedule
  participant AW as assignmentReminder.worker
  participant NS as NotificationService
  participant NDB as notifications table

  CRON->>AW: remindAssignmentsDue at 19:00 IST
  AW->>AW: query assignments due in next 24 hours
  AW->>AW: resolve enrolled student userIds per course
  AW->>NS: fromTemplate assignment_due_24h with title and id
  NS->>NS: render template then apply daily cap
  NS->>NDB: insert allowed rows
  NS-->>AW: sent and skipped counts

The same fromTemplate mechanism backs classroom_assigned, mas101_mou_approved, mrtest_new_score, and mrlearn_module_completed — fired from their respective domains.


Background jobs & async

Queue Worker Trigger Role in comms
emailQueue email.worker (src/workers/email.worker.ts) On demand via QueueService.addEmailJob Renders + sends all queued transactional email. Concurrency 5, rate limit 10/sec, removeOnComplete/Fail: 50
notificationQueue (none on this branch) Declared in QueueService Reserved; in-app notifications are written synchronously, not via this queue
assignmentReminderQueue assignmentReminder.worker Daily 19:00 IST (0 19 * * *) Fires assignment_due_24h notifications
dailyCardsQueue daily-cards.worker Daily midnight IST (0 0 * * *) Generates per-student daily cards surfaced at /api/student/daily-cards
whatsappQueue whatsapp.worker On demand Sibling channel; see WhatsApp

Worker bootsrc/index.ts imports ./workers/email.worker during startup, which lazily initialises EmailService, AuthService, NotificationPreferenceService, and LeadEmailService against the live DataSource once the DB connects.

Worker event hooksemail.worker logs completed / failed / error events; failed jobs re-throw so BullMQ records them (and retries when attempts is configured by the producer).

No webhooks / socket events are part of this domain. The unread badge is polled (~60s) by the frontend rather than pushed over Socket.IO; the Redis cache (5-min TTL) absorbs that load.


External integrations

Integration Used by Env vars Failure / fallback
Gmail SMTP (Nodemailer) EmailService, AuthService EMAIL_USER, EMAIL_PASS, EMAIL_FROM (optional), EMAIL_TIMEOUT_MS (default 20000) Missing creds → sendEmail throws + logs; auth flows skip send without failing. SMTP hang → Promise.race send timeout. 535 BadCredentials → rotate Gmail app password (ops playbook)
Redis NotificationService, NotificationController, BullMQ REDIS_HOST, REDIS_PORT Unread count + daily-card caches; queue backing store
InvoicePdfService payment-invoice email job (S3/asset config) PDF failure → email sent without attachment
MAS logo asset assetLoader.loadMasLogo bundled asset Returns null if unreadable → email sent without inline logo

Configuration / behavioural flags

  • MENTOR_ONBOARDING_CC_EMAIL / STUDENT_ONBOARDING_CC_EMAIL — CC address for onboarding emails (default info@myanalyticsschool.com).
  • APPLICATION_MEETING_TIME_ZONE — timezone for formatting academic-meeting emails (default Asia/Kolkata).
  • FRONTEND_URL / MAS_WEBSITE_URL — used to build action and CTA links inside email bodies.
  • Multi-platform: sendWelcomeEmail accepts a platform of mr-mentor or my-analytics-school, changing the subject + branding.

Email credentials are sourced from AWS Secrets Manager in deployed environments — see AWS Secrets Manager migration and the ops runbooks. WhatsApp and Exotel telephony are documented separately (WhatsApp, Exotel telephony).


Status lifecycles

LeadEmailLog.status

stateDiagram-v2
  [*] --> queued: LeadEmailService.send inserts row
  queued --> sent: worker markSent after SMTP accepts
  queued --> failed: worker markFailed on SMTP error
  queued --> failed: enqueue itself threw
  failed --> queued: salesperson re-sends
  sent --> [*]

EmailSent.status

stateDiagram-v2
  [*] --> sent: send succeeded, audit row logged
  [*] --> failed: send threw, audit row logged with errorMessage
  sent --> bounced: marked externally (enum allows it)
  sent --> [*]
  failed --> [*]

Notification.isRead

stateDiagram-v2
  [*] --> unread: createForUsers inserts row, cache invalidated
  unread --> read: markAsRead or markAllAsRead sets readAt
  read --> [*]
  unread --> expired: expiresAt passes

Edge cases, limits & gotchas

  • Two template systems, do not confuse them. Code templates (src/services/templates/*.ts) are compiled functions for system mail and are not editable at runtime. DB templates (email_templates) are CRM/recruitment templates edited through the API. They never overlap.
  • Transactional email must never be gated by preferences. NotificationPreferenceService is consulted only for announcement-type jobs (batch-meeting-scheduled, sm-meeting-scheduled). OTPs, credentials, payment receipts, and onboarding mail always send. The service defaults to allow when no StudentProfile row exists (leads, mentors, pre-onboarding users have none).
  • allowsEmailByAddress does a case-insensitive lookup by email and returns true (allow) when no matching user is found — so unknown addresses are never silently dropped.
  • WhatsApp opt-out is phone-based and conservative: matched on the last 10 digits, and an opt-out on any matching profile blocks the send (phones are not unique across accounts).
  • Daily cap is global and IST-anchored. MAX_NOTIFICATIONS_PER_DAY = 3 counts every category against one student per IST calendar day. Broadcasts silently skip capped users and report a skipped count — they are not queued for the next day.
  • Notifications are synchronous. Despite the existence of a notificationQueue, there is no consumer; NotificationService writes rows inline. A very large broadcast is chunked (500/batch) to keep cap-count queries bounded, but still runs in the request lifecycle.
  • Unread badge can be stale up to its TTL. The count is cached 5 min and the frontend polls ~60s; createForUsers, markAsRead, and markAllAsRead all invalidate or overwrite the cache key, but cross-process writes that bypass the service would not.
  • Inline auth emails bypass the queue and retries. Signup OTP / registration emails are sent synchronously from AuthService with their own transport — a slow SMTP server slows the auth request. Missing creds are treated as non-fatal (logged, not thrown) so testing without SMTP still works.
  • No automatic retries unless the producer asks. addEmailJob is usually called without attempts/backoff, so a failed send stays failed (retained in the failed set for inspection via Bull Board at /admin/queues). CRM and template sends compensate by recording a failed status the user can re-trigger.
  • HTML is auto-massaged. enhanceEmailHtml rewrites gradient backgrounds and strips flex styles from table rows. If an email renders oddly, check that transform before blaming the template.
  • Template ownership. Non-ADMIN users only see templates they own (belongsTo = userId) plus global ones (belongsTo IS NULL); update/delete is restricted to the owner or an ADMIN.
  • LeadEmailLog lives in the mas_crm Postgres schema, unlike the other comms tables which are in the default schema.