A workflow that registers a user on staging, waits for the real confirmation e-mail and follows the link, in bash, with no secrets.
Here is the end-to-end check most projects wish they had: on every deploy to staging, register a brand-new user, wait for the confirmation e-mail the application really sends, follow the link inside it, and assert that the account is active. No mocked mailer, no test-only code path that skips verification.
What made this hard was the mailbox. It needs to be reachable from a CI runner, it needs to be readable without a password stored as a secret, and it must not collide when two workflows run at once. A disposable inbox with an HTTP API solves all three, so the whole job is bash, curl and jq.
The workflow
name: e2e-signup
on:
workflow_run:
workflows: [deploy-staging]
types: [completed]
jobs:
signup:
runs-on: ubuntu-latest
steps:
- name: Invent an address
run: echo "BOX=ci-${GITHUB_RUN_ID}-${RANDOM}@grabmail.io" >> "$GITHUB_ENV"
- name: Register on staging
run: |
curl -sf -X POST https://staging.example.com/api/register \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$BOX\",\"password\":\"Ci-${RANDOM}-pass!\"}"
- name: Wait for the e-mail
run: |
for i in $(seq 1 30); do
LIST=$(curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$BOX")
ID=$(echo "$LIST" | jq -r '.messages[0].id // empty')
[ -n "$ID" ] && { echo "MSG_ID=$ID" >> "$GITHUB_ENV"; exit 0; }
sleep 2
done
echo 'no verification e-mail within 60 s' >&2; exit 1
- name: Follow the link
run: |
MSG=$(curl -sG "https://grabmail.io/api/v1/message/$MSG_ID" --data-urlencode "mailbox=$BOX")
LINK=$(echo "$MSG" | jq -r .text | grep -oE 'https://staging\.example\.com/verify[^ >"]*' | head -1)
test -n "$LINK"
curl -sf "$LINK" > /dev/null
- name: Assert the account is active
run: |
STATE=$(curl -sf "https://staging.example.com/api/users/by-email?email=$BOX" | jq -r .state)
test "$STATE" = active
Why it holds up
No secrets. The public domains on GrabMail need no key, so the workflow has nothing to leak and nothing to rotate. The only thing that identifies the mailbox is the address, and the address is unguessable because it carries the run id and a random number.
No collisions. Two workflows running at the same time invent two different addresses. There is no shared account and no shared state.
No cleanup. Messages are deleted after five days by the service, so the job never has to delete anything.
Honest failures. If the mail does not arrive in a minute, the job fails with a sentence that says so. That is the bug you want to catch: a mailer misconfigured on staging is exactly what this test exists to notice.
Details worth knowing
The mailbox endpoint is rate-limited to one request per second per address, and answers 429 with a Retry-After header if you go faster. Two seconds between polls is a comfortable margin. The API reference lists the response fields; the preview in the list response is often enough to spot a six-digit code without fetching the full message.
If the application refuses the address because it sits on a disposable-mail blocklist, the fix is not a different local part. Point a domain you own at the service instead: one MX record makes every address on it a catch-all inbox, and it is not on anybody's list.
The full GitHub Actions guide covers the variants: extracting a one-time code instead of a link, retrying across a re-deploy, and reporting the e-mail body as a job artifact when the assertion fails.
Top comments (0)