Somewhere in your CI/CD pipeline, there's a hardcoded email address. Maybe it's [email protected] or [email protected]. It's been there since the first engineer wrote the signup test, and nobody's touched it since — because it kind of works. Until it doesn't. This article explains why that's a ticking time bomb and shows you how to fix it with disposable email inboxes that take 5 minutes to set up.
The Three Problems With Real Emails in CI/CD
1. Shared Inbox Pollution
When every CI job sends verification emails to the same address, your "test inbox" becomes a swamp of hundreds of unread messages. Finding the right email for the right test run becomes a race condition. Test A reads the verification email meant for Test B, and both fail.
Real-world example: A team at a fintech startup had 47 flaky test failures per week traced to email collisions in a shared QA mailbox. Switching to per-test disposable inboxes dropped flaky failures to zero.
2. Flaky Tests From Timing Issues
IMAP/POP3 polling against Gmail or Outlook is unpredictable. Sometimes emails arrive in 2 seconds; sometimes it's 30. Google throttles automated logins, Outlook blocks "suspicious" connections, and corporate email servers add arbitrary delays. Your CI pipeline becomes a coin flip.
3. Security Risk
That shared test email? Its password is in a CI secret, an env file, or worse — in the codebase. It's a real email account with a real password. If it's compromised, the attacker gets access to every verification email, password reset link, and OTP code your staging app has ever sent. Some of those links might still work.
With OpenInbox, there are no credentials to leak. You use an API key scoped to inbox creation — it can't read your company's real email, access other services, or be used for phishing.
The Fix: One Disposable Inbox Per Test Run
The pattern is simple: each test creates its own inbox via the OpenInbox API, uses it for whatever email action it needs, and the inbox auto-deletes after one hour. No shared state. No cleanup scripts. No IMAP credentials.
Isolation
Every CI run gets fresh inboxes. No cross-run contamination.
Speed
Pure HTTPS API calls — no IMAP, no SMTP ports to open.
Security
Only an API key is stored. Inboxes auto-expire. Zero data retention.
Setting Up OpenInbox in GitHub Actions
1. Store your API key
Go to your repository → Settings → Secrets and variables → Actions → New repository secret. Name it OPENINBOX_API_KEY and paste your key. Get one free at /for-developers.
2. Create the workflow file
Here's a complete GitHub Actions workflow that runs email tests against a staging environment:
name: Email Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
email-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Wait for staging deployment
run: |
echo "Waiting for staging to be ready..."
for i in {1..30}; do
if curl -sf https://staging.yourapp.com/health; then
echo "Staging is up!"
break
fi
sleep 5
done
- name: Run email integration tests
env:
OPENINBOX_API_KEY: ${{ secrets.OPENINBOX_API_KEY }}
STAGING_URL: https://staging.yourapp.com
run: npx jest test/integration/email/ --runInBand --verbose
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: email-test-results
path: test-results/The Test Script
Here's the test file that the workflow runs. It covers the three most common email flows: signup verification, password reset, and OTP login:
const API = 'https://openinbox.io/api/v1';
const KEY = process.env.OPENINBOX_API_KEY!;
const APP = process.env.STAGING_URL!;
const headers = { 'X-API-Key': KEY };
async function createInbox() {
const res = await fetch(`${API}/inboxes`, { method: 'POST', headers });
const json = await res.json();
return json.data as { id: string; email: string };
}
async function pollEmails(inboxId: string, timeout = 30_000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await fetch(`${API}/inboxes/${inboxId}/emails`, { headers });
const json = await res.json();
if (json.data.length > 0) return json.data;
await new Promise((r) => setTimeout(r, 2_000));
}
throw new Error('Timeout waiting for emails');
}
async function getEmailBody(emailId: string) {
const res = await fetch(`${API}/emails/${emailId}`, { headers });
const json = await res.json();
return json.data;
}
// ─── Tests ─────────────────────────────────────────────
jest.setTimeout(60_000);
describe('Email flows (CI)', () => {
test('signup → verification email → confirm', async () => {
const inbox = await createInbox();
// Trigger signup
const res = await fetch(`${APP}/api/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: inbox.email,
password: 'CiTest123!',
}),
});
expect(res.status).toBe(201);
// Wait for verification email
const emails = await pollEmails(inbox.id);
expect(emails[0].subject.toLowerCase()).toContain('verify');
// Extract verification link
const full = await getEmailBody(emails[0].id);
const link = full.textBody.match(/https?:\/\/[^\s]+verify[^\s]*/i);
expect(link).not.toBeNull();
// Confirm the account
const confirm = await fetch(link![0], { redirect: 'follow' });
expect(confirm.ok).toBe(true);
});
test('password reset → email → new password', async () => {
const inbox = await createInbox();
// Request password reset
await fetch(`${APP}/api/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox.email }),
});
// Wait for reset email
const emails = await pollEmails(inbox.id);
const full = await getEmailBody(emails[0].id);
const resetLink = full.textBody.match(
/https?:\/\/[^\s]*reset[^\s]*/i,
);
expect(resetLink).not.toBeNull();
});
test('OTP login → email → extract code', async () => {
const inbox = await createInbox();
// Request OTP
await fetch(`${APP}/api/send-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox.email }),
});
// Wait for OTP email
const emails = await pollEmails(inbox.id);
const full = await getEmailBody(emails[0].id);
// Extract OTP code (4-8 digits)
const otp = full.textBody.match(/\b(\d{4,8})\b/);
expect(otp).not.toBeNull();
expect(otp![1].length).toBeGreaterThanOrEqual(4);
});
});Before vs. After: The Difference Is Night and Day
| Aspect | Real Email (Before) | OpenInbox (After) |
|---|---|---|
| Test isolation | Shared inbox — tests collide | Unique inbox per test run |
| CI setup | IMAP credentials + port 993 | One API key, HTTPS only |
| Flaky failures | 15–40% from timing & collisions | < 1% (HTTP polling is deterministic) |
| Cleanup | Manual inbox purge scripts | Auto-expire after 1 hour |
| Security risk | Real password in CI secrets | Scoped API key, no mailbox access |
| Parallel test runs | Email collisions | Fully isolated — scales to 50+ workers |
Advanced Patterns
Matrix builds with multiple email flows
Use GitHub Actions matrix strategy to test different email flows in parallel. Each job gets its own inbox, so there are no collisions:
jobs:
email-tests:
runs-on: ubuntu-latest
strategy:
matrix:
test-suite:
- test/integration/email/signup.test.ts
- test/integration/email/password-reset.test.ts
- test/integration/email/otp-login.test.ts
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run ${{ matrix.test-suite }}
env:
OPENINBOX_API_KEY: ${{ secrets.OPENINBOX_API_KEY }}
run: npx jest ${{ matrix.test-suite }} --runInBandReusable workflow for multiple repos
If your organization has multiple services that send emails, extract the email test workflow into a reusable workflow and call it from each repo. The only input is the staging URL and the test path. The API key is shared via organization-level secrets.
GitLab CI / Jenkins
The API is pure HTTPS — no special ports or protocols. It works identically from GitLab CI, Jenkins, CircleCI, Buildkite, or any runner that can make HTTP requests. Here's the GitLab equivalent:
email-tests:
stage: test
image: node:20
variables:
OPENINBOX_API_KEY: $OPENINBOX_API_KEY
script:
- npm ci
- npx jest test/integration/email/ --runInBand --verbose
artifacts:
when: always
paths:
- test-results/Troubleshooting Common Issues
Email never arrives
Double-check that your staging app's SMTP is correctly configured and the domain isn't on a blocklist. Create the inbox, send a test email manually via cURL, and verify it shows up. If the API returns the email but your test doesn't see it, your polling timeout may be too short.
Rate limit errors (429)
Free tier has limited daily requests. For CI pipelines that run frequently, upgrade to Pro (3,000 requests/day) or Business (10,000 requests/day). See pricing at /pricing.
Tests pass locally but fail in CI
CI runners are often slower and have higher network latency. Increase your poll timeout to 45–60 seconds. Also ensure the OPENINBOX_API_KEY secret is correctly set in your CI provider.
Multiple emails in inbox
If your app sends a welcome email AND a verification email, filter by subject. Use emails.find(e => e.subject.includes("verify")) instead of emails[0].
Migration Checklist
Ready to swap out the shared Gmail account? Here's the step-by-step:
- 1Sign up at openinbox.io/for-developers and generate an API key
- 2Add the key as a secret in your CI provider (OPENINBOX_API_KEY)
- 3Create a helper module wrapping the 3 API calls (create inbox, poll emails, get email body)
- 4Replace hardcoded email addresses in your test files with createInbox() calls
- 5Update your CI workflow to inject the API key as an env variable
- 6Remove the old test Gmail/Outlook credentials from your secrets
- 7Run the pipeline and verify all email tests pass
- 8Delete the shared test email account entirely
Fix your CI email tests in 5 minutes
No IMAP credentials, no shared mailboxes, no flaky tests. Just a clean API key and disposable inboxes that work everywhere.