Notification Providers & Resolution
Every channel supports two modes: tenant-configured (bring your own provider) and
platform default (works out of the box). The resolver in src/resolver.ts picks
at dispatch time � tenants never need to configure a provider for notifications to work.
Resolution Pattern
Job arrives for tenantId X, channel = email
|
v
resolveEmailProvider("X")
|
|-- db.notificationProvider.findFirst({
| where: {
| tenantId: "X",
| channel: "email",
| verifiedAt: { not: null }, // unverified rows are skipped
| }
| })
|
|-- Row found (provider = "smtp") -> new SmtpProvider(decrypt(row.config))
|-- Row found (provider = "sendgrid") -> new SendGridProvider(decrypt(row.config))
|-- Row found (provider = "ses") -> new SesProvider(decrypt(row.config))
+-- No row / not verified -> new SendGridProvider(platform env vars)Same pattern for WhatsApp and Telegram. Web notifications always use the platform (there is no per-tenant web push provider).
Encryption note:
decrypt()is currently aJSON.parsepassthrough during development. In production it must be replaced with AES-256-GCM decryption from@leadmetrics/cryptobefore provider configs are stored in the DB.
Why Tenants Configure Their Own Providers
| Benefit | Detail |
|---|---|
| Email deliverability | Email from noreply@yourbrand.com (SPF + DKIM on their domain) lands better than a shared platform sender |
| WhatsApp brand presence | Messages come from the tenant’s verified WhatsApp Business number |
| Existing contracts | Many agencies already have SendGrid / Twilio accounts with negotiated rates |
| Compliance | Some enterprise clients require outbound comms from their own infrastructure |
Database Table � notification_provider
One row per channel per tenant. Only a verified row is used � unverified rows fall back to the platform default.
model NotificationProvider {
id String @id @default(cuid())
tenantId String
channel String // "email" | "whatsapp" | "telegram"
provider String // "smtp" | "sendgrid" | "ses" | "whatsapp_business_api" | "telegram_bot"
config String // JSON (will be AES-256-GCM encrypted in production)
verifiedAt DateTime? // NULL = configured but not tested; unverified = platform default used
lastError String? // shown in Dashboard settings on test failure
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(...)
@@unique([tenantId, channel]) // one active provider per channel per tenant
@@map("notification_provider")
}Provider Packages
All concrete providers are in packages/providers/. Each package owns one provider type.
@leadmetrics/provider-sendgrid � Email
interface EmailProvider {
readonly name: string;
send(message: EmailMessage): Promise<EmailSendResult>;
}
interface EmailMessage {
to: { name: string; email: string }[];
subject: string;
html: string;
text?: string;
replyTo?: { name: string; email: string };
attachments?: { filename: string; content: Buffer; contentType: string }[];
}
interface EmailSendResult { messageId: string; provider: string; }Class: SendGridProvider
Config: { apiKey: string; fromAddress: string; fromName?: string }
Dep: @sendgrid/mail
@leadmetrics/provider-smtp � Email (SMTP / Gmail / Outlook / Postfix)
Class: SmtpProvider
Config: { host: string; port: number; secure: boolean; user: string; pass: string; fromAddress: string; fromName?: string }
Dep: nodemailer
@leadmetrics/provider-ses � Email (AWS SES)
Class: SesProvider
Config: { accessKeyId: string; secretAccessKey: string; region: string; fromAddress: string; fromName?: string }
Dep: @aws-sdk/client-ses + nodemailer SES transport
@leadmetrics/provider-whatsapp � WhatsApp Business API
interface WhatsAppProvider {
readonly name: string;
sendTemplate(message: WhatsAppMessage): Promise<WhatsAppSendResult>;
}
interface WhatsAppMessage {
to: string; // E.164
template: { name: string; languageCode: string };
variables: Record<string, string>;
}Class: WhatsAppBusinessProvider
Config: { apiKey: string; phoneNumberId: string; baseUrl?: string }
Dep: axios ? Meta Graph API v19.0
Note: WhatsApp only supports pre-approved Meta templates. See channel doc for the registered template map.
@leadmetrics/provider-telegram � Telegram Bot
interface TelegramProvider {
readonly name: string;
send(message: TelegramMessage): Promise<TelegramSendResult>;
}
interface TelegramMessage {
chatId: string | number;
text: string;
parseMode?: "HTML" | "Markdown" | "MarkdownV2";
disableWebPagePreview?: boolean;
}Class: TelegramBotProvider
Config: { botToken: string; defaultChatId?: string; baseUrl?: string }
Dep: axios ? Telegram Bot API
SMS � Planned
| Package | Class | Provider | Status |
|---|---|---|---|
@leadmetrics/provider-msg91 | Msg91Provider | MSG91 (India) | Planned |
@leadmetrics/provider-twilio | TwilioProvider | Twilio (International) | Planned |
Provider Config Per Channel
Configs stored in notification_provider.config (JSON, encrypted in production):
// smtp
{ "host": "smtp.gmail.com", "port": 587, "secure": false,
"user": "bot@acme.com", "pass": "...", "fromAddress": "noreply@acme.com" }
// sendgrid
{ "apiKey": "SG.xxx", "fromAddress": "noreply@acme.com", "fromName": "Acme" }
// ses
{ "accessKeyId": "AKIA...", "secretAccessKey": "...", "region": "ap-south-1",
"fromAddress": "noreply@acme.com" }{ "apiKey": "EAA...", "phoneNumberId": "123456789", "baseUrl": "https://graph.facebook.com/v19.0" }Telegram
{ "botToken": "123456:ABC-...", "defaultChatId": "-100..." }Platform Defaults (Environment Variables)
If no tenant-specific row exists (or it is unverified), these platform-level env vars are used. Tenants do not need to configure anything for notifications to work.
| Channel | Env vars |
|---|---|
| Email (SendGrid) | SENDGRID_API_KEY, EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME |
WHATSAPP_API_KEY, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_BASE_URL | |
| Telegram | TELEGRAM_BOT_TOKEN, TELEGRAM_DEFAULT_CHAT_ID |
Adding a New Email Provider
- Create
packages/providers/<name>/� same structure asprovider-sendgrid - Implement the
EmailProviderinterface (samesend()signature) - Add
case "<name>":toresolveEmailProvider()inapps/servers/notifications/src/resolver.ts - Add unit tests with
vi.hoisted()mock pattern (seeprovider-sendgridtests)