SendGrid
Category: Notification — Email
Package: @leadmetrics/provider-email → SendGridProvider
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 type | Template |
|---|---|
| HITL review requests | hitl-review-request |
| Activity completion | activity-completed |
| Monthly reports | monthly-report-ready |
| Invoice / billing | invoice-created |
| Credit top-up | credit-topup-confirmed |
| Budget warnings | budget-warning |
| Welcome / onboarding | welcome |
| Password reset | password-reset |
Config Structure
Platform default (env vars)
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM_ADDRESS=noreply@leadmetrics.io
EMAIL_FROM_NAME=LeadmetricsTenant 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)
| Test | Approach |
|---|---|
send() dispatches correct payload | Mock sgMail.send, assert to, from, subject, html |
send() maps attachments to base64 | Mock sgMail.send, assert content is base64-encoded Buffer |
send() returns messageId from response headers | Mock 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 email | Assert to === fromAddress |
Integration tests (apps/notifications/src/handlers/email.handler.test.ts)
| Test | Approach |
|---|---|
| Platform default used when no tenant row | Seed DB with no notification_providers row; assert SendGridProvider created with env vars |
| Tenant SendGrid used when verified row present | Seed verified sendgrid row; assert SendGridProvider created with tenant apiKey |
Platform default used when verified_at is NULL | Seed unverified row; assert platform default used |
verify() endpoint sets verified_at | POST /test; assert notification_providers.verified_at is not null |
verify() endpoint stores last_error on failure | Mock 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.
Related
- Notification Packages —
@leadmetrics/provider-emailpackage structure - Notification Providers — resolution pattern, verification flow
- Notification Channels — email handler detail
- SMTP Provider — alternative email provider
- AWS SES Provider — alternative email provider