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 requestsSet two environment variables. Get the domain once from GET /namespace:
RAVENHOOK_TOKEN=rh_your_token_here
RAVENHOOK_DOMAIN=ex4mple0ns.inbox.ravenhook.devThe 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 == 200The 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
waitvalue. A 25 second wait with a 10 second client timeout fails every time, and looks like the mail never arrived. waitis capped at 25 seconds. Ask for more and you get422rather than a silent downgrade.- A
429means too many simultaneous waiting requests. Back off rather than retrying harder — see About 429. html_bodyis sanitised, but still sender-supplied. Do not render it outside a sandbox.