ravenhook

Python

There is no SDK to install. The API is plain HTTP, so requests and about thirty lines of helper are the whole integration. Here is what that looks like end to end.

Install

pip install requests

Set two environment variables. Get the domain once from GET /namespace:

RAVENHOOK_TOKEN=rh_your_token_here
RAVENHOOK_DOMAIN=ex4mple0ns.inbox.ravenhook.dev

The helper

Write this once and forget it. The important part is wait_for_mail: it holds the connection open server-side, so there is no polling loop and no sleep to tune.

# ravenhook.py
import os
import re
import uuid

import requests

API = "https://api.ravenhook.dev"
TOKEN = os.environ["RAVENHOOK_TOKEN"]
DOMAIN = os.environ["RAVENHOOK_DOMAIN"]

HEADERS = {"Authorization": f"Bearer {TOKEN}"}


def new_address(prefix="test"):
    """A fresh address for this run. Nothing is registered."""
    return f"{prefix}-{uuid.uuid4()}@{DOMAIN}"


def wait_for_mail(address, seconds=25):
    """Block until a message arrives, or raise if none does."""
    reply = requests.get(
        f"{API}/messages/latest",
        params={"address": address, "wait": seconds},
        headers=HEADERS,
        timeout=seconds + 10,
    )
    reply.raise_for_status()

    message = reply.json()["message"]
    if message is None:
        raise AssertionError(f"No mail reached {address} in {seconds}s")
    return message


def wait_for_code(address, pattern=r"\b\d{6}\b", seconds=25):
    """Wait for mail and pull the first code out of its body."""
    message = wait_for_mail(address, seconds)
    match = re.search(pattern, message["text_body"])
    if match is None:
        raise AssertionError(f"No code matching {pattern} in: {message['subject']}")
    return match.group()

An API test

# test_signup.py
from ravenhook import new_address, wait_for_code


def test_signup_sends_a_verification_code(client):
    inbox = new_address("signup")

    client.post("/register", json={"email": inbox, "password": "hunter2"})

    code = wait_for_code(inbox)

    response = client.post("/verify", json={"email": inbox, "code": code})
    assert response.status_code == 200

The same thing through the UI

With Playwright, the only difference is how the signup happens:

# test_signup_ui.py
from ravenhook import new_address, wait_for_code


def test_user_can_verify_their_email(page):
    inbox = new_address("signup")

    page.goto("https://yourapp.test/register")
    page.get_by_label("Email").fill(inbox)
    page.get_by_role("button", name="Sign up").click()

    code = wait_for_code(inbox)

    page.get_by_label("Verification code").fill(code)
    page.get_by_role("button", name="Verify").click()

    assert page.get_by_text("Welcome").is_visible()

Running in parallel

This is the part that does not work with a shared mailbox. Ten tests reading one address cannot tell whose code is whose — the messages are genuinely indistinguishable, so no filtering rescues it.

Because addresses cost nothing and need no setup call, each test just makes its own:

# pytest -n 8 works without any coordination between workers,
# because every test invents its own address.

def test_password_reset(page):
    inbox = new_address("reset")
    ...

def test_invite_flow(page):
    inbox = new_address("invite")
    ...

No fixtures to serialise, no lock, no cleanup between runs. Old mail expires on its own within 24 hours.

Pulling out a link instead of a code

We return the whole body rather than guessing which part you wanted, so extracting a confirmation URL is the same shape as extracting a code:

import re

message = wait_for_mail(inbox)

# the confirmation link, rather than a numeric code
url = re.search(r"https://\S+/confirm/\S+", message["text_body"]).group()
page.goto(url)

Notes

  • Set your HTTP timeout above the wait value. A 25 second wait with a 10 second client timeout fails every time, and looks like the mail never arrived.
  • wait is capped at 25 seconds. Ask for more and you get 422 rather than a silent downgrade.
  • A 429 means too many simultaneous waiting requests. Back off rather than retrying harder — see About 429.
  • html_body is sanitised, but still sender-supplied. Do not render it outside a sandbox.