Python SDK

Complete guide to the OneShotMail Python SDK -- installation, API reference, Behave/pytest/unittest integration.

Installation

pip install oneshot-mail

Requires Python 3.9+. The SDK depends on httpx for HTTP and pydantic for response models.

Configuration

import oneshot

# Uses ONESHOT_API_KEY from environment automatically
address = oneshot.create()

Explicit client

from oneshot import Client

client = Client(
    api_key="osm_live_your_key",
    base_url="https://api.oneshotemail.com/v1",  # default
    timeout=30.0,  # default HTTP timeout in seconds
)

Module-level configuration

import oneshot

oneshot.configure(
    api_key="osm_live_your_key",
    base_url="https://api.oneshotemail.com/v1",
    timeout=30.0,
)

Context manager

The client implements the context manager protocol for clean resource management:

from oneshot import Client

with Client(api_key="osm_live_your_key") as client:
    addr = client.create(ttl_seconds=300)
    email = client.wait_for_email(addr.id)
# Connection pool is automatically released

API Reference

create(ttl_seconds, label, mode)

Create a new one-shot email address.

ParameterTypeDefaultDescription
ttl_secondsint3600Time-to-live before auto-expiry.
labelstrNoneOptional label for filtering.
modestr"receive""receive" or "send".

Returns: Address object.

addr = client.create(ttl_seconds=300, label="signup-test")
print(addr.id)        # "abc123xyz789def456"
print(addr.address)   # "abc123xyz789def456@in.oneshotemail.com"
print(addr.status)    # "waiting"
print(addr.mode)      # "receive"
print(addr.expires_at)  # datetime
print(addr.label)     # "signup-test"

get(address_id)

Retrieve an address and its current status.

ParameterTypeDescription
address_idstrThe address ID.

Returns: Address object (with email summary if received).

Raises: NotFoundError, ExpiredError.

addr = client.get("abc123xyz789def456")
if addr.status == "received":
    print(f"Email from: {addr.email.from_address}")
    print(f"Subject: {addr.email.subject}")

get_email(address_id)

Retrieve the full parsed email content.

ParameterTypeDescription
address_idstrThe address ID.

Returns: Email object with from_address, to, subject, text_body, html_body, headers, received_at, size_bytes, and attachments.

Raises: NotFoundError (no email yet), ExpiredError.

email = client.get_email("abc123xyz789def456")
print(email.from_address)   # "noreply@example.com"
print(email.subject)        # "Verify your account"
print(email.text_body)      # "Click here to verify..."
print(email.html_body)      # "<html>...</html>"
print(email.headers)        # {"Message-ID": "...", "DKIM-Signature": "..."}
print(email.size_bytes)     # 15234
print(email.attachments)    # [Attachment(...), ...]

get_email_raw(address_id)

Retrieve the raw RFC 822 email source as a string.

raw = client.get_email_raw("abc123xyz789def456")
# Full email source with all headers
print(raw)

download_attachment(address_id, index)

Download a single attachment by zero-based index.

Returns: bytes — the raw attachment content.

pdf_bytes = client.download_attachment("abc123xyz789def456", 0)
with open("invoice.pdf", "wb") as f:
    f.write(pdf_bytes)

wait_for_email(address_id, timeout, poll_interval)

Poll until an email arrives, using exponential backoff. This is the primary method for test suites.

ParameterTypeDefaultDescription
address_idstrThe address ID to watch.
timeoutfloat60Max seconds to wait.
poll_intervalfloat2.0Initial polling interval.

Returns: Email object.

Raises: WaitTimeoutError if no email arrives within timeout. ExpiredError if the address expires while waiting.

Backoff behavior: starts at poll_interval, multiplied by 1.5 after each poll, capped at 10 seconds.

# Basic usage
email = client.wait_for_email(addr.id, timeout=30)

# Aggressive polling for fast email delivery
email = client.wait_for_email(addr.id, timeout=10, poll_interval=0.5)

# Patient waiting for slow external services
email = client.wait_for_email(addr.id, timeout=120, poll_interval=5.0)

send(to, subject, text_body, html_body, attachments, ttl_seconds, label)

Create a one-shot address and send a single email from it.

ParameterTypeDefaultDescription
tostrDestination address.
subjectstrEmail subject.
text_bodystrNonePlain text body.
html_bodystrNoneHTML body.
attachmentslist of tuplesNoneAttachments (see below).
ttl_secondsint300TTL for the address record.
labelstrNoneOptional label.

