All posts
    Guides

    How to Test Email in Your CI/CD Pipeline (Without a Real Inbox)

    The Devmailr Team8 min read

    Most test suites have a hole exactly where email is. Your app sends a verification email on signup, a code on login, a reset link on “forgot password” — and the end-to-end test just skips it, or someone pastes a code in by hand. This guide shows the pattern that closes that gap: a disposable inbox you create over an API, send mail to, and read back inside CI.

    Why real inboxes don’t work in CI

    The instinct is to point tests at a real mailbox — a shared Gmail, a team alias. It falls apart quickly:

    • You can’t read it programmatically without OAuth dances and app passwords.
    • Parallel CI jobs collide in the same inbox and read each other’s mail.
    • Rate limits and spam filtering delay or hide the very message you’re asserting on.
    • Storing real mailbox credentials in CI is a security liability.
    • It’s stateful — yesterday’s emails pollute today’s assertions.

    What you actually want is an inbox that is disposable, isolated per run, and readable over a plain REST API. That’s exactly what a service like Devmailr provides: a fresh prefix@devmailr.app address in one request, on a real public mail server that accepts mail from anyone.

    The pattern: create → trigger → wait → assert → clean up

    Every email test follows the same five steps. Once you have a helper for them, a test is a few lines.

    1. Create an inbox

    One POST gives you a fresh, unique address:

    bash
    BASE=https://api.devmailr.app/api
    AUTH="Authorization: Bearer $DEVMAILR_API_KEY"
    
    MAILBOX=$(curl -sf -X POST "$BASE/mailboxes" -H "$AUTH" \
      -H "Content-Type: application/json" -d '{"prefix":"ci-'"$CI_JOB_ID"'"}')
    ID=$(echo "$MAILBOX" | jq -r ._id)
    ADDRESS=$(echo "$MAILBOX" | jq -r .address)

    The response gives you the full address (e.g. ci-1234@devmailr.app) and an _idyou’ll use to read mail. Put something unique per run in the prefix (a job id) so parallel pipelines never share an inbox. Prefixes are globally unique, so always read address back from the response rather than constructing it.

    Heads up: API keys live on the Indie and Pro plans — generate one in Settings → API Keys, then store it as a CI secret (conventionally DEVMAILR_API_KEY). Never hardcode it.

    2. Trigger your app to send mail

    Point your system under test at $ADDRESS— register a user, request a reset, whatever flow you’re testing. Nothing special happens here; your app sends email exactly as it would to a real customer.

    3. Wait for the email

    This is where naive tests get flaky — they sleep 5 and hope. Instead, block until the message actually arrives:

    bash
    # Hold the connection open until a matching email lands (or 60s passes)
    EMAIL=$(curl -sf "$BASE/mailboxes/$ID/wait?from=noreply@myapp.com&has_code=true&timeout=60" -H "$AUTH")

    The /wait endpoint long-polls and returns the moment a matching email arrives. You can narrow what counts as a match with from, subject, has_code=true (only mail that carries a verification code), or even a custom match regex.

    Tip: One /waitcall replaces a polling loop of ~30 list requests — fewer moving parts, and it won’t eat into your rate limit.

    4. Assert on what arrived

    The email comes back parsed, and the useful bits are already extracted for you at receive time — no scraping HTML:

    • extractedCodes — candidate OTP / verification codes, best guess first.
    • primaryLink — the main call-to-action link (verify / confirm / reset).
    • extractedLinks — every link in the message.
    • verdicts — SPF / DKIM / DMARC / spam / virus results from the receiving server.
    • subject, from, bodyText, bodyHtml — the raw message, if you need it.
    bash
    CODE=$(echo "$EMAIL" | jq -r '.extractedCodes[0]')
    LINK=$(echo "$EMAIL" | jq -r '.primaryLink')

    5. Clean up

    Delete the inbox so it doesn’t count against your plan’s mailbox limit next run:

    bash
    curl -sf -X DELETE "$BASE/mailboxes/$ID" -H "$AUTH" > /dev/null

    A complete GitHub Actions example

    Putting it together — a step that fails the build if the signup email never arrives or carries no code:

    github actions
    - name: Verify signup email
      env:
        DEVMAILR_API_KEY: ${{ secrets.DEVMAILR_API_KEY }}
      run: |
        BASE=https://api.devmailr.app/api
        AUTH="Authorization: Bearer $DEVMAILR_API_KEY"
    
        MAILBOX=$(curl -sf -X POST "$BASE/mailboxes" -H "$AUTH" \
          -H "Content-Type: application/json" -d '{"prefix":"ci-'"$GITHUB_RUN_ID"'"}')
        ID=$(echo "$MAILBOX" | jq -r ._id)
        ADDRESS=$(echo "$MAILBOX" | jq -r .address)
    
        # ...trigger your app to send a signup email to $ADDRESS...
    
        CODE=$(curl -sf "$BASE/mailboxes/$ID/wait?has_code=true&timeout=60" -H "$AUTH" \
          | jq -r '.extractedCodes[0] // empty')
        curl -sf -X DELETE "$BASE/mailboxes/$ID" -H "$AUTH" > /dev/null
    
        [ -z "$CODE" ] && echo "No verification code arrived" && exit 1
        echo "Got code: $CODE"

    Doing it from your test runner

    The same flow works from Node, Python, or any language with an HTTP client. A tiny Node helper:

    javascript
    const BASE = 'https://api.devmailr.app/api'
    const headers = { Authorization: `Bearer ${process.env.DEVMAILR_API_KEY}` }
    
    export async function createInbox(prefix) {
      const res = await fetch(`${BASE}/mailboxes`, {
        method: 'POST',
        headers: { ...headers, 'Content-Type': 'application/json' },
        body: JSON.stringify({ prefix }),
      })
      return res.json() // { _id, address, ... }
    }
    
    export async function waitForEmail(mailboxId, query = '') {
      const res = await fetch(`${BASE}/mailboxes/${mailboxId}/wait?timeout=60&${query}`, { headers })
      if (res.status === 404) throw new Error('No matching email arrived in time')
      return res.json()
    }

    From there a test is two lines:

    javascript
    const inbox = await createInbox(`ci-${Date.now()}`)
    // ...trigger signup to inbox.address...
    const email = await waitForEmail(inbox._id, 'has_code=true')
    expect(email.extractedCodes[0]).toMatch(/^\d{6}$/)

    Where to go next

    The same five-step pattern covers most email flows. For the specifics:

    Try it in two minutes

    Create a disposable prefix@devmailr.app inbox over the API, send mail to it from your app, and read it in your tests. Free plan, no card required.