Testing & CI

GitHub Actions: an end-to-end job that reads real email

The sign-up test that reads its confirmation code from a mailbox works on a laptop and then meets a CI runner: no secret to mount, an egress question, a deadline that has to fit the job, and a mailbox that must be fresh for every run and every retry. Here is the workflow, for Playwright and for pytest, with the parts that only matter on a runner.

  • Intermediate
  • 15 min read
Three grey gears driving a belt that carries a blue envelope toward a grey gate topped with a blue lamp

What changes on a runner

The test itself does not change. What changes is everything around it, and each of these has a specific answer rather than a shrug:

There is no secret to mount
Reading a public mailbox needs no key, no account and no header, so the mailbox side adds nothing to secrets. The only credential in the job is the one your application already needs to send mail — SendGrid, Postmark, SES, whichever — and it belongs to your app, not to the test.
The runner has to reach the internet
Outbound HTTPS to grabmail.io, and outbound to whatever your mailer uses. GitHub-hosted runners allow both by default; a self-hosted runner behind an egress filter needs one rule added.
Runs overlap
Two pull requests, four shards, a retry of a flaky job — several copies of the same test read mail at the same time. A shared address would let them read each other’s codes; a fresh address per test makes the whole class of problem impossible.
Time is metered
A test that waits sixty seconds for mail is fine. A job that waits sixty seconds in every one of forty tests is forty minutes of billed runner. The waits have to be bounded, and the suite has to be sharded once it grows.

The test code these workflows run is the one from the Playwright guide or the Python guide: a fresh address, a wait with a deadline, an extractor anchored on your template. Nothing in it is CI-specific, which is the point — the runner-specific parts all live in the workflow file.

The workflow, for Playwright

One job. It starts your application with a real outbound mailer, waits for it to answer, runs the suite, and keeps the report only when something failed.

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20                 # the whole job, comfortably above every wait inside it

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      # Your application, started the way it runs in staging: a REAL outbound
      # mailer. Its credentials are YOUR secret; the mailbox side needs none.
      - name: Start the application
        run: npm run start:test &
        env:
          MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
          APP_URL: http://localhost:3000

      - name: Wait for the application
        run: npx wait-on --timeout 60000 http://localhost:3000/health

      - name: Run the suite
        run: npx playwright test
        env:
          BASE_URL: http://localhost:3000

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Three lines carry the weight. timeout-minutes: 20 is the outer bound everything else nests inside. The application is started with its real mailer credentials, because a test that reads real mail needs real mail to be sent. And the report is uploaded only on failure, with a short retention — a passing run has nothing worth keeping.

The workflow, for pytest

The same shape with the Python toolchain: start the application, wait for its health endpoint, run the suite with a per-test timeout above the mail deadline, keep the JUnit report on failure.

.github/workflows/e2e.yml
name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - run: pip install -r requirements.txt -r requirements-test.txt

      - name: Start the application
        run: python -m app.server &
        env:
          MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
          APP_URL: http://localhost:8000

      - name: Wait for the application
        run: |
          for i in $(seq 1 60); do
            curl -sf http://localhost:8000/health && exit 0
            sleep 1
          done
          echo "application did not come up" >&2; exit 1

      - name: Run the suite
        run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
        env:
          BASE_URL: http://localhost:8000

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: pytest-report
          path: report.xml

--timeout=120 comes from the pytest-timeout plugin and is the per-test ceiling; the mail deadline inside the helper is sixty seconds, so a test that waits for one message and then does some browser work still fits. The health-check loop is written out rather than pulled in as an action because it is eight lines and there is nothing to get wrong in them.

The one network rule

Reading a mailbox is an outbound HTTPS request from the runner to grabmail.io. That is the entire network footprint:

  • No inbound. Nothing connects to the runner. There is no webhook to receive, no SMTP server to run, no port to expose.
  • No SMTP from the runner. The mail is sent by your application through its provider, over that provider’s API or SMTP endpoint — the same way it does in production. The runner never speaks SMTP itself.
  • Only grabmail.io:443 to add on a runner with an egress allow-list — plus your mail provider and your package registry, which the job needed already.
on a hardened runner, allow exactly that
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            grabmail.io:443
            api.your-mail-provider.example:443
            registry.npmjs.org:443

If the suite passes locally and fails in CI with a connection error from the helper, this rule is the first thing to check, and it is almost always the whole answer. A self-hosted runner in a corporate network will typically have HTTPS egress filtered by hostname; the hostname to allow is the API’s, and the request is plain HTTPS on 443.

Shards, matrix jobs and retries

Once the suite is slow enough to shard, shard it. Playwright splits a run across jobs with --shard, and because every test opens its own mailbox, the shards need nothing from each other:

four shards, each a separate job
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # ... the same steps as above, then:
      - run: npx playwright test --shard=${{ matrix.shard }}/4

The same property covers the other two ways a test ends up running twice at once:

