Testing Email in CI with GitHub Actions

Send from your test suite through send.smtp.dev, poll the REST API for the message, and assert on subject and body. Works on any CI.

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.

Messages stay for your plan's retention window, or until you delete them. Unique subjects keep old runs out of the way; delete messages after each test if you'd rather keep inboxes empty.