---
title: "Testing Email in CI with GitHub Actions"
description: "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."
canonical_url: "https://smtp.dev/docs/guides/ci/"
last_updated: "2026-07-16T01:19:48.018Z"
---

Your code sends over plain SMTP, your test reads the result over the [API](/docs/api). `send.smtp.dev` delivers only to accounts in your sandbox, so test emails can't reach anyone real.

## One-Time Setup

<steps level="3">

### Create a sender and a receiver account

On the [Accounts](/accounts) page or via the [API](/docs/api#create-an-account) - e.g. `ci@example.com` and `user@example.com`.

### Create an API token

On the [API Keys](/tokens) 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.

</steps>

## The Test

Point your app's SMTP transport at the sandbox. With nodemailer:

```js
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:

```js
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](/docs/api#list-messages) returns 30 messages per page, newest first.

## The Workflow

```yaml
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.

<note>

Messages stay for your plan's retention window, or [until you delete them](/docs/features/message#message-expiry). Unique subjects keep old runs out of the way; [delete messages](/docs/api#delete-a-message) after each test if you'd rather keep inboxes empty.

</note>
