Skip to Content
ServersNotificationsNotification Channels

Notification Channels

Per-channel implementation details � queue names, handler logic, provider packages, template handling, and current status.


Channel Summary

ChannelQueueConcurrencyTemplate sourceStatus
Emailnotifications__email10email_template DB tableLive
WhatsAppnotifications__whatsapp3Meta pre-approved template namesLive
Telegramnotifications__telegram5telegram-templates.ts mapLive
SMSnotifications__sms2sms-templates.ts mapStub � no provider yet
Web (in-app)notifications__web20Inline Handlebars stringsLive

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 slugPackageNotes
smtp@leadmetrics/provider-smtpAny SMTP � Gmail, Outlook, Postfix
sendgrid@leadmetrics/provider-sendgridTenant’s own SendGrid account
ses@leadmetrics/provider-sesTenant’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:

  1. Add resolveSmsProvider(tenantId) to src/resolver.ts
  2. Replace the stub body in sms.handler.ts with real dispatch logic
  3. Use src/templates/sms-templates.ts for 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

PackageClassMarketStatus
@leadmetrics/provider-msg91Msg91ProviderIndiaPlanned
@leadmetrics/provider-twilioTwilioProviderInternationalPlanned

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") }

© 2026 Leadmetrics — Internal use only