DEV Community

Christian Potvin
Christian Potvin

Posted on

Per-Test Email Inbox Isolation with Pytest and MinuteMail

TL;DR: Replace your shared test email account with a per-test disposable inbox. One Pytest fixture, zero infrastructure, works in parallel CI.


The Problem

You have a Pytest test that exercises a real sign-up or notification flow:

def test_registration_sends_welcome_email():
    # 1. Register a user
    # 2. Check the inbox for a welcome email
    # 3. Assert subject line and content
Enter fullscreen mode Exit fullscreen mode

You're using a shared test@yourcompany.com inbox. It works in isolation but breaks the moment you run tests in parallel — multiple workers fight over the same inbox. One test reads the email meant for another. A stale email from the previous run triggers a false pass.

The fix is not "add a longer sleep." The fix is inbox isolation.


Why Shared Inboxes Break Parallel Tests

When two tests share the same email address:

  1. Test A registers user_a@shared-inbox.com
  2. Test B registers user_b@shared-inbox.com (same address, different user object)
  3. Test A polls the inbox and reads Test B's email
  4. Test A asserts on the wrong user's welcome message → false pass or false fail

This gets worse at scale. 8 workers, 8 tests, 1 inbox = chaos.


The Solution: Per-Test Disposable Inboxes

Create a fresh inbox before each test. Give that test's unique address to the sign-up form. Poll only that inbox. Let it expire when done.


Implementation with MinuteMail

MinuteMail has a REST API for creating short-lived mailboxes and reading their contents. There's also an open-source Python SDK.

Install the SDK:

pip install minutemail
Enter fullscreen mode Exit fullscreen mode

Set your API key:

export MINUTEMAIL_API_KEY=your_key_here
Enter fullscreen mode Exit fullscreen mode

The Fixture

# conftest.py

import pytest
import os
import time
import requests

API_KEY = os.environ["MINUTEMAIL_API_KEY"]
BASE_URL = "https://api.minutemail.co/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}


@pytest.fixture
def isolated_inbox():
    """Creates a fresh MinuteMail inbox for each test. Expires automatically."""
    response = requests.post(
        f"{BASE_URL}/mailboxes",
        headers=HEADERS,
        json={"domain": "minutemail.cc", "expiresIn": 10},  # 10-minute TTL — more than enough for a test
    )
    response.raise_for_status()
    mailbox = response.json()

    yield mailbox  # provide { "id": ..., "address": ..., "expiresAt": ... }

    # Inbox expires on its own — no teardown needed


def wait_for_email(mailbox_id: str, timeout: int = 30) -> dict:
    """Poll the inbox until an email arrives or timeout is reached."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        response = requests.get(
            f"{BASE_URL}/mailboxes/{mailbox_id}/mails",
            headers=HEADERS,
        )
        response.raise_for_status()
        messages = response.json().get("items", [])
        if messages:
            return items[0]
        time.sleep(2)
    raise TimeoutError(f"No email received in mailbox {mailbox_id} within {timeout}s")
Enter fullscreen mode Exit fullscreen mode

The Test

# test_registration.py

import re


def test_welcome_email_sent_after_registration(client, isolated_inbox):
    # isolated_inbox["address"] is a unique address for this test only
    email_address = isolated_inbox["address"]

    response = client.post("/register", json={
        "email": email_address,
        "password": "SecurePass123!"
    })
    assert response.status_code == 201

    # Poll for the welcome email
    email = wait_for_email(isolated_inbox["id"], timeout=30)

    assert "Welcome" in email["subject"]
    assert email_address in email["body"]


def test_password_reset_email_contains_link(client, isolated_inbox):
    # Each test gets its own inbox — no interference from other tests
    email_address = isolated_inbox["address"]

    # Create and then request reset for this user
    client.post("/register", json={"email": email_address, "password": "OldPass123!"})
    client.post("/reset-password", json={"email": email_address})

    email = wait_for_email(isolated_inbox["id"], timeout=30)

    # Extract reset link
    link = re.search(r'https?://\S+reset\S+', email["body"])
    assert link is not None, "Reset link not found in email body"
Enter fullscreen mode Exit fullscreen mode

Running in Parallel

pip install pytest-xdist
pytest -n 8  # 8 parallel workers, each with its own inbox
Enter fullscreen mode Exit fullscreen mode

Because each worker gets its own isolated_inbox fixture instance with a unique address, there is zero shared state between workers.


Using the Python SDK (Alternative)

If you'd rather use the official SDK than raw requests:

# conftest.py (SDK version)

import pytest
from minutemail import MinuteMail

client = MinuteMail(api_key=os.environ["MINUTEMAIL_API_KEY"])


@pytest.fixture
def isolated_inbox():
    mailbox = client.create_mailbox(ttl=10)
    yield mailbox


def wait_for_email(mailbox, timeout=30):
    import time
    deadline = time.time() + timeout
    while time.time() < deadline:
        messages = mailbox.get_messages()
        if messages:
            return items[0]
        time.sleep(2)
    raise TimeoutError("No email received")
Enter fullscreen mode Exit fullscreen mode

SDK source: https://github.com/minutemailco/minutemail-python


CI/CD Integration

# .github/workflows/test.yml
- name: Run tests
  env:
    MINUTEMAIL_API_KEY: ${{ secrets.MINUTEMAIL_API_KEY }}
  run: pytest -n 4 --tb=short
Enter fullscreen mode Exit fullscreen mode

Free tier: 100 API calls/day. Each test uses 2–5 calls (create + poll attempts). For larger CI pipelines with many test workers, the Pro plan gives 10,000 calls/day.


Handling Slow Email Providers

Some email providers (particularly during cold starts or heavy load) can take 10–30 seconds. Tune the timeout and poll interval:

def wait_for_email(mailbox_id, timeout=60, poll_interval=3):
    deadline = time.time() + timeout
    while time.time() < deadline:
        messages = requests.get(
            f"{BASE_URL}/mailboxes/{mailbox_id}/mails",
            headers=HEADERS
        ).json().get("items", [])
        if messages:
            return items[0]
        time.sleep(poll_interval)
    raise TimeoutError(...)
Enter fullscreen mode Exit fullscreen mode

Summary

  • Shared inboxes cause non-deterministic failures in parallel test suites
  • Per-test inbox isolation eliminates the shared-state problem entirely
  • A pytest.fixture wrapping a disposable inbox API makes this a one-liner in each test
  • The MinuteMail API + Python SDK handles the mailbox lifecycle; tests just yield and move on

Tags: #testing #python #pytest #automation

Created: 2026-02-26

Top comments (0)