Two pull requests at the same time
Two jobs, two sets of random addresses, no overlap. The per-client ceiling on the API is 1200 requests a minute, which is twenty mailboxes polled once a second — a job polling one mailbox at a time is nowhere near it.
A retried job
A retry runs the test body again, which invents a new address again. The old mailbox still holds the old message for 5 days, and nothing reads it — the retry never sees it.
Playwright’s own retries
Same thing, one level down: each attempt runs the fixture again. Do not move the address into a beforeAll to save time; that is exactly the sharing that lets an attempt read the previous attempt’s code.

Timeouts that fit inside the job

There are four clocks and they have to nest, innermost shortest. When they do not, the failure is reported by the wrong one and points at the wrong cause.

ClockSet whereA sane value
Mail deadlineInside the helper (timeoutMs, timeout=)60 s. Transactional mail lands in seconds; a minute covers a slow provider queue.
Test timeoutplaywright.config.ts / --timeout120 s. Above the deadline plus the browser work around it.
Step timeouttimeout-minutes on the step, if anyUsually unset; the job bound is enough.
Job timeouttimeout-minutes on the job20 min. Enough for install, start, the suite and the upload; low enough that a hung app does not bill an hour.

The symptom of a broken nesting is specific: a test that waits sixty seconds inside Playwright’s default thirty-second test timeout dies at thirty seconds with a message about the test, every time, and says nothing about the mail. Set the test timeout first, then everything outward.

Reading a failure

A failed run should tell you which of three things happened — the mail never came, the wrong mail came, or the code in it was wrong — without re-running anything. Four habits make that true:

  1. Log the address. The helper’s failure message names the mailbox it waited on. Print it once more at the top of the test so it is in the job log even when the assertion is somewhere else.
  2. Open the mailbox by hand. Messages stay for 5 days, so /inbox/<address> on this site shows exactly what the runner saw — or did not — for the rest of the week. This is the single most useful thing about a real mailbox over a mocked one.
  3. Keep the report on failure. Playwright’s trace shows the click that should have sent the mail; the JUnit file shows which test and how long it waited.
  4. Check the service status before blaming the test. The status page is probed from outside every two minutes; if inbound mail was down at the time of the run, the failure was real and not yours.

Cleaning up, optionally

Everything expires after 5 days whether or not anyone deletes it, so a run that skips cleanup costs nothing. Deleting what the run read is still worth a step, because the next failure is then read against a genuinely empty mailbox. It is idempotent — deleting twice still answers 200 — so it can never fail a build on its own:

an always-run cleanup step
      - name: Delete what the run read
        if: always()
        run: |
          for addr in $(cat .e2e-addresses 2>/dev/null); do
            curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
            | jq -r '.messages[].id' \
            | xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
          done

Make it if: always() and never make it required: a cleanup that fails should be a warning in a log, not a red build.

Before you call it done

  • No mailbox credential in secrets; only your application’s own mailer key.
  • Outbound HTTPS to grabmail.io allowed, and nothing inbound.
  • A fresh address per test, invented in the test body — safe under shards and retries.
  • The four timeouts nested: deadline < test < step < job.
  • The report uploaded on failure, with the address in the log.
  • Cleanup as an always-run, never-required step.

That is all the runner adds. The test discipline underneath — deadline, fresh address, anchored pattern — is in testing a verification flow end to end, and the extraction rules on their own are in OTP codes in automated tests.

Questions

Do I need to add a GrabMail secret to the repository?

No. The public domains take no key, no account and no header, so there is nothing to add to secrets. The only credential in the workflow is the one your application uses to send mail, which it would need to run at all.

Does this work in a private repository or on a self-hosted runner?

Yes. The runner makes outbound HTTPS requests to grabmail.io and nothing else; where you host the runner is irrelevant. On a self-hosted runner behind an egress filter, allow that hostname on port 443.

Will concurrent jobs hit the rate limit?

Not in practice. The per-address limit is one read a second, which the helper respects, and the per-client ceiling is 1200 requests a minute — twenty mailboxes polled once a second, from one runner. Several runners are several clients. A 429 is answered with Retry-After, and the helper sleeps it out rather than failing.

Can I run this on a schedule, as a synthetic check of production sign-up?

Yes, and it is a good use of it: an on: schedule workflow that signs up with a fresh address every hour and reads the code proves the whole production mail path, provider included. Keep the address prefix recognisable so the sign-ups are easy to purge on your side.

What if my application refuses disposable domains?

Point a domain you own at the service — one MX record, no account — and use that domain in the fixture. The setup takes a few minutes and unlimited test accounts on one domain shows the pattern in a suite.

Is anything in the mailbox private?

No. Anyone who knows an address can read it, on a public domain and on your own. For a random address holding one throwaway code that is irrelevant; for a staging environment that sends real customer data it is disqualifying — do not point one here.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.