SMTP
Category: Notification — Email
Package: @leadmetrics/provider-email → SmtpProvider
External SDK: nodemailer
Purpose
SMTP is the most flexible tenant email option — it works with any SMTP server including Gmail, Outlook/Microsoft 365, self-hosted Postfix, or any transactional email service that exposes SMTP credentials. Tenants who don’t have a SendGrid account but do have an SMTP mail server use this path.
There is no platform SMTP default — SMTP is tenant-only. The platform uses SendGrid as its default; SMTP is only active when a tenant has configured and verified it.
Config Structure
Tenant config (stored in notification_providers.config, encrypted)
interface SmtpConfig {
host: string; // e.g. "smtp.gmail.com", "smtp.office365.com"
port: number; // 587 (STARTTLS) | 465 (SSL/TLS) | 25 (plain, rare)
secure: boolean; // true = SSL/TLS on connect (port 465); false = STARTTLS via EHLO (port 587)
user: string; // SMTP auth username (usually the email address)
pass: string; // Password or app-specific password
fromAddress: string; // e.g. "noreply@acmeplumbing.com"
fromName: string; // e.g. "Acme Plumbing"
}Common SMTP server settings
| Mail server | host | port | secure | Notes |
|---|---|---|---|---|
| Gmail | smtp.gmail.com | 587 | false | Requires “App Password” if 2FA is on |
| Outlook / M365 | smtp.office365.com | 587 | false | Requires app password or OAuth |
| Zoho Mail | smtp.zoho.com | 587 | false | — |
| Self-hosted Postfix | custom | 587 | false | SPF/DKIM must be configured on the domain |
| Mailgun SMTP | smtp.mailgun.org | 587 | false | Alternative to Mailgun REST API |
Integration Pattern
Provider class (packages/provider-email/src/providers/smtp.ts)
import nodemailer from 'nodemailer';
class SmtpProvider implements EmailProvider {
readonly name = 'smtp';
private transporter: nodemailer.Transporter;
constructor(private cfg: SmtpConfig) {
this.transporter = nodemailer.createTransport({
host: cfg.host,
port: cfg.port,
secure: cfg.secure,
auth: {
user: cfg.user,
pass: cfg.pass,
},
});
}
async send(message: EmailMessage): Promise<EmailSendResult> {
const info = await this.transporter.sendMail({
to: message.to.map(r => `"${r.name}" <${r.email}>`).join(', '),
from: `"${this.cfg.fromName}" <${this.cfg.fromAddress}>`,
replyTo: message.replyTo
? `"${message.replyTo.name}" <${message.replyTo.email}>`
: undefined,
subject: message.subject,
html: message.html,
text: message.text,
attachments: message.attachments?.map(a => ({
filename: a.filename,
content: a.content,
contentType: a.contentType,
})),
});
return {
messageId: info.messageId,
provider: 'smtp',
};
}
async verify(): Promise<void> {
// Checks TCP connection + auth — does NOT send a real email
await this.transporter.verify();
}
}Key difference from SendGrid verify()
SmtpProvider.verify() calls nodemailer.transporter.verify() which opens a connection, authenticates, and closes — it does not send a real email. This is safer for test connections where the tenant may have strict send quotas.
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 = 'smtp' → new SmtpProvider(decrypt(row.config))
└── No row / unverified → SendGridProvider(platform default) ← SMTP is never a platform defaultTest Cases
Unit tests (packages/provider-email/src/providers/smtp.test.ts)
| Test | Approach |
|---|---|
send() calls sendMail with correct args | Mock nodemailer.createTransport; spy on sendMail; assert to, from, subject |
send() formats to as RFC 2822 string | Assert "Name" <email> format |
send() passes attachment buffers unchanged | Assert content is same Buffer passed in |
send() returns messageId from nodemailer response | Mock sendMail returns { messageId: '<test@example.com>' } |
send() throws on auth failure (SMTP 535) | Mock sendMail throws Error('Invalid login'); assert propagated |
verify() calls transporter.verify() | Spy on transporter.verify; assert called once |
verify() throws on bad host | Mock verify throws Error('ECONNREFUSED'); assert propagated |
Integration tests
| Test | Approach |
|---|---|
| Full send against local SMTP (Mailpit) | Start Mailpit in Docker; configure SMTP pointing to localhost:1025; assert message arrives in Mailpit inbox |
| Attachment arrives in Mailpit | Send with a Buffer attachment; check Mailpit message for part |
verified_at set after successful test | POST /providers, POST /providers/:id/test; assert verified_at not null |
last_error set on connection refused | Point to non-existent SMTP host; POST test; assert last_error contains connection error |
Dev testing recommendation
Use Mailpit as the local SMTP catcher. Add to docker-compose.dev.yml:
mailpit:
image: axllent/mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UISet tenant SMTP config to host: localhost, port: 1025, secure: false, user: '', pass: ''.
Related
- SendGrid Provider — platform default email; preferred for most tenants
- AWS SES Provider — alternative for AWS-native tenants
- Notification Packages —
@leadmetrics/provider-emailstructure - Notification Providers — resolution pattern and UI