Metadata-Version: 2.5
Name: otpbox
Version: 0.1.0
Summary: Python client for the OTPBox API - disposable inboxes and OTP/verification-link extraction for test automation and AI agents.
Project-URL: Homepage, https://otpbox.io/docs
Project-URL: Repository, https://github.com/harshilshah1310/otpbox
Project-URL: Issues, https://github.com/harshilshah1310/otpbox/issues
Author: Harshil Shah
License: MIT
License-File: LICENSE
Keywords: disposable-email,email,otp,playwright,pytest,selenium,test-automation,testing
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Provides-Extra: pytest
Requires-Dist: pytest>=7; extra == 'pytest'
Description-Content-Type: text/markdown

# otpbox

Python client for [OTPBox](https://otpbox.io) - disposable email inboxes and OTP/verification-link extraction, for pytest suites (Selenium, Playwright-python, or anything else), scripts, and AI agents. Standard library only - no dependencies beyond Python itself.

Get a free API key (no card, 200 requests/month) at <https://otpbox.io/docs>, or in code:

```bash
curl -X POST https://otpbox.io/api/v1/keys/free -d '{}'
```

## Install

```bash
pip install otpbox
```

## Quick start

```python
from otpbox import OtpBox

client = OtpBox()  # reads OTPBOX_API_KEY (falls back to OTPBOX_KEY)

with client.inbox() as inbox:
    print(inbox.address)  # e.g. tidy.willow806@otpbox.io

    # sign up for something with inbox.address, then:
    code = inbox.get_otp(timeout=20)
    print("Your code:", code)
    # the inbox is deleted automatically when the `with` block exits, pass or fail
```

Without the context manager:

```python
inbox = client.create_inbox()
try:
    code = client.get_otp(inbox, timeout=20)
finally:
    client.delete_inbox(inbox)
```

## pytest fixture

`otpbox.pytest_plugin` registers automatically once both `otpbox` and `pytest` are installed (it's declared as a `pytest11` entry point - no `conftest.py` wiring needed). It exposes an `otpbox_inbox` fixture: a disposable inbox created before your test and deleted after it, pass or fail.

```python
def test_signup(otpbox_inbox):
    submit_signup_form(email=otpbox_inbox.address)
    assert otpbox_inbox.get_otp(timeout=20)
```

Set `OTPBOX_API_KEY` (and, for a local dev server, `OTPBOX_BASE_URL`) in the environment the test runs in. Per-test inbox options via a marker:

```python
import pytest

@pytest.mark.otpbox_inbox(ttl_hours=1, local="signup-test")
def test_x(otpbox_inbox):
    ...
```

To source the key or base URL from somewhere other than the environment, override the session-scoped fixtures in your own `conftest.py`:

```python
import pytest

@pytest.fixture(scope="session")
def otpbox_api_key():
    return my_secret_manager.get("otpbox-key")
```

### Example: pytest + Selenium sign-up test

```python
from selenium import webdriver
from selenium.webdriver.common.by import By

def test_signup_with_real_otp(otpbox_inbox):
    driver = webdriver.Chrome()
    try:
        driver.get("https://your-app.example.com/signup")
        driver.find_element(By.NAME, "email").send_keys(otpbox_inbox.address)
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

        code = otpbox_inbox.get_otp(timeout=20)
        driver.find_element(By.NAME, "otp").send_keys(code)
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

        assert "Welcome" in driver.page_source
    finally:
        driver.quit()
```

### Example: pytest + Playwright for Python

```python
def test_signup_with_real_otp(otpbox_inbox, page):
    page.goto("https://your-app.example.com/signup")
    page.fill('[name="email"]', otpbox_inbox.address)
    page.click('button[type="submit"]')

    page.fill('[name="otp"]', otpbox_inbox.get_otp(timeout=20))
    page.click('button[type="submit"]')

    assert page.locator("text=Welcome").is_visible()
```

## Waiting for mail

`get_otp()` / `wait_for_otp()` and `get_verification_link()` / `wait_for_verification_link()` poll across *all* of an inbox's messages for one matching what's being waited for - not just the newest - the same way the OTPBox MCP server's `wait_for_email` + `get_otp` / `get_verification_link` tools behave. That matters when more than one email might land (e.g. a "welcome" email arriving before the "here's your code" one). Both raise `otpbox.Timeout` rather than returning `None`.

```python
code = client.get_otp(inbox, timeout=20, poll_interval=1.5)
link = client.get_verification_link(inbox, timeout=20, link_types=["verification"])  # optional filter
```

Pass `since=` (a timestamp in epoch ms, or a previously-seen `MessageSummary`) to ignore mail that arrived before a point in time - useful after triggering a "resend code":

```python
first = client.wait_for_email(inbox, timeout=20)
# trigger a resend, then:
second = client.wait_for_email(inbox, timeout=20, since=first)
```

## Batches

Create up to 50 inboxes in one call:

```python
batch = client.create_batch(count=20)
for item in batch.items:
    print(item.address, item.token)
```

## Idempotency keys

Pass `idempotency_key=` to `create_inbox()` / `create_batch()` so a retried CI step can't create a second resource - a retry with the same key returns the original response:

```python
import os
inbox = client.create_inbox(idempotency_key=f"signup-{os.environ['GITHUB_RUN_ID']}")
```

## Test identities

A synthetic persona (name, email, country, profile) backed by a real inbox. Templates: `us_customer`, `indian_customer`, `european_customer`, `business_customer`, `student`, `employee` (`otpbox.IDENTITY_TEMPLATES`).

```python
identity = client.create_identity("business_customer")
print(identity.name, identity.email, identity.profile["company"])
# identity.token is the backing inbox's bearer token - shown once, on creation only

many = client.create_identities("student", count=10)  # up to 50
client.delete_identity(identity.id)  # also deletes the backing inbox
```

## Usage and quota

```python
usage = client.usage()
# Usage(plan='free', quota_limit=200, used=12, period='2026-09', status='active', pooled=False)
print(f"{usage.used} / {usage.quota_limit or 'unlimited'} requests this month ({usage.remaining} left)")
```

Every authenticated call counts as one request, including `usage()` itself. Over the quota, calls raise `otpbox.QuotaExceeded` (a subclass of `otpbox.OtpBoxError`, `.status == 429`, `.code == 'quota_exceeded'`, plus `.used` / `.limit`).

## Webhooks

```python
webhook = client.create_webhook(
    "https://your-app.example.com/webhooks/otpbox",  # https only
    ["otp.extracted", "usage.approaching", "usage.exceeded"],
)
print(webhook.secret)  # save this - shown once

hooks = client.list_webhooks()
client.delete_webhook(webhook.id)
```

In your receiver (Flask example):

```python
import hashlib, hmac, os
from flask import Flask, abort, request
from otpbox import verify_webhook_signature

app = Flask(__name__)

@app.post("/webhooks/otpbox")
def otpbox_webhook():
    raw = request.get_data()  # bytes: the raw, unparsed body
    ok = verify_webhook_signature(os.environ["OTPBOX_WEBHOOK_SECRET"], raw, request.headers.get("x-otpbox-signature", ""))
    if not ok:
        abort(401)
    event = request.get_json(force=True)
    if event["event"] == "otp.extracted":
        print("code:", event["code"])
    return "", 200
```

Events: `inbox.created`, `inbox.deleted`, `inbox.expired`, `message.received`, `otp.extracted`, `link.detected`, `identity.created`, `identity.deleted`, `usage.approaching`, `usage.exceeded` (`otpbox.WEBHOOK_EVENTS`). Deliveries retry up to 5 attempts (5m/15m/1h/4h) and a webhook is disabled after 10 consecutive failures. See <https://otpbox.io/docs#webhooks> for every payload shape.

## Exceptions

All exceptions are `otpbox.OtpBoxError` (has `.status`, `.code`, `.details`) or a subclass:

| Exception | When |
|---|---|
| `AuthenticationError` | 401 (`missing_key`, `invalid_key`, `key_expired`) or 402 `license_inactive` |
| `InsufficientScope` | 403 `insufficient_scope` (`.required` names the missing scope) |
| `QuotaExceeded` | 429 `quota_exceeded` (`.used` / `.limit`) |
| `RateLimited` | 429 `rate_limited` (`.retry_after`, key-minting only) |
| `NotFound` | 404 `not_found` |
| `Timeout` | a `get_otp`/`get_verification_link`/`wait_for_email` call, or the underlying HTTP request, timed out |

## API reference

| Method | Description |
|---|---|
| `OtpBox(api_key=None, base_url=None, timeout=30)` | `api_key` defaults to `OTPBOX_API_KEY` env var (then `OTPBOX_KEY`); `base_url` to `OTPBOX_BASE_URL` env var, then `https://otpbox.io`. |
| `OtpBox.mint_free_key(base_url=None)` | Static method: `POST /api/v1/keys/free` - no key needed. |
| `create_inbox(domain=None, local=None, ttl_hours=None, *, idempotency_key=None)` | Create a disposable inbox. |
| `get_inbox(inbox)` / `delete_inbox(inbox)` | Fetch or delete an inbox. Every method that takes `inbox` accepts an id, an `Inbox`, or an `InboxHandle`. |
| `client.inbox(...)` | Context manager: creates on enter, deletes on exit. Yields an `InboxHandle` with `.get_otp()`, `.get_verification_link()`, `.wait_for_email()`, `.list_messages()`, `.delete()`. |
| `list_messages(inbox)` (alias `get_messages`) | List messages, newest first. |
| `get_message(message)` | Fetch one full message. |
| `wait_for_email(inbox, timeout=20, poll_interval=1.5, *, since=None)` | Block until a message arrives; raises `Timeout`. |
| `get_otp(inbox, ...)` (alias `wait_for_otp`) | Block until a message with a code arrives; returns the code. |
| `get_verification_link(inbox, ..., link_types=None)` (alias `wait_for_verification_link`) | Block until a message with a link arrives; returns the URL. |
| `usage()` (alias `get_usage`) | Plan, quota limit, usage, period, status, pooled. |
| `create_batch(count, ttl_hours=None, *, idempotency_key=None)` / `get_batch(id)` | Create/fetch up to 50 inboxes at once. |
| `create_identity(template, ttl_hours=None)` / `create_identities(template, count, ttl_hours=None)` / `get_identity(id)` / `delete_identity(id)` | Test identities. |
| `list_webhooks()` / `create_webhook(url, events)` (alias `register_webhook`) / `delete_webhook(id)` | Webhooks. |
| `verify_webhook_signature(secret, raw_body, signature_header)` | Module-level function; verifies an incoming delivery. |

## Also available

- **TypeScript SDK** (`otpbox-sdk` on npm) and **Playwright integration** (`otpbox-playwright`) - see [`../sdk`](../sdk) and [`../playwright-sdk`](../playwright-sdk).
- **MCP server** at `https://otpbox.io/mcp` for AI agent clients - see <https://otpbox.io/docs#mcp>.

## Development

```bash
pip install -e ".[dev]"
python -m pytest
python -m build
```

## License

MIT
