Testing Email in CI with GitHub Actions
Your code sends over plain SMTP, your test reads the result over the API. send.smtp.dev delivers only to accounts in your sandbox, so test emails can't reach anyone real.
One-Time Setup
Create a sender and a receiver account
On the Accounts page or via the API - e.g. ci@example.com and user@example.com.
Create an API token
On the API Keys page. The token is shown once.
Store the secrets in your repository
SMTP_DEV_API_KEY (the token) and SMTP_DEV_PASSWORD (the sender account's password) under Settings → Secrets and variables → Actions.
The Test
Point your app's SMTP transport at the sandbox. With nodemailer:
import nodemailer from 'nodemailer'
const transport = nodemailer.createTransport({
host: 'send.smtp.dev',
port: 587,
requireTLS: true,
auth: { user: 'ci@example.com', pass: process.env.SMTP_DEV_PASSWORD },
})
Then assert on what arrived:
import assert from 'node:assert'
import { test } from 'node:test'
const API = 'https://api.smtp.dev'
const headers = { 'X-API-KEY': process.env.SMTP_DEV_API_KEY }
async function api(path) {
const res = await fetch(API + path, { headers })
assert.ok(res.ok, `${path} returned ${res.status}`)
return res.json()
}
async function inboxOf(address) {
const { member } = await api(`/accounts?address=${encodeURIComponent(address)}`)
const account = member.find(a => a.address === address)
const { id, mailboxes } = await api(`/accounts/${account.id}`)
return { accountId: id, mailboxId: mailboxes.find(m => m.path === 'INBOX').id }
}
async function waitForMessage({ accountId, mailboxId }, match, timeout = 30_000) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const { member } = await api(`/accounts/${accountId}/mailboxes/${mailboxId}/messages`)
const found = member.find(match)
if (found)
return api(`/accounts/${accountId}/mailboxes/${mailboxId}/messages/${found.id}`)
await new Promise(resolve => setTimeout(resolve, 2000))
}
throw new Error(`no matching message after ${timeout}ms`)
}
test('welcome email', async () => {
const subject = `Welcome ${Date.now()}`
await transport.sendMail({
from: 'ci@example.com',
to: 'user@example.com',
subject,
text: 'Confirm your address: https://example.com/confirm/abc123',
})
const inbox = await inboxOf('user@example.com')
const message = await waitForMessage(inbox, m => m.subject === subject)
assert.equal(message.from.address, 'ci@example.com')
assert.match(message.text, /\/confirm\//)
})
Put a unique value in the subject (a timestamp, the commit SHA). Parallel runs share the inbox, and List Messages returns 30 messages per page, newest first.
The Workflow
name: email-tests
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npm test
env:
SMTP_DEV_API_KEY: ${{ secrets.SMTP_DEV_API_KEY }}
SMTP_DEV_PASSWORD: ${{ secrets.SMTP_DEV_PASSWORD }}
Nothing here is GitHub-specific. Jenkins, GitLab CI, CircleCI: same two env vars, same SMTP host, same API.