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:
- Transactional email — credentials, OTPs, meeting notifications, payment
receipts, discount approvals, CRM outreach. Most are dispatched
asynchronously through the
emailQueueBullMQ queue and rendered byEmailService(src/services/EmailService.ts). A few latency-sensitive auth flows (signup OTP, registration credentials) bypass the queue and send inline fromAuthService. - In-app notifications — the student bell badge + notification feed, plus
admin broadcasts. Written synchronously to the
notificationstable byNotificationService(src/services/NotificationService.ts); the unread count is cached in Redis. Subject to a hard 3-notifications-per-student-per-day cap. - Editable email templates — two separate systems:
- Code templates under
src/services/templates/*— TypeScript functions returning HTML strings, imported on demand byEmailService. - DB templates (
email_templatestable) — created/edited by Sales/Admin users through the/api/sales/email-templatesAPI, with{{variable}}interpolation and anemails_sentaudit 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
emailQueuewith atypediscriminator (meeting-approval,welcome-email,payment-invoice,crm-lead-email, …). The worker switches ontypeto pick a render method. - Code template — a function in
src/services/templates/such asWelcomeEmailTemplate.tsthat 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 asubject,body, and{{var}}placeholders. Used for CRM/recruitment outreach. Editable in the UI; interpolated viafillTemplate. - Email send log (
EmailSent) — an audit row written for every send done through the Sales template send endpoint (statussent/failed/bounced). - Lead email log (
LeadEmailLog) — a CRM-specific audit row for outreach to aRawLead; its status (queued→sent/failed) is flipped by the worker. - Notification — an in-app message row (bell feed). Has a
type,category,priority, optional CTA, andisRead/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 cap —
MAX_NOTIFICATIONS_PER_DAY = 3, enforced per student per IST calendar day across all categories. - Notification preference — per-student
emailNotifications/whatsappAlertsflags (onstudent_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:
EmailServiceconstructs one pooled Nodemailer transport (Gmail service,pool: true,maxConnections: 3,maxMessages: 50) with connection / greeting / socket timeouts, plus aPromise.racesend timeout (EMAIL_TIMEOUT_MS, default 20s) so a blocked SMTP socket can never hang a worker job indefinitely.- The
notificationQueueis declared inQueueService, but in-app notifications are written synchronously byNotificationServicefrom request/worker context — there is nonotification.workerconsuming that queue on this branch. EmailService.enhanceEmailHtmlpost-processes every outgoing HTML body to addbackground-colorfallbacks 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(defaultsent).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_PASSare missing,EmailService.sendEmaillogs the intent and throws; the job fails and BullMQ retains it in the failed set (removeOnFail: 50). - Send timeout / SMTP error — the
Promise.racerejects afterEMAIL_TIMEOUT_MS; the worker re-throws, marking the job failed. BullMQ retries only if the producer setattemptsin 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.
Journey 4 — Payment receipt email with generated PDF and inline logo¶
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 boot — src/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 hooks — email.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 (defaultinfo@myanalyticsschool.com).APPLICATION_MEETING_TIME_ZONE— timezone for formatting academic-meeting emails (defaultAsia/Kolkata).FRONTEND_URL/MAS_WEBSITE_URL— used to build action and CTA links inside email bodies.- Multi-platform:
sendWelcomeEmailaccepts aplatformofmr-mentorormy-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.
NotificationPreferenceServiceis 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 noStudentProfilerow exists (leads, mentors, pre-onboarding users have none). allowsEmailByAddressdoes a case-insensitive lookup by email and returnstrue(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 = 3counts every category against one student per IST calendar day. Broadcasts silently skip capped users and report askippedcount — they are not queued for the next day. - Notifications are synchronous. Despite the existence of a
notificationQueue, there is no consumer;NotificationServicewrites 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, andmarkAllAsReadall 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
AuthServicewith 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.
addEmailJobis usually called withoutattempts/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 afailedstatus the user can re-trigger. - HTML is auto-massaged.
enhanceEmailHtmlrewrites 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. LeadEmailLoglives in themas_crmPostgres schema, unlike the other comms tables which are in the default schema.
Related docs¶
- Sales CRM — leads — CRM lead-outreach send + timeline
- WhatsApp messaging — sibling channel +
whatsappQueue - Exotel telephony — click-to-call + SMS channel
- Mentorship & meetings — meeting emails + action tokens
- Payments & finance / GST — payment-invoice + discount emails
- Identity & access — OTP, onboarding, password-changed mail
- Student portal & profile — notification preference flags
- Student engagement & gamification — daily cards + templated notifications
- Workflow automation engine —
workflow-emailjob producer