Metadata-Version: 2.4
Name: openemail
Version: 0.0.1
Summary: The official Python SDK for the OpenEmail API. Send email and broadcasts, work with threads, drafts, labels, templates, rules, webhooks, tracking, contacts, audiences, sign-up forms, domains, suppressions, files, API keys, roles, members, mailbox imports and disposable inboxes from code.
Project-URL: Homepage, https://openemail.uk
Project-URL: Documentation, https://openemail.uk/docs/python
Project-URL: Reference, https://openemail.uk/docs/python/reference/methods
Project-URL: Changelog, https://openemail.uk/docs/python/changelog
Project-URL: Support, https://openemail.uk/contact
Author: OpenEmail
License-Expression: LicenseRef-Proprietary
Keywords: api,api client,email,email api,email templates,inbox,mailbox,openemail,sdk,send email,temp mail,transactional email,webhooks
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: anyio>=3.7
Requires-Dist: httpx<1,>=0.27
Requires-Dist: typing-extensions>=4.12
Description-Content-Type: text/markdown

<div align='center'>
   <a href='https://openemail.uk'>
        <img
            src='https://openemail.uk/logo.svg'
            alt='OpenEmail Logo'
            width='180'
        />
   </a>

   <br />
</div>

<p align='center'>
    Email you can build on. Send mail, read the mailbox and automate a workspace from code.
</p>

<p align='center'>
    <a href='https://openemail.uk'>
        <b>
            Website
        </b>
    </a>
    •
    <a href='https://openemail.uk/docs/python'>
        <b>
            Documentation
        </b>
    </a>
    •
    <a href='https://openemail.uk/docs/python/reference/methods'>
        <b>
            Every method
        </b>
    </a>
    •
    <a href='https://openemail.uk/docs/api/reference'>
        <b>
            API Reference
        </b>
    </a>
</p>

<br />

## Intro to the Python Package

The official Python client for the OpenEmail API. A method for every one of the 336 documented operations, 430 in all once the paging and upload helpers are counted, typed end to end. There is a synchronous client and an asynchronous one with the same methods, it runs on Python 3.10 and newer, and it depends only on `httpx`, `anyio` and `typing-extensions`.

It carries a workspace API key or an OAuth access token, so it belongs on a server or in a tool that runs on your own machine. The one exception is disposable inboxes, which need no credential.

### Installing
```bash
pip install openemail
```

Or `uv add openemail`, or `poetry add openemail`.

### Using
```python
from openemail import OpenEmail

client = OpenEmail('oe_live_...')

sent = client.emails.send({
    'from': 'Acme Billing <billing@acme.com>',
    'to': 'ada@example.com',
    'subject': 'Your September invoice',
    'html': '<p>Your invoice is attached.</p>',
    'attachments': [{'filename': 'invoice.pdf', 'content': pdf_bytes, 'contentType': 'application/pdf'}],
})

print(sent['id'], sent['status'])
```

Request bodies are plain dictionaries with the API's own field names, so `'from'`, `'scheduledAt'` and `'replyTo'` read exactly as they do in the API reference. Responses are dictionaries too. Every body and response has a `TypedDict` in `openemail.types`, so your editor completes the keys and a type checker catches a misspelt one.

Create a key in OpenEmail under Settings, API keys. It is shown once, and it belongs in an environment variable rather than in code. `OpenEmail()` with no key reads `OPENEMAIL_API_KEY`:

```python
from openemail import OpenEmail

client = OpenEmail()
```

Build the client once, in a module of its own, and import it everywhere else. It keeps one connection pool, is safe to share between threads, and closes with `client.close()` or a `with` block.

Or skip even that. The package ships a ready made `openemail` client that reads `OPENEMAIL_API_KEY` the first time it is touched:

```python
from openemail import openemail

openemail.emails.send({'from': sender, 'to': recipient, 'subject': subject, 'text': text})
```

Every send carries an idempotency key, generated once per call and reused by its retries, so a retried request replays the original message rather than sending a second one. Pass your own with `idempotency_key=` to make that hold across processes and restarts.

### Async
```python
import asyncio

from openemail import AsyncOpenEmail


async def main() -> None:
    async with AsyncOpenEmail() as client:
        sent = await client.emails.send({'from': sender, 'to': recipient, 'subject': 'Hi', 'text': 'Hello'})

        async for thread in client.threads.iterate(folder='inbox'):
            print(thread['id'])

        print(sent['status'])


asyncio.run(main())
```

`AsyncOpenEmail` has every method `OpenEmail` has, with the same arguments, and runs on asyncio and trio.

### Reading the mailbox
```python
page = client.threads.list(folder='inbox', limit=25)

for thread in client.threads.iterate(folder='inbox', query='invoice'):
    full = client.threads.get(thread['id'])

    print(full['messageCount'], full['hasUnread'])
```

Every paginated resource has `list` for one page, `list_all` for every page at once and `iterate` to stream items and stop whenever you like. A page is `{'items': [...], 'hasMore': ..., 'nextCursor': ...}`, and `list_all` returns one list, apart from `addresses.list_all`, which returns the whole address book.

### Errors
```python
from openemail import OpenEmailApiError, openemail

try:
    openemail.templates.send('order-shipped', {
        'from': 'dispatch@acme.com',
        'to': 'ada@example.com',
        'props': {'orderId': 'AC-4192'},
    })
except OpenEmailApiError as error:
    if error.is_validation:
        print(error.code, error.param, error.request_id)

    raise
```

An API refusal is one class, `OpenEmailApiError`, with `status`, `type`, `code`, `param` and `request_id`, plus `is_validation`, `is_not_found`, `is_rate_limited` and friends to branch on. No response at all is `OpenEmailNetworkError`, with `is_timeout` when the deadline passed. Both inherit `OpenEmailError`. An argument the client can tell is wrong before anything is sent, such as a malformed key, raises `ValueError`.

### Webhooks
```python
import os

from fastapi import FastAPI, Request, Response
from openemail import verify_webhook_signature

app = FastAPI()


@app.post('/webhooks/openemail')
async def webhook(request: Request) -> Response:
    event = verify_webhook_signature(
        payload=await request.body(),
        headers=request.headers,
        secret=os.environ['OPENEMAIL_WEBHOOK_SECRET'],
    )

    print(event['type'], event['data'])

    return Response(status_code=204)
```

It checks the HMAC in constant time and rejects a delivery more than five minutes old, then returns the parsed event, or raises `WebhookVerificationError`. Pass the raw body as bytes or text: re-serialising it changes the bytes and the signature will not match. The headers can come from any framework, since the lookup ignores case.

### Disposable inboxes
```python
from openemail import create_temp_mail

temp = create_temp_mail()

inbox = temp.create({'ttlMinutes': 60})

messages = temp.list_messages(inbox['id'], inbox_token=inbox['token'])
```

`create` needs no credential and is the only call that returns the inbox token, so keep it.

### OAuth access tokens
An app a person connected to OpenEmail with OAuth, such as a command line tool or an agent, holds an access token rather than an API key. Pass it as `access_token`:

```python
from openemail import OpenEmail

client = OpenEmail(access_token=session.fresh_access_token)
```

`access_token` takes the token itself, or a function that returns it. The function runs before every request, so renew the token there when it is close to expiring and the client never has to be rebuilt. On `AsyncOpenEmail` the function may also be `async`. Pass `api_key` or `access_token`, not both. `OpenEmail()` reads `OPENEMAIL_ACCESS_TOKEN` when you pass neither and `OPENEMAIL_API_KEY` is not set. `me.get()` answers `'object': 'oauth_token'` for a token, with the connected app's `clientId` and `expiresAt`, when the person's approval of the app runs out.

A token acts for a person, so before a sensitive change, such as deleting a domain or changing a webhook, it is asked for the same verification code the web app asks for. The request fails with `is_step_up_required`. Ask for a code, check it, then replay the request:

```python
from openemail import OpenEmailApiError

try:
    client.domains.delete(domain_id)
except OpenEmailApiError as error:
    if not error.is_step_up_required:
        raise

    challenge = client.security.begin_step_up()

    if challenge['method'] == 'email':
        prompt = f'Enter the code we emailed to {challenge.get("sentTo")}: '
    else:
        prompt = 'Enter the code from your authenticator app, or a backup code: '

    client.security.verify_step_up({'code': input(prompt)})
    client.domains.delete(domain_id)
```

An emailed code works for 10 minutes, and `begin_step_up({'resend': True})` sends a fresh one. Once a code is verified the app is not asked again for 60 minutes. `security.step_up_status()` says whether it is verified right now. API keys are never asked for a code.

### Configuring
Pass keyword arguments when the defaults are not right:

```python
import os

import httpx
from openemail import OpenEmail

client = OpenEmail(
    os.environ['OPENEMAIL_API_KEY'],
    base_url='https://api.openemail.uk',
    timeout=30,
    max_retries=2,
    http_client=httpx.Client(proxy='http://proxy.internal:3128', follow_redirects=True),
    headers={'X-Team': 'billing'},
)
```

The shipped `openemail` client takes the same arguments through `init(...)`, called once at startup.

`base_url` also comes from `OPENEMAIL_BASE_URL`. Use an `https:` origin: the client refuses to send an API key, an access token or an inbox token over plain `http:`, and raises before the request leaves, unless the server is on this machine at `localhost`, a `127.x.x.x` address or `::1`. A `base_url` on `0.0.0.0` raises when the client is built, since that is the address a server listens on: use `127.0.0.1` with the same port.

`timeout` is in seconds and bounds the whole attempt, the response body included, and `0` turns it off. Reads are retried on 408 and 5xx with backoff. A 429 is retried only when it carries a `Retry-After`, and any wait longer than a minute raises instead of sleeping. Writes that cannot safely repeat are not retried. Every method outside `temp_mail` takes `api_key=` and `timeout=`, so one process can serve several workspaces with one client. The `temp_mail` methods take `inbox_token=` instead of `api_key=`.

An endpoint no method wraps yet is one `client.raw.request()` away, with the client's credential, base URL, timeout and retry policy applied:

```python
result = client.raw.request('/something-new', method='POST', body={'name': 'Invoices'})
```

The path must begin with a single `/`. Anything else, such as `//host/x`, raises before a request is sent, and so does a path whose finished URL leaves the base URL's origin, so the credential it carries never reaches another host.

When a newer version is on PyPI the client says so once on a terminal. `OPENEMAIL_DISABLE_UPDATE_NOTICE=1` or `disable_update_notice=True` turns that off.
