🎉 Limited Time Offer: 30% OFF First Month!

Use code at checkout - Valid on all plans

View Plans

🌐 Custom Domains Now Available!

Receive emails at [email protected] — available on all paid plans

Learn More
Back to Blog
DevOps13 min read

Stop Using Your Real Email in CI/CD — Here's the Fix

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:

.github/workflows/email-tests.yml
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:

test/integration/email/flows.test.ts
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

AspectReal Email (Before)OpenInbox (After)
Test isolationShared inbox — tests collideUnique inbox per test run
CI setupIMAP credentials + port 993One API key, HTTPS only
Flaky failures15–40% from timing & collisions< 1% (HTTP polling is deterministic)
CleanupManual inbox purge scriptsAuto-expire after 1 hour
Security riskReal password in CI secretsScoped API key, no mailbox access
Parallel test runsEmail collisionsFully 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:

.github/workflows/matrix.yml (excerpt)
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 }} --runInBand

Reusable 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:

.gitlab-ci.yml (excerpt)
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:

  1. 1Sign up at openinbox.io/for-developers and generate an API key
  2. 2Add the key as a secret in your CI provider (OPENINBOX_API_KEY)
  3. 3Create a helper module wrapping the 3 API calls (create inbox, poll emails, get email body)
  4. 4Replace hardcoded email addresses in your test files with createInbox() calls
  5. 5Update your CI workflow to inject the API key as an env variable
  6. 6Remove the old test Gmail/Outlook credentials from your secrets
  7. 7Run the pipeline and verify all email tests pass
  8. 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.