Python SDK

The Python client lives in python/ in the repository and is not published to PyPI yet. It needs Python 3.9 or newer and depends only on httpx. It covers the whole /v0 API, including pods, lists, suppressions, domains, search, metrics and billing reads.

pip install ./python                  # core
pip install "./python[websocket]"     # adds websockets for client.websockets.connect()

Quickstart#

from emailingagents import Client

client = Client(api_key="am_...", base_url="http://localhost:3000")
inbox = client.inboxes.create(username="support", display_name="Support Agent", client_id="support-v1")
sent = client.inboxes.messages.send(inbox.inbox_id, to="user@example.com", subject="Hello", text="Hi there", labels=["outreach"])
print(sent.message_id, sent.thread_id)

for message in client.inboxes.messages.list_all(inbox.inbox_id):
    print(message.subject)   # list responses carry a preview; call messages.get(...) for extracted_text

api_key and base_url fall back to the EMAILINGAGENTS_API_KEY and EMAILINGAGENTS_BASE_URL environment variables. Always set the base URL; the default is http://localhost:3000. Responses are dictionaries that also allow attribute access (inbox.inbox_id), with the snake_case wire names; a key that is a Python keyword gets a trailing underscore (message.from_).

Resources#

inboxes (with .messages, .threads, .drafts, .webhooks, .lists), threads, drafts, webhooks, pods (with .inboxes, .webhooks, .domains, .api_keys, .lists), api_keys, domains, lists, suppressions, billing, metrics (usage, events, rates), events and agent. Methods that return a page also have a list_all variant that follows next_page_token for you. Send, reply, reply-all, forward and draft calls accept track_opens=True when the backend supports open tracking.

draft = client.inboxes.drafts.create(inbox.inbox_id, to="a@example.com", subject="Later", text="...")
client.inboxes.drafts.send(inbox.inbox_id, draft.draft_id)
client.inboxes.messages.update(inbox.inbox_id, sent.message_id, add_labels=["resolved"], remove_labels=["unread"])
hits = client.inboxes.messages.search(inbox.inbox_id, "demo next week")
usage = client.metrics.usage(usage_types=["message_count"], period=3600)

Async#

AsyncClient has the same surface. Every call is awaited and list_all is an async iterator.

import asyncio
from emailingagents import AsyncClient

async def main():
    async with AsyncClient(api_key="am_...", base_url="http://localhost:3000") as client:
        async for thread in client.threads.list_all(limit=50):
            print(thread.subject)

asyncio.run(main())

Errors#

Non-2xx responses raise a subclass of ApiError carrying status, code, message, fix and body: ValidationError (400), AuthenticationError (401), PermissionError_ (403), NotFoundError (404), ConflictError (409, or code already_exists, which the API returns as 403), UnprocessableError (422), RateLimitError (429, with retry_after seconds) and ServerError (5xx). Connection failures raise TransportError. See errors for the codes.

from emailingagents import NotFoundError

try:
    client.inboxes.get("missing")
except NotFoundError as e:
    print(e.status, e.code, e.fix)

Retries and idempotency#

The client retries up to max_retries times (default 2) with exponential backoff. A 429 is retried for every call and honours Retry-After. Network errors and 500, 502, 503 and 504 are retried only when replaying is safe: GET and DELETE, or a call that carries idempotency_key= (sent as the Idempotency-Key header) or a client_id.

client.inboxes.messages.send(inbox.inbox_id, to="a@example.com", text="Hi", idempotency_key="order-4821-confirmation")

Other options: timeout (seconds, default 30), default_headers, http_client and transport for a custom httpx setup.

Verify webhooks#

Pass the raw request body, the headers and the whsec_ secret that webhooks.create returned once. See webhooks for the scheme.

from emailingagents import verify_webhook, WebhookVerificationError

try:
    event = verify_webhook(request.get_data(), request.headers, secret)
except WebhookVerificationError:
    return "bad signature", 400
print(event.event_type)

A valid signature can be replayed inside the tolerance window, so dedupe on svix-id (retries of one event reuse it). Keep seen ids for at least the tolerance:

event = verify_webhook(body, headers, secret)
if not seen.add_if_new(headers["svix-id"], ttl=600):   # e.g. redis SET NX EX 600, or a unique DB column
    return "", 200                                      # already handled
handle(event)

Websockets#

Install the websocket extra, then:

with client.websockets.connect(inbox_ids=[inbox.inbox_id], event_types=["message.received"]) as socket:
    for event in socket:
        print(event.event_type)

The async client uses async with ... as socket and async for event in socket. The key is sent in an Authorization: Bearer header so it stays out of URLs and proxy logs; pass api_key_in_query=True to connect() only if you cannot set headers. See websockets.

To give an AI agent these capabilities as tools, see the LangChain integration.