Skip to Content
ProvidersSendGrid

SendGrid

Category: Notification — Email
Package: @leadmetrics/provider-emailSendGridProvider
External SDK: @sendgrid/mail


Purpose

SendGrid is the platform default email provider. Every tenant gets working email notifications from day one using the platform’s SendGrid account. Tenants can optionally bring their own SendGrid account for improved deliverability from their own domain and full analytics inside their SendGrid dashboard.

What it sends

Notification typeTemplate
HITL review requestshitl-review-request
Activity completionactivity-completed
Monthly reportsmonthly-report-ready
Invoice / billinginvoice-created
Credit top-upcredit-topup-confirmed
Budget warningsbudget-warning
Welcome / onboardingwelcome
Password resetpassword-reset

Config Structure

Platform default (env vars)

SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx EMAIL_FROM_ADDRESS=noreply@leadmetrics.io EMAIL_FROM_NAME=Leadmetrics

Tenant config (stored in notification_providers.config, encrypted)

interface SendGridConfig { apiKey: string; // SG API key with "Mail Send" permission scope fromAddress: string; // Must be a verified sender in the tenant's SG account fromName: string; // Display name shown in email client }

The tenant’s config row in notification_providers:

channel: 'email' provider: 'sendgrid' config: { apiKey, fromAddress, fromName } ← AES-256-GCM encrypted verified_at: TIMESTAMPTZ (NULL until test passes)

Integration Pattern

Provider class (packages/provider-email/src/providers/sendgrid.ts)

import sgMail from '@sendgrid/mail'; class SendGridProvider implements EmailProvider { readonly name = 'sendgrid'; constructor( private apiKey: string, private fromAddress: string, private fromName: string, ) { sgMail.setApiKey(apiKey); } async send(message: EmailMessage): Promise<EmailSendResult> { const response = await sgMail.send({ to: message.to.map(r => ({ name: r.name, email: r.email })), from: { name: this.fromName, email: this.fromAddress }, replyTo: message.replyTo, subject: message.subject, html: message.html, text: message.text, attachments: message.attachments?.map(a => ({ filename: a.filename, content: a.content.toString('base64'), type: a.contentType, disposition: 'attachment', })), }); return { messageId: response[0].headers['x-message-id'] as string, provider: 'sendgrid', }; } async verify(): Promise<void> { // Sends a real test email to the configured from-address to verify API key and sender await sgMail.send({ to: this.fromAddress, from: { name: this.fromName, email: this.fromAddress }, subject: 'Leadmetrics — SendGrid connection test', text: 'This is a test email from Leadmetrics to verify your SendGrid configuration.', }); } }

How it is resolved

resolveEmailProvider(tenantId) ├── DB query: notification_providers WHERE tenant_id = X AND channel = 'email' AND verified_at IS NOT NULL ├── Row found with provider = 'sendgrid' → new SendGridProvider(decrypt(row.config).apiKey, ...) └── No row / unverified → new SendGridProvider(env.SENDGRID_API_KEY, env.EMAIL_FROM_ADDRESS, ...)

Handlebars template rendering

Templates are stored in apps/notifications/src/templates/ as .hbs files. The renderer substitutes {{variable}} placeholders before passing HTML to provider.send().

// apps/notifications/src/templates/renderer.ts const template = Handlebars.compile(fs.readFileSync(`./templates/${templateSlug}.hbs`, 'utf8')); const html = template(variables);

Attachments

Email attachments are fetched from S3 before dispatch and passed as Buffer objects:

// Inside email.handler.ts const attachments = await Promise.all( (job.data.attachments ?? []).map(async (key) => { const object = await s3.getObject({ Bucket: config.S3_BUCKET, Key: key }).promise(); return { filename: path.basename(key), content: object.Body as Buffer, contentType: object.ContentType ?? 'application/octet-stream', }; }), );

Test Cases

Unit tests (packages/provider-email/src/providers/sendgrid.test.ts)

TestApproach
send() dispatches correct payloadMock sgMail.send, assert to, from, subject, html
send() maps attachments to base64Mock sgMail.send, assert content is base64-encoded Buffer
send() returns messageId from response headersMock returns x-message-id header
send() throws on SG 401 (invalid API key)Mock throws ResponseError; assert propagated
send() throws on SG 403 (sender not verified)Mock throws 403; assert propagated
verify() calls sgMail.send with self-addressed test emailAssert to === fromAddress

Integration tests (apps/notifications/src/handlers/email.handler.test.ts)

TestApproach
Platform default used when no tenant rowSeed DB with no notification_providers row; assert SendGridProvider created with env vars
Tenant SendGrid used when verified row presentSeed verified sendgrid row; assert SendGridProvider created with tenant apiKey
Platform default used when verified_at is NULLSeed unverified row; assert platform default used
verify() endpoint sets verified_atPOST /test; assert notification_providers.verified_at is not null
verify() endpoint stores last_error on failureMock sgMail.send to throw; assert last_error populated

Dev / staging

In dev mode, filterRecipientsForDev() replaces all to addresses with DEV_EMAIL_OVERRIDE. No real emails are sent to end users during development.


© 2026 Leadmetrics — Internal use only