Notification Channels
Per-channel implementation details � queue names, handler logic, provider packages, template handling, and current status.
Channel Summary
| Channel | Queue | Concurrency | Template source | Status |
|---|---|---|---|---|
notifications__email | 10 | email_template DB table | Live | |
notifications__whatsapp | 3 | Meta pre-approved template names | Live | |
| Telegram | notifications__telegram | 5 | telegram-templates.ts map | Live |
| SMS | notifications__sms | 2 | sms-templates.ts map | Stub � no provider yet |
| Web (in-app) | notifications__web | 20 | Inline Handlebars strings | Live |
Email Channel
Handler: src/handlers/email.handler.ts
Queue: notifications__email
Concurrency: 10
Dispatch flow
Job received
|
+-> filterRecipientsForDev(recipients, "email")
| dev/test: drop addresses whose domain is not in DEV_ALLOWED_EMAIL_DOMAINS
| production/staging: pass all through
| empty result -> return { status: "skipped_dev_filter" }
|
+-> resolveEmailProvider(tenantId)
| tenant verified row -> SmtpProvider | SendGridProvider | SesProvider
| none/unverified -> SendGridProvider (platform default)
|
+-> loadEmailTemplate(templateSlug ?? type, tenantId)
| in-memory cache (1 min TTL)
| DB: email_template WHERE slug = ? AND (tenantId = ? OR tenantId IS NULL)
| ORDER BY tenantId DESC (tenant row sorts first)
| returns { subject, html, text }
|
+-> renderTemplate(subject | html | text, variables)
| Handlebars.compile(template, { noEscape: true })(variables)
|
+-> provider.send({ to, subject, html, text })
| returns { messageId }
|
+-> return { status: "sent", messageId }Email templates � DB model
Templates live in the email_template Prisma model:
model EmailTemplate {
id String @id @default(cuid())
tenantId String? // null = platform default
slug String // e.g. "welcome", "approval_required"
subject String
htmlBody String @db.Text
textBody String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, slug])
@@map("email_template")
}Variables use {{variable}} Handlebars syntax. The welcome template is seeded via
packages/db/src/seed.ts as a platform default (tenantId = null).
Platform default: SendGrid
From address: EMAIL_FROM_ADDRESS env var (e.g. noreply@leadmetrics.ai)
Sender name: EMAIL_FROM_NAME (default: Leadmetrics)
Tenant override � supported providers
| Provider slug | Package | Notes |
|---|---|---|
smtp | @leadmetrics/provider-smtp | Any SMTP � Gmail, Outlook, Postfix |
sendgrid | @leadmetrics/provider-sendgrid | Tenant’s own SendGrid account |
ses | @leadmetrics/provider-ses | Tenant’s own AWS SES |
WhatsApp Channel
Handler: src/handlers/whatsapp.handler.ts
Queue: notifications__whatsapp
Concurrency: 3
WhatsApp Business API requires Meta-approved templates — free-form messages are not
allowed. Each NotificationType maps to a pre-approved template config defined in
src/templates/whatsapp-templates.ts:
// whatsapp-templates.ts
export const WHATSAPP_TEMPLATES: Partial<Record<NotificationType, WhatsAppTemplateConfig>> = {
approval_required: { name: "leadmetrics_approval_required", languageCode: "en", paramKeys: [...] },
// ...
};The handler calls getWhatsAppTemplate(type) — types not in the map return
{ status: "no_template" } without retrying. Add new types to whatsapp-templates.ts,
not to the handler directly.
Dispatch flow
Job received
|
+-> filterRecipientsForDev(recipients, "whatsapp")
| dev: keep only phone numbers starting with DEV_ALLOWED_PHONE_PREFIX
|
+-> getWhatsAppTemplate(type) (from whatsapp-templates.ts)
| no entry -> return { status: "no_template" }
|
+-> resolveWhatsAppProvider(tenantId)
|
+-> per-recipient: provider.sendTemplate({ to: phone, template: { name, languageCode }, variables })
| Promise.allSettled � partial success allowed
|
+-> return { status: "sent" | "partial", sent: N }Provider
Package: @leadmetrics/provider-whatsapp
Class: WhatsAppBusinessProvider
API: Meta Graph API v19.0
Config: { apiKey, phoneNumberId, baseUrl? }
Telegram Channel
Handler: src/handlers/telegram.handler.ts
Queue: notifications__telegram
Concurrency: 5
Telegram is used primarily for internal ops alerting. Templates are Handlebars
strings defined in src/templates/telegram-templates.ts, keyed by NotificationType,
and rendered to HTML (parseMode: “HTML”).
If recipients is empty in the job payload, the ops fallback channel
(TELEGRAM_DEFAULT_CHAT_ID) is used. This check happens before the dev filter
so ops alerts are never silently dropped in dev mode.
Dispatch flow
Job received
|
+-> targets = recipients.length > 0 (ops fallback BEFORE dev filter)
| ? recipients
| : [{ name: "ops", chatId: TELEGRAM_DEFAULT_CHAT_ID }]
|
+-> filterRecipientsForDev(targets, "telegram")
| empty -> return { status: "skipped_dev_filter", sent: 0 }
|
+-> resolveTelegramProvider(tenantId)
|
+-> getTelegramTemplate(type) (from telegram-templates.ts)
| fallback: "[<type>]" literal
+-> renderTemplate(templateStr, variables) -> HTML string
|
+-> per-target: provider.send({ chatId, text, parseMode: "HTML" })
| Promise.allSettled — partial success allowed
|
+-> return { status: "sent" | "partial", sent: N }Provider
Package: @leadmetrics/provider-telegram
Class: TelegramBotProvider
API: Telegram Bot API
Config: { botToken, defaultChatId?, baseUrl? }
SMS Channel
Handler: src/handlers/sms.handler.ts
Queue: notifications__sms
Concurrency: 2
Status: STUB � no provider dispatches yet.
The BullMQ worker is running and consuming jobs. The handler applies the dev filter,
logs a warning, and returns { status: "sent", sent: 0 }.
When @leadmetrics/provider-msg91 and @leadmetrics/provider-twilio are built:
- Add
resolveSmsProvider(tenantId)tosrc/resolver.ts - Replace the stub body in
sms.handler.tswith real dispatch logic - Use
src/templates/sms-templates.tsfor plain-text message content
SMS template map (src/templates/sms-templates.ts)
Short plain-text strings per type (max 160 chars; long messages split by provider):
const SMS_TEMPLATES: Partial<Record<string, (vars: Record<string, string>) => string>> = {
credits_exhausted: (v) =>
`Leadmetrics: Credit limit reached. ${v.pausedActivities} activities paused. Top up at ${v.topupUrl}`,
credits_warning_80: (v) =>
`Leadmetrics: ${v.creditsUsed}/${v.creditsTotal} credits used. ${v.creditsRemaining} remaining until ${v.resetDate}`,
payment_failed: (v) =>
`Leadmetrics: Payment of Rs.${v.amountInr} failed. Update billing at ${v.billingUrl}`,
pipeline_blocked: (v) =>
`Leadmetrics: Pipeline blocked � "${v.blockedTaskTitle}". View at ${v.pipelineUrl}`,
};Planned providers
| Package | Class | Market | Status |
|---|---|---|---|
@leadmetrics/provider-msg91 | Msg91Provider | India | Planned |
@leadmetrics/provider-twilio | TwilioProvider | International | Planned |
Web (In-App) Channel
Handler: src/handlers/web.handler.ts
Queue: notifications__web
Concurrency: 20
In-app notifications are written to the notification Prisma table (shown in the
Dashboard notification centre) and optionally pushed live via an SSE hub.
Dispatch flow
Job received
|
+-> renderTemplate("web_<type>", variables) -> short message string
|
+-> per-recipient: db.notification.create({
| tenantId, userId, type, message, refId, refType, read: false
| })
| failure: log.warn + continue (soft fail � don't retry for a DB insert error)
|
+-> if NOTIFICATION_HUB_URL && NOTIFICATION_HUB_KEY:
| POST <hub>/push { tenantId, type, message, recipients: [userId, ...] }
| Headers: { x-hub-key: NOTIFICATION_HUB_KEY }
| -> triggers SSE push to open Dashboard tabs
| best-effort: hub offline -> notification persisted, no live push
|
+-> return { status: "sent" }Prisma model
model Notification {
id String @id @default(cuid())
tenantId String
userId String?
type String
channel String @default("web")
title String
message String @db.Text
refId String?
refType String?
read Boolean @default(false)
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, userId, read])
@@index([tenantId, createdAt])
@@map("notification")
}