Skip to Content

SMTP

Category: Notification — Email
Package: @leadmetrics/provider-emailSmtpProvider
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 serverhostportsecureNotes
Gmailsmtp.gmail.com587falseRequires “App Password” if 2FA is on
Outlook / M365smtp.office365.com587falseRequires app password or OAuth
Zoho Mailsmtp.zoho.com587false
Self-hosted Postfixcustom587falseSPF/DKIM must be configured on the domain
Mailgun SMTPsmtp.mailgun.org587falseAlternative 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 default

Test Cases

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

TestApproach
send() calls sendMail with correct argsMock nodemailer.createTransport; spy on sendMail; assert to, from, subject
send() formats to as RFC 2822 stringAssert "Name" <email> format
send() passes attachment buffers unchangedAssert content is same Buffer passed in
send() returns messageId from nodemailer responseMock 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 hostMock verify throws Error('ECONNREFUSED'); assert propagated

Integration tests

TestApproach
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 MailpitSend with a Buffer attachment; check Mailpit message for part
verified_at set after successful testPOST /providers, POST /providers/:id/test; assert verified_at not null
last_error set on connection refusedPoint 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 UI

Set tenant SMTP config to host: localhost, port: 1025, secure: false, user: '', pass: ''.


© 2026 Leadmetrics — Internal use only