Skip to content

Python (pytest, Selenium, Playwright)

Copy this helper into your test folder. It uses only the standard library, so there’s nothing to install, and reads the API key from PROXYMAIL_QA_API_KEY:

proxymail_qa.py
"""Disposable inboxes for Python tests (pytest with Selenium or Playwright):
create a test address, sign up with it in your app, then wait for the code or
link from the email it gets. Guide: https://proxymail.qa/docs/python
The API key comes from the test run's environment (PROXYMAIL_QA_API_KEY).
When PROXYMAIL_QA_RUN is set (start_run, or the GitHub Action), every call
adds what it saw to that run. Standard library only; Python 3.9 or later.
"""
import contextlib
import json
import math
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
BASE_URL = os.environ.get("PROXYMAIL_QA_API_URL", "https://api.proxymail.qa/v1")
class ProxyMailQAError(Exception):
"""An API error. When a wait finds nothing, hint says why (an email
provider in sandbox mode, say), and the message includes it."""
def __init__(self, status, message, hint=None):
super().__init__(f"{message} {hint}" if hint else message)
self.status = status
self.hint = hint
def _request(method, path, body=None, retry=False):
key = os.environ.get("PROXYMAIL_QA_API_KEY")
if not key:
raise RuntimeError("Set PROXYMAIL_QA_API_KEY for the test run.")
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
run = os.environ.get("PROXYMAIL_QA_RUN")
if run:
headers["Proxy-Mail-QA-Run"] = run
# A wait that will be asked again: its "nothing arrived" isn't evidence yet.
if retry:
headers["Proxy-Mail-QA-Run-Retry"] = "true"
# The test this call belongs to (set_test, or the pytest hooks below).
if _test["name"]:
headers["Proxy-Mail-QA-Test"] = urllib.parse.quote(_test["name"], safe="")
request = urllib.request.Request(
BASE_URL + path,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=40) as response:
raw = response.read()
if response.headers.get("Content-Type", "").startswith("text/markdown"):
return raw.decode()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as error:
payload = json.loads(error.read() or b"{}")
raise ProxyMailQAError(error.code, payload.get("message", str(error)), payload.get("hint")) from None
def create_address(**options):
"""A new test address with a persona and a password for the sign-up form:
{"address": ..., "password": ..., "persona": {...}}. Options include
label, lifetime_days, domain, age ("adult", "under13", ...), country, and
where it's used: site (a host or page URL) and environment ("staging")."""
return _request("POST", "/addresses", {"persona": True, "password": True, **options})
def form_values(fields, address=None, **options):
"""What to type or pick in each field of a sign-up form. fields lists what
the page says about each one: {"id", "type", "name", "label",
"placeholder", "autocomplete", "pattern", "min_length", "max_length",
"options": [{"value", "label"}]}. Returns {"address", "password",
"values": [{"id", "kind", "value", "note"}, ...]}, from a new address made
for the form (options as for create_address, such as age) or the one
given. You fill in the form."""
body = {"fields": fields, **options}
if address:
body["address"] = address
return _request("POST", "/form-values", body)
def find_addresses(tags=None, country=None, min_age=None, max_age=None, has_password=None, search=None, limit=None, site=None, environment=None):
"""Test accounts made earlier, newest first, where every filter given
matches: find_addresses(tags=["age-gate"], country="GB", max_age=17)
finds under-18s in GB. Ages are whole years today, from the persona's
date of birth; search is part of the address, label, notes, name or
username; site and environment are where it's used."""
query = [("tag", tag) for tag in tags or []]
filters = (("country", country), ("min_age", min_age), ("max_age", max_age), ("search", search), ("limit", limit), ("site", site), ("environment", environment))
for name, value in filters:
if value is not None:
query.append((name, value))
if has_password is not None:
query.append(("has_password", "true" if has_password else "false"))
return _request("GET", "/addresses" + ("?" + urllib.parse.urlencode(query) if query else ""))
def check_out_account(tags=None, country=None, min_age=None, max_age=None, has_password=None, search=None, minutes=None, site=None, environment=None):
"""An account from the pool: one matching every filter given that nobody
else has checked out (the least recently used first), held for `minutes`
(default 10). Raises ProxyMailQAError 409 when every match is checked
out, 404 when none match. Tag accounts with their state (verified,
premium, ...) to make pools."""
filters = {"tags": tags, "country": country, "min_age": min_age, "max_age": max_age, "has_password": has_password, "search": search, "minutes": minutes}
filters.update(site=site, environment=environment)
return _request("POST", "/addresses/checkout", {name: value for name, value in filters.items() if value is not None})
def sign_in_account(site=None, environment=None, tags=None):
"""The saved test account to sign in with, and its password, in one call:
one with a saved password and every tag given, used on this site first,
then the one that got mail most recently. None when none fits. Needs a
key created with "Can read saved passwords"; every reveal is logged."""
body = {name: value for name, value in (("site", site), ("environment", environment), ("tags", tags)) if value is not None}
try:
return _request("POST", "/addresses/sign-in", body)
except ProxyMailQAError as error:
if error.status == 404:
return None
raise
def mark_dev_host(host):
"""Marks a host ("localhost:3000") as a dev environment of the project:
links to it then pass check_email's local-links check."""
return _request("POST", "/project/dev-hosts", {"host": host})["dev_hosts"]
def extend_checkout(account, minutes=None):
"""Holds a checked-out account longer: `minutes` from now (default 10,
at most 60)."""
body = {"token": account["checkout"]["token"]}
if minutes is not None:
body["minutes"] = minutes
return _request("POST", f"/addresses/{urllib.parse.quote(account['address'])}/extend", body)
def return_account(account, add_tags=None, remove_tags=None):
"""Frees a checked-out account for the next test, recording the state
it was left in (tags to add and remove)."""
body = {"token": account["checkout"]["token"], "add_tags": add_tags, "remove_tags": remove_tags}
return _request("POST", f"/addresses/{urllib.parse.quote(account['address'])}/return", body)
@contextlib.contextmanager
def checked_out_account(create=None, check=None, reset=None, new_account=None, attempts=3, **filters):
"""with checked_out_account(tags=["premium"]) as account: ... checks an
account out and always returns it.
Hooks make it an account in the state the test needs. Your test code runs
them, with your own access to the app; Proxy Mail QA never signs in to it.
create(account) makes a new one when none is free: sign up with
account["address"] and account["password"], verify it and set its state
(new_account holds create_address options such as age or label).
check(account) confirms the state: return False or raise when it isn't,
and it's reset and checked again, or another account is tried; one that
still fails loses the filter's tags and is tagged check-failed.
reset(account) restores the state afterwards; if it fails, the account
loses the filter's tags and is tagged needs-reset."""
account = _prepare_account(create, check, reset, new_account or {}, attempts, filters)
try:
yield account
except BaseException:
with contextlib.suppress(Exception):
_restore_account(account, reset, filters)
raise
_restore_account(account, reset, filters)
def _prepare_account(create, check, reset, new_account, attempts, filters):
tried, last = [], ""
for _ in range(attempts):
try:
account = check_out_account(**filters)
except ProxyMailQAError as error:
if error.status not in (404, 409):
raise
# None matches, or every match is busy: make one, if the test can.
if create:
return _new_account(create, check, new_account, filters)
if tried: # accounts that failed their check have left the pool
break
raise
if account["address"] in tried: # every match has been tried
with contextlib.suppress(Exception):
return_account(account)
break
tried.append(account["address"])
problem = _failed_check(check, account)
if problem is None:
return account
if reset:
try:
reset(account)
problem = _failed_check(check, account)
except Exception as error:
problem = f"reset failed: {error}"
if problem is None:
return account
last = problem
_note(f"Test account failed its check ({problem}); it loses its tags and is tagged check-failed.", account["address"])
with contextlib.suppress(Exception):
return_account(account, add_tags=["check-failed"], remove_tags=filters.get("tags"))
if create:
return _new_account(create, check, new_account, filters)
tried_text = f": tried {', '.join(tried)}" + (f" (last: {last})" if last else "") if tried else ""
raise RuntimeError(f"No test account passed the check{tried_text}. Pass create to make one.")
def _new_account(create, check, new_account, filters):
"""Makes an account with the create hook, held from the start so no
parallel test takes it half made, then tags and checks it."""
tags = [tag.lower() for tag in filters.get("tags") or []]
min_age, max_age = filters.get("min_age"), filters.get("max_age")
if (min_age is not None or max_age is not None) and not (new_account.get("age") or new_account.get("date_of_birth")):
raise ValueError("Give new_account an age or date_of_birth, so new accounts match min_age and max_age.")
options = {**new_account}
if filters.get("country") and "country" not in options:
options["country"] = filters["country"]
created = create_address(**options)
held = check_out_account(search=created["address"], minutes=filters.get("minutes"))
if held["address"] != created["address"]:
with contextlib.suppress(Exception):
return_account(held)
raise RuntimeError(f"Couldn't hold the new account {created['address']}.")
try:
create({**held, "password": created["password"]})
tagged = _set_tags(created["address"], sorted(set(held["tags"]) | set(tags)))
account = {**tagged, "checkout": held["checkout"]}
age = _age((account.get("persona") or {}).get("date_of_birth"))
if age is not None and ((min_age is not None and age < min_age) or (max_age is not None and age > max_age)):
raise ValueError(f"The new account is {age}, outside min_age and max_age: give new_account a matching age.")
problem = _failed_check(check, account)
if problem is not None:
raise RuntimeError(f"The new account {created['address']} failed its check after create: {problem}")
_note("Made a new test account" + (f" tagged {', '.join(tags)}." if tags else "."), created["address"])
return account
except BaseException:
# Untagged, it stays out of the pool; it expires with its address.
with contextlib.suppress(Exception):
_set_tags(created["address"], held["tags"])
with contextlib.suppress(Exception):
return_account(held)
raise
def _restore_account(account, reset, filters):
"""Runs reset, then returns the account. If reset fails, the account
leaves the pool's state (needs-reset) and the error is raised."""
if reset:
try:
reset(account)
except Exception as error:
_note(f"Couldn't reset the test account ({error}); it loses its tags and is tagged needs-reset.", account["address"])
with contextlib.suppress(Exception):
return_account(account, add_tags=["needs-reset"], remove_tags=filters.get("tags"))
raise
with contextlib.suppress(Exception):
return_account(account)
def _failed_check(check, account):
"""None when the account passes (or there's no check), or why it doesn't."""
if check is None:
return None
try:
return "check returned False" if check(account) is False else None
except Exception as error: # such as an AssertionError
return str(error) or type(error).__name__
def _set_tags(address, tags):
return _request("PATCH", f"/addresses/{urllib.parse.quote(address)}", {"tags": tags})
def _age(date_of_birth):
"""Whole years from a YYYY-MM-DD date of birth, or None without one."""
if not date_of_birth:
return None
born, today = datetime.strptime(date_of_birth, "%Y-%m-%d").date(), datetime.now(timezone.utc).date()
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
def _note(text, address):
"""Adds a line to the current run, if there is one, without failing the test."""
if os.environ.get("PROXYMAIL_QA_RUN"):
with contextlib.suppress(Exception):
add_run_note(text, address=address)
def _wait(kind, address, since, timeout, extra):
deadline = time.monotonic() + timeout
since = since.astimezone(timezone.utc).isoformat()
while True:
left = max(0, min(30, int(deadline - time.monotonic())))
query = urllib.parse.urlencode({"since": since, "timeout": left, **extra})
more = time.monotonic() + left < deadline - 0.5
try:
return _request("GET", f"/addresses/{urllib.parse.quote(address)}/{kind}?{query}", retry=more)["value"]
except ProxyMailQAError as error:
# Only a wait that found nothing is repeated, until the timeout.
if error.status != 404 or not str(error).startswith(f"No {kind} arrived") or time.monotonic() >= deadline - 0.5:
raise
def wait_for_code(address, since, timeout=30, unused=False):
"""The code from the newest email received since `since` (a datetime),
waiting up to `timeout` seconds. unused=True skips codes already used
(returned by an earlier wait, or copied in the app)."""
return _wait("code", address, since, timeout, {"unused": "true"} if unused else {})
def wait_for_link(address, since, type="verify", app=None, timeout=30, unused=False):
"""The link from the newest email received since `since`: verify,
magic_signin, invite, reset or any. app=True asks for the link that opens
a mobile app (myapp://...) when the email also has a web one;
unused=True skips links already used."""
extra = {"type": type, **({} if app is None else {"app": "true" if app else "false"}), **({"unused": "true"} if unused else {})}
return _wait("link", address, since, timeout, extra)
def _count_query(since, type, subtype, service, **extra):
query = {key: value for key, value in extra.items()}
if since is not None:
query["since"] = since if isinstance(since, str) else since.astimezone(timezone.utc).isoformat()
if service:
query["service"] = service
if type:
query["type"] = type
if subtype:
query["subtype"] = subtype if isinstance(subtype, str) else ",".join(subtype)
return "?" + urllib.parse.urlencode(query) if query else ""
def count_emails(address, since=None, type=None, subtype=None, service=None):
"""How many emails reached the address, each summarised without its
content: {"count": ..., "emails": [{"received_at", "service", "type",
"subtype"}, ...]}. type is "code" or "link"; without since, every email
the address has received. Count after waiting for the first email, so a
duplicate has had time to arrive."""
query = _count_query(since, type, subtype, service)
return _request("GET", f"/addresses/{urllib.parse.quote(address)}/count{query}")
def check_email(address, since=None, type=None, subtype=None, service=None, email_id=None, expect_host=None, expect_text=None, expect_language=None):
"""Checks one email the address received (the newest that matches, or
email_id): SPF, DKIM and DMARC, local and plain http links, where its link
goes (expect_host), a stated expiry, a plain-text part, size, image-only
emails, phrases it should contain (expect_text, a list) and its language
(expect_language, such as "de"). Returns {"passed", "failed", "warnings",
"checks": [{"check", "status", "detail"}, ...]}, never the email's content.
Wait for the email first."""
query = _count_query(since, type, subtype, service)
extra = [(name, value) for name, value in (("email_id", email_id), ("expect_host", expect_host), ("expect_language", expect_language)) if value]
extra += [("expect_text", phrase) for phrase in expect_text or []]
if extra:
query += ("&" if query else "?") + urllib.parse.urlencode(extra)
return _request("GET", f"/addresses/{urllib.parse.quote(address)}/checks{query}")
def expect_no_email(address, since=None, timeout=30, type=None, subtype=None, service=None):
"""Returns when no matching email arrives within `timeout` seconds, for
checks such as "an unsubscribed user gets nothing"; raises
ProxyMailQAError (status 409) as soon as one does. Pass since as the time
of the action, so an email that came before the call still counts;
without it, from now."""
deadline = time.monotonic() + timeout
while True:
left = max(0, min(30, math.ceil(deadline - time.monotonic())))
query = _count_query(since, type, subtype, service, timeout=left)
more = time.monotonic() + left < deadline - 0.5
result = _request("GET", f"/addresses/{urllib.parse.quote(address)}/none{query}", retry=more)
# The server's since, so later requests watch from the same moment.
since = result["since"]
if time.monotonic() >= deadline - 0.5:
return result
def start_run(name, source=None, meta=None):
"""Starts a run: later calls (from this process and any it starts) add
what they saw, for a report on the pull request. Returns {"id", "url",
...}. In GitHub Actions, the Proxy Mail QA action does this for you."""
run = _request("POST", "/runs", {"name": name, "meta": meta or {}, **({"source": source} if source else {})})
os.environ["PROXYMAIL_QA_RUN"] = run["id"]
return run
def add_run_note(text, failed=False, address=None):
"""A line of your own in the run; failed=True fails it."""
_request("POST", f"/runs/{os.environ['PROXYMAIL_QA_RUN']}/notes", {"text": text, "failed": failed, "address": address})
def finish_run(failed=False):
"""Finishes the run: passed unless an email was missing, came when none
should have, failed a check, or failed=True says the tests failed."""
return _request("POST", f"/runs/{os.environ.pop('PROXYMAIL_QA_RUN')}/finish", {"failed": failed})
def set_test(name):
"""Names the test the following calls belong to (None for none), so the
run's report says which test each email came from. The pytest hooks at
the end of this file do it for you."""
_test["name"] = " ".join(str(name).split())[:200] if name else None
def record_test(name, status, duration_ms=None, error=None):
"""A test's result in the run: status is "passed", "failed", "flaky" or
"skipped", and a failed test fails the run."""
body = {"name": name, "status": status, "duration_ms": duration_ms, "error": error}
_request("POST", f"/runs/{os.environ['PROXYMAIL_QA_RUN']}/tests", {key: value for key, value in body.items() if value is not None})
def record_test_totals(passed, failed, skipped=0, flaky=0):
"""How the suite went, for the top of the run's report."""
totals = {"passed": passed, "failed": failed, "skipped": skipped, "flaky": flaky}
_request("POST", f"/runs/{os.environ['PROXYMAIL_QA_RUN']}/tests", {"totals": totals})
def run_report(run_id=None):
"""The run's report in Markdown, for a pull request or job summary."""
return _request("GET", f"/runs/{run_id or os.environ['PROXYMAIL_QA_RUN']}?format=markdown")
def update_address(address, **changes):
"""Changes a test account's details, such as after the app changed them:
first_name, last_name, username, date_of_birth, country, label, notes or
tags. None clears one."""
return _request("PATCH", f"/addresses/{urllib.parse.quote(address)}", changes)
def set_password(address, password=None, label="password"):
"""Saves the password a test account uses now, after the app changed or
reset it. Without one, a strong password is generated and returned once,
as "password"."""
body = {"label": label}
if password is not None:
body["password"] = password
return _request("PUT", f"/addresses/{urllib.parse.quote(address)}/password", body)
def variants(address):
"""Versions of the address for testing a sign-up form: "delivered" ones
(capitals, plus tags with special characters) whose mail reaches its
inbox, "undeliverable" ones, and "invalid" ones it should reject."""
return _request("GET", f"/addresses/{urllib.parse.quote(address)}/variants")
def delete_address(address):
_request("DELETE", f"/addresses/{urllib.parse.quote(address)}")
def now():
"""The time to pass as `since`: call it just before submitting the form."""
return datetime.now(timezone.utc)
# pytest: in a run (PROXYMAIL_QA_RUN), the report says which test each email
# came from, records each failed test with the start of its error, and shows
# the totals. Turn it on by importing the hooks into your conftest.py:
#
# from proxymail_qa import pytest_runtest_setup, pytest_runtest_teardown, pytest_runtest_logreport, pytest_sessionfinish
_test = {"name": None}
_totals = {"passed": 0, "failed": 0, "skipped": 0, "flaky": 0}
def _test_name(nodeid):
"""tests/test_signup.py::TestSignUp::test_code[firefox] reads as
TestSignUp › test_code[firefox]."""
return " › ".join(nodeid.split("::")[1:]) or nodeid
def pytest_runtest_setup(item):
set_test(_test_name(item.nodeid))
def pytest_runtest_teardown(item):
set_test(None)
def pytest_runtest_logreport(report):
if not os.environ.get("PROXYMAIL_QA_RUN"):
return
if report.skipped and report.when in ("setup", "call"):
_totals["skipped"] += 1
elif report.failed and report.when in ("setup", "call"):
_totals["failed"] += 1
# pytest's one-line summary of the failure, such as "AssertionError: …".
crash = getattr(getattr(report.longrepr, "reprcrash", None), "message", None)
error = " ".join((crash or str(report.longrepr).strip().splitlines()[-1]).split())[:300]
with contextlib.suppress(Exception):
record_test(_test_name(report.nodeid), "failed", round(report.duration * 1000), error)
elif report.passed and report.when == "call":
_totals["passed"] += 1
def pytest_sessionfinish(session, exitstatus):
if os.environ.get("PROXYMAIL_QA_RUN") and any(_totals.values()):
with contextlib.suppress(Exception):
record_test_totals(**_totals)
test_signup.py
from selenium.webdriver.common.by import By
from proxymail_qa import create_address, delete_address, now, wait_for_code
def test_sign_up_sends_a_working_code(driver): # your WebDriver fixture
account = create_address()
driver.get("https://staging.example.com/signup")
driver.find_element(By.NAME, "email").send_keys(account["address"])
driver.find_element(By.NAME, "password").send_keys(account["password"])
submitted = now()
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
code = wait_for_code(account["address"], since=submitted)
driver.find_element(By.NAME, "code").send_keys(code)
delete_address(account["address"])
test_signup.py
import re
from playwright.sync_api import Page, expect
from proxymail_qa import create_address, delete_address, now, wait_for_link
def test_sign_up_verifies_the_email(page: Page):
account = create_address()
page.goto("/signup")
page.get_by_label("Email").fill(account["address"])
page.get_by_label("Password").fill(account["password"])
submitted = now()
page.get_by_role("button", name="Sign up").click()
page.goto(wait_for_link(account["address"], since=submitted))
expect(page).to_have_url(re.compile("verified"))
delete_address(account["address"])