Attachment format: Each attachment is a tuple of (filename, content_bytes) or (filename, content_type, content_bytes). File-like objects are also accepted instead of bytes.

Returns: Address object with mode="send" and status="sent".

# Simple text email
result = client.send(
    to="intake@myapp.com",
    subject="Test invoice",
    text_body="Please process this invoice.",
)

# HTML email with attachments
with open("invoice.pdf", "rb") as f:
    pdf_bytes = f.read()

result = client.send(
    to="intake@myapp.com",
    subject="Invoice #1234",
    text_body="See attached invoice.",
    html_body="<p>See attached invoice.</p>",
    attachments=[
        ("invoice.pdf", "application/pdf", pdf_bytes),
        ("receipt.txt", b"Total: $42.00"),
    ],
    label="invoice-test",
)

list(status, label, mode, limit, cursor)

List addresses for the authenticated user.

ParameterTypeDefaultDescription
statusstrNoneFilter by status.
labelstrNoneFilter by label.
modestrNoneFilter by mode.
limitint20Max results.
cursorstrNonePagination cursor.

Returns: AddressListResponse with items (list of Address) and next_cursor.

# List all waiting addresses
result = client.list(status="waiting")
for addr in result.items:
    print(f"{addr.id}: {addr.status}")

# Paginate
result = client.list(limit=10)
while result.next_cursor:
    result = client.list(limit=10, cursor=result.next_cursor)

delete(address_id)

Delete an address and its email data immediately.

client.delete("abc123xyz789def456")

delete_by_label(label)

Bulk-delete all addresses matching a label.

client.delete_by_label("ci-run-abc123")

account()

Get account details and usage.

Returns: Account object.

acct = client.account()
print(f"Plan: {acct.plan}")
print(f"Receive usage: {acct.usage.receive.used}/{acct.usage.receive.limit}")
print(f"Credits: {acct.credits_remaining}")

buy_credits(amount)

Initiate a credit purchase.

Returns: CheckoutURL with checkout_url string.

checkout = client.buy_credits(500)
print(f"Complete purchase at: {checkout.checkout_url}")

health()

Check API health (does not require authentication).

Returns: HealthStatus with status, region, version.

h = client.health()
print(f"API status: {h.status}, region: {h.region}, version: {h.version}")

save_attachments(address_id, directory)

Convenience method: download all attachments for an address and save them to a directory.

Returns: list of Path objects pointing to the saved files.

paths = client.save_attachments("abc123xyz789def456", "./downloads")
for path in paths:
    print(f"Saved: {path}")

Error handling

All SDK exceptions inherit from OneShotError:

ExceptionHTTP StatusWhen
UnauthorizedError401Invalid or missing API key.
QuotaExceededError402Quota and credits exhausted.
NotFoundError404Address not found or no email yet.
ExpiredError410Address expired and deleted.
ValidationError400/422Invalid request parameters.
RateLimitedError429Rate limit exceeded.
WaitTimeoutErrorN/Await_for_email() timed out.
OneShotErrorAnyBase class for all other errors.

Every exception has these attributes:

  • message — Human-readable description.
  • code — Machine-readable code (e.g., "QUOTA_EXCEEDED").
  • status_code — HTTP status code (if from an API response).
  • response_body — Raw response dict (if available).

QuotaExceededError additionally has upgrade_url. RateLimitedError additionally has retry_after (seconds).


Behave (BDD) integration

Behave is the Python BDD framework. Here is a complete worked example.

Feature file: features/signup.feature

Feature: User signup
  As a new user
  I want to sign up for an account
  So that I can access the application

  Scenario: Successful signup sends verification email
    Given I have a temporary email address labelled "signup"
    When I sign up with that email address
    Then I should receive a verification email within 30 seconds
    And the email subject should contain "Verify your account"
    And the email body should contain a verification link

Step definitions: features/steps/signup_steps.py

import re
from behave import given, when, then
import oneshot


@given('I have a temporary email address labelled "{label}"')
def step_create_address(context, label):
    run_id = context.config.userdata.get("run_id", "local")
    context.address = oneshot.create(
        ttl_seconds=300,
        label=f"{label}-{run_id}",
    )
    context.email_address = context.address.address


@when("I sign up with that email address")
def step_signup(context):
    # Replace with your app's signup API
    context.app_client.post("/signup", json={
        "email": context.email_address,
        "password": "SecureP@ss1",
    })


@then("I should receive a verification email within {timeout:d} seconds")
def step_wait_for_email(context, timeout):
    context.email = oneshot.wait_for_email(context.address.id, timeout=timeout)


@then('the email subject should contain "{text}"')
def step_check_subject(context, text):
    assert text in context.email.subject, (
        f"Expected '{text}' in subject, got: {context.email.subject}"
    )


@then("the email body should contain a verification link")
def step_check_verification_link(context):
    body = context.email.text_body or context.email.html_body
    match = re.search(r"https?://\S+/verify\S*", body)
    assert match, f"No verification link found in email body"
    context.verification_link = match.group(0)

Environment setup: features/environment.py

import os
import uuid
import oneshot


def before_all(context):
    oneshot.configure(api_key=os.environ["ONESHOT_API_KEY"])
    context.run_id = str(uuid.uuid4())[:8]
    context.config.userdata["run_id"] = context.run_id


def after_all(context):
    # Clean up all addresses from this test run
    oneshot.delete_by_label(f"signup-{context.run_id}")


def after_scenario(context, scenario):
    # Optional: clean up per-scenario if you prefer
    if hasattr(context, "address"):
        try:
            oneshot.delete(context.address.id)
        except oneshot.NotFoundError:
            pass  # Already cleaned up or expired

Running

pip install behave oneshot-mail
export ONESHOT_API_KEY="osm_live_your_key"
behave

pytest integration

Fixture: conftest.py

import os
import uuid
import pytest
import oneshot


@pytest.fixture(scope="session")
def oneshot_client():
    """Shared OneShotMail client for the test session."""
    client = oneshot.Client(api_key=os.environ["ONESHOT_API_KEY"])
    yield client
    client.close()


@pytest.fixture(scope="session")
def run_label():
    """Unique label for this test run, used for bulk cleanup."""
    return f"pytest-{uuid.uuid4().hex[:8]}"


@pytest.fixture
def email_address(oneshot_client, run_label):
    """Create a temporary email address for a single test."""
    addr = oneshot_client.create(ttl_seconds=300, label=run_label)
    yield addr
    try:
        oneshot_client.delete(addr.id)
    except oneshot.NotFoundError:
        pass


@pytest.fixture(scope="session", autouse=True)
def cleanup_all(oneshot_client, run_label):
    """After the entire test session, bulk-delete all addresses by label."""
    yield
    oneshot_client.delete_by_label(run_label)

Test file: tests/test_signup.py

import oneshot


def test_signup_sends_verification_email(oneshot_client, email_address, app):
    # Trigger signup
    app.post("/signup", json={
        "email": email_address.address,
        "name": "Test User",
    })

    # Wait for the verification email
    email = oneshot_client.wait_for_email(email_address.id, timeout=30)

    assert "Verify your account" in email.subject
    assert "Test User" in email.text_body


def test_password_reset_sends_email(oneshot_client, email_address, app):
    # Create the user first, then trigger reset
    app.post("/signup", json={"email": email_address.address, "name": "Test"})
    app.post("/password-reset", json={"email": email_address.address})

    email = oneshot_client.wait_for_email(email_address.id, timeout=30)
    assert "Reset your password" in email.subject

Parallel execution with pytest-xdist

The label-based cleanup pattern works perfectly with pytest-xdist:

pip install pytest-xdist
pytest -n 4 --dist loadfile

Each worker gets its own email_address fixture, so there is no cross-contamination between parallel tests.


unittest integration

import os
import unittest
import oneshot


class EmailTestCase(unittest.TestCase):
    """Base test case with OneShotMail support."""

    @classmethod
    def setUpClass(cls):
        cls.client = oneshot.Client(api_key=os.environ["ONESHOT_API_KEY"])
        cls.addresses = []

    @classmethod
    def tearDownClass(cls):
        for addr_id in cls.addresses:
            try:
                cls.client.delete(addr_id)
            except oneshot.NotFoundError:
                pass
        cls.client.close()

    def create_address(self, label=None, ttl=300):
        addr = self.client.create(ttl_seconds=ttl, label=label)
        self.addresses.append(addr.id)
        return addr


class TestSignup(EmailTestCase):
    def test_verification_email(self):
        addr = self.create_address(label="test-signup")

        # Trigger your app's signup
        self.app.signup(email=addr.address)

        email = self.client.wait_for_email(addr.id, timeout=30)
        self.assertIn("Verify your account", email.subject)