All posts
    Tutorials

    How to Test Email in Python with pytest

    The Devmailr Team7 min read

    Whether you’re testing a Django signup, a FastAPI password reset, or any flow that sends mail, your pytest suite needs a way to read what your app sent. Here’s how to test email in Python with a disposable inbox — a small client, a fixture, and assertions on the code or link.

    A tiny client

    devmailr.py
    import os, requests
    
    BASE = "https://api.devmailr.app/api"
    AUTH = {"Authorization": f"Bearer {os.environ['DEVMAILR_API_KEY']}"}
    
    def create_inbox(prefix):
        return requests.post(f"{BASE}/mailboxes", headers=AUTH, json={"prefix": prefix}).json()
    
    def wait_for_email(mailbox_id, **params):
        params.setdefault("timeout", 60)
        # long-poll: give requests a read timeout above the server wait
        r = requests.get(f"{BASE}/mailboxes/{mailbox_id}/wait", headers=AUTH,
                         params=params, timeout=params["timeout"] + 5)
        if r.status_code == 404:
            raise AssertionError("No matching email arrived in time")
        r.raise_for_status()
        return r.json()
    
    def delete_inbox(mailbox_id):
        requests.delete(f"{BASE}/mailboxes/{mailbox_id}", headers=AUTH)

    A pytest fixture

    A fixture gives each test a fresh inbox and cleans it up afterward:

    conftest.py
    import time, pytest
    from devmailr import create_inbox, delete_inbox
    
    @pytest.fixture
    def inbox():
        box = create_inbox(f"pt-{int(time.time() * 1000)}")
        yield box
        delete_inbox(box["_id"])

    The test

    test_signup.py
    from devmailr import wait_for_email
    
    def test_signup_sends_verification_code(inbox, client):
        # 1. Trigger your app to send a verification email
        client.post("/signup", json={"email": inbox["address"], "password": "s3cret-pw"})
    
        # 2. Block until it arrives and pull the code
        email = wait_for_email(inbox["_id"], has_code="true")
        code = email["extractedCodes"][0]
        assert code.isdigit()
    
        # 3. Finish the flow
        resp = client.post("/verify", json={"email": inbox["address"], "code": code})
        assert resp.status_code == 200

    (client here is your app’s test client — Django, Flask, or FastAPI.)

    Custom tokens and links

    If the code isn’t a plain number, pass your own regex with match and read matches; for magic links, use primaryLink:

    python
    # extract a custom token from "Your code: AB12-CD34"
    email = wait_for_email(inbox["_id"], match=r"code: ([A-Z0-9-]+)")
    token = email["matches"][0]
    
    # follow a magic link
    email = wait_for_email(inbox["_id"], subject="Confirm your email")
    link = email["primaryLink"]

    Tips

    • /wait long-polls, so the requests read timeout must exceed it (the client adds 5s).
    • Unique prefix per test (millisecond timestamp) so pytest-xdist parallel workers don’t share mail.
    • Reusing across steps? Pass received_after=<ms> to skip stale mail.
    • Set DEVMAILR_API_KEY as a CI secret.

    Related reading

    Try it in two minutes

    Create a disposable prefix@devmailr.app inbox over the API, send mail to it from your app, and read it in your tests. Free plan, no card required.