since makes the wait ignore anything sent before the form was submitted. Waits hold for up to 30 seconds; pass timeout for longer. If nothing arrives, ProxyMailQAError says why as far as Proxy Mail QA can tell, for example that your app’s email provider only sends to verified addresses.

For a code instead of a link, use wait_for_code; for the link that opens a mobile app, wait_for_link(address, since, app=True).

form_values gives what to type or pick in each field of a form, from a new address made for it: a username and password that fit the fields’ rules, the date of birth in the field’s format, a country the drop-down offers. Describe each field as the page does, then fill them in:

result = form_values([
{"id": "email", "type": "email", "label": "Email"},
{"id": "pw", "type": "password", "label": "Password", "min_length": 12},
{"id": "dob", "type": "text", "label": "Date of birth", "placeholder": "DD/MM/YYYY"},
])
for field in result["values"]:
if field["value"] is not None:
page.locator(f"#{field['id']}").fill(field["value"])

age= and country= shape a new address; address= uses one made earlier. More in the REST API guide.

Checking that an email didn’t arrive, or arrived once

Section titled “Checking that an email didn’t arrive, or arrived once”

Some tests pass when nothing happens: an unsubscribed user gets no newsletter, a second reset request within a minute is refused. expect_no_email watches the address and raises ProxyMailQAError (status 409) as soon as a matching email arrives. count_emails counts what came, for “exactly one verification email”:

test_emails.py
import time
from proxymail_qa import count_emails, expect_no_email, now, wait_for_link
def test_unsubscribed_users_get_no_newsletter(app, unsubscribed):
sent = now()
app.send_newsletter()
expect_no_email(unsubscribed["address"], since=sent, timeout=20)
def test_sign_up_sends_one_verification_email(page, account):
submitted = now()
page.get_by_role("button", name="Sign up").click()
wait_for_link(account["address"], since=submitted)
time.sleep(10) # give a duplicate time to arrive
assert count_emails(account["address"], since=submitted, type="link")["count"] == 1

Neither returns the emails’ content: each email is summarised by when it arrived, who sent it and whether it had a code or link. Filters: type ("code" or "link"), subtype (such as "verify" or "reset") and service. Everything else is in the REST API guide, and Running in CI covers keys, parallel runs and clean-up.

check_email runs checks on the email that arrived: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes, whether it says when the code or link expires, a plain-text part, size and image-only emails, and, when you ask, phrases it should contain (expect_text=[...]) and its language (expect_language="de"). It returns verdicts, never the email’s content:

test_emails.py
from proxymail_qa import check_email, now, wait_for_link
def test_verification_email_is_well_built(page, account):
submitted = now()
page.get_by_role("button", name="Sign up").click()
wait_for_link(account["address"], since=submitted)
result = check_email(account["address"], since=submitted, type="link", expect_host="staging.example.com")
assert result["passed"], [c for c in result["checks"] if c["status"] == "fail"]

What each check means is in the REST API guide. Testing an app that runs on your machine? mark_dev_host("localhost:3000") once, and links to it pass local_links.

Tag accounts with their state, then let each test borrow one that no other test has: with checked_out_account(tags=["verified", "premium"]) as account: checks one out and always returns it. To record what a test changed, call return_account(account, add_tags=[...], remove_tags=[...]) yourself after check_out_account(...). More in the REST API guide.

When a test needs an account in a particular state in your app (verified, on the premium plan, with no orders), pass three hooks. Your test code runs them with its own access to your app; Proxy Mail QA never signs in to it. Here app stands for your own code that talks to your app.

def create(account): # when none is free: sign up, verify, upgrade
submitted = now()
app.sign_up(account["address"], account["password"])
app.open(wait_for_link(account["address"], since=submitted))
app.admin.set_plan(account["address"], "premium")
def check(account): # still in that state? Return False or raise if not
return app.admin.user(account["address"])["plan"] == "premium"
def reset(account): # after the test: put it back
app.admin.reset_user(account["address"], plan="premium", orders=[])
with checked_out_account(tags=["verified", "premium"], create=create, check=check, reset=reset) as account:
...

An account that fails check is reset and checked again; one that still fails loses the pool’s tags, is tagged check-failed, and the next is tried (attempts, 3). create runs when none matches or all are checked out, on a new address held for this test from the start (new_account takes create_address options such as age). If reset fails after the test, the account is tagged needs-reset and leaves the pool. The Node.js client works the same way.

When PROXYMAIL_QA_RUN is set, every call adds what it saw to that run: the addresses, which emails arrived and how fast, the codes, where each link goes, checks, and emails that never came. In GitHub Actions, the Proxy Mail QA action sets it and posts the report on the pull request. Elsewhere, call start_run("Sign-up tests") before the tests (in a session-scoped fixture, say) and finish_run(failed=...) after; run_report() gives the report in Markdown.

In pytest, import the helper’s hooks into conftest.py. In a run, each call then names its test, so the report shows which test each email came from, and the run records each failed test with the start of its error, and the totals:

conftest.py
from proxymail_qa import pytest_runtest_setup, pytest_runtest_teardown, pytest_runtest_logreport, pytest_sessionfinish

Outside a run they do nothing. Other runners can call set_test(name), record_test(name, status, duration_ms, error) and record_test_totals(passed, failed, skipped, flaky) themselves.

find_addresses returns test accounts made earlier, newest first, where every filter given matches: tags, country, min_age and max_age (whole years today, from the persona’s date of birth), has_password, search (part of the address, label, notes, name or username), and site and environment (where it’s used, set with create_address(site=..., environment=...)).

from proxymail_qa import find_addresses
teen = find_addresses(tags=["age-gate"], country="GB", max_age=17, has_password=True)[0]

sign_in_account(site=..., tags=[...]) goes one step further: the saved test account to sign in with, used on this site first, with its password, or None when none fits. It needs a key with “Can read saved passwords”.

from proxymail_qa import sign_in_account
account = sign_in_account(site="staging.example.com", tags=["verified"])
page.get_by_label("Email").fill(account["address"])
page.get_by_label("Password").fill(account["password"])

When a test changes the account in the app, record it so the saved details still sign in: set_password(address, "the-new-password") after a password change or reset (leave the password out to generate one, returned once), and update_address(address, username="new_name") for a new name, username, date of birth or country, or its label, notes and tags.

To test a sign-up form with unusual but valid addresses, variants(address) lists versions whose mail still reaches the address’s inbox, and invalid ones the form should reject (more). A test that holds a pooled account longer than planned can call extend_checkout(account, minutes=20).