diff --git a/README.md b/README.md index acb884f..43cde77 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,24 @@ millionsend.Batch.send(payloads, batch_validation="permissive") ``` - `idempotency_key` → `Idempotency-Key` header (POST only). A replay with the same key and body returns the original ids; a different body raises `InvalidIdempotentRequestError`. -- `batch_validation` → `x-batch-validation` header: `"strict"` (default) rejects the whole batch on the first invalid item; `"permissive"` processes the valid items and lists the rest in the response's `errors[]` (`{index, message}`). +- `batch_validation` → `x-batch-validation` header: `"strict"` (default) rejects the whole batch on the first invalid item; `"permissive"` processes the valid items and lists the rest in the response's `errors[]`: `{index, message}` for emails, plus `statusCode` and `name` for contacts. + +### Pagination + +Keyset lists return `{"object": "list", "has_more", "data"}` and take `limit`, `after` and `before`. Most are newest first: `after` takes a page's last id and pages toward older items, and `before` takes its first id and pages toward newer ones (still newest first inside the page). `has_more` refers to the direction you page in. Threads are most recently active first, a thread's emails oldest first (`after` pages toward newer), and drafts most recently updated first; labels and topics aren't paginated. The SDK doesn't page for you. + +To sync only what's new, page toward newer items from the newest id you stored: + +```python +page = millionsend.Emails.list(before=newest_stored_id, limit=100) +while page.data: + store(page.data) + if not page.has_more: + break + page = millionsend.Emails.list(before=page.data[0].id, limit=100) +``` + +A cursor whose item was deleted raises `ValidationError` (422) on the top-level lists and `Emails.Receiving.list`, and `NotFoundError` (404) on the `Inboxes` lists: start again without a cursor and stop at ids you already have. ## Errors @@ -83,9 +100,23 @@ except MillionSendError as e: - `e.code` is the stable `name` discriminant (`validation_error`, `not_found`, `restricted_api_key`, `sending_paused`, `invalid_idempotent_request`, …). - `e.status_code` is the HTTP status, or `None` for client-side/transport failures (connection refused, DNS, timeout). +- `e.retry_after` is the response's `retry-after` header in seconds, or `None` when it had none (see [Retries](#retries)). - `Emails.send` / `Batch.send` raise `AllRecipientsSuppressedError` (422 `all_recipients_suppressed`) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`. -Subclasses: `MissingApiKeyError`, `InvalidApiKeyError`, `ValidationError`, `AllRecipientsSuppressedError`, `InvalidParameterError`, `InvalidPayloadError`, `PayloadTooLargeError`, `NotFoundError`, `ConflictError`, `ForbiddenError`, `RestrictedApiKeyError`, `SendingPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`, `PlanLimitReachedError`, `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InternalServerError`, `ApplicationError`. Unknown names raise the base `MillionSendError`. +Subclasses: `MissingApiKeyError`, `InvalidApiKeyError`, `ValidationError`, `AllRecipientsSuppressedError`, `InvalidParameterError`, `InvalidPayloadError`, `PayloadTooLargeError`, `NotFoundError`, `ConflictError`, `ForbiddenError`, `RestrictedApiKeyError`, `SendingPausedError`, `RateLimitExceededError`, `DailyQuotaExceededError`, `PlanLimitReachedError`, `InvalidIdempotentRequestError`, `ConcurrentIdempotentRequestsError`, `InboxSendPolicyError`, `InboxSendCapReachedError`, `ApprovalRequiredError`, `EmailQuarantinedError`, `EmailBlockedError`, `TooManyWaitsError`, `NameReviewUnavailableError`, `InternalServerError`, `ApplicationError`. Unknown names raise the base `MillionSendError`. A body that isn't JSON, such as an older server's plain-text 404 for an unknown route, raises `ApplicationError` with the HTTP status. Messages are for people and may change: branch on `e.code` and `e.status_code`, not on the message. + +### Retries + +The SDK never retries by itself. After `e.retry_after` seconds it's worth retrying: + +- `NameReviewUnavailableError` (503; 60 seconds, or 300 while a new team's subdomain waits for its review); +- `RateLimitExceededError` when `e.retry_after` is 60 or less (the per-minute limits); +- `TooManyWaitsError`. + +`InboxSendCapReachedError` and a `RateLimitExceededError` for the day's name-review budget last until the next UTC midnight, so don't retry them in the same process; the review budget shares the per-minute limits' `rate_limit_exceeded` name, so decide on `e.retry_after`. Don't retry a 403, 404, 409 or 422. + +- Sends (`Emails.send`, `Batch.send`, replies, forwards, `Drafts.send`): resend with the same `idempotency_key`. A batch replay returns the stored response, even if a recipient was refused or suppressed since. `{"kind": "draft"}` (HTTP 202) is a held draft, not a failure: never resend it. +- `Inboxes.create` takes no idempotency key. After a 503 or 429 nothing was created, so creating again is safe; after a timeout it may create a second inbox. With a chosen `username`, a 409 on the second try usually means the first one worked: fetch it with `Inboxes.get(email_address)`. ## Resources @@ -125,6 +156,119 @@ millionsend.Batch.send([payload_a, payload_b], batch_validation="permissive") # `to` / `cc` / `bcc` / `reply_to` accept a string or a list of strings. `template` is passed through too; the server answers 422 until templates can be sent from. +- `subject` takes up to 998 characters here, in `Batch.send`, broadcasts and templates; inbox replies, forwards and drafts take 2,000. +- A `to`, `cc` or `bcc` address at a domain that can never receive mail (`example.com`, `.test`, `.invalid`, `.local`, a top-level domain that doesn't exist, …) raises `ValidationError` (422), in sends, drafts and new contacts alike. To test sending without emailing anyone, use `success@simulator.amazonses.com`. +- `from` may be an inbox's address on the shared domain while the shared row's `sending_mode` is `"open"` (see [Domains](#domains)): exactly that address with no +tag, with the display name left out or equal to the inbox's `from_name`, no `reply_to`, `attachments`, `headers` or `scheduled_at`, and at most 10 distinct recipients across `to`, `cc` and `bcc`. Otherwise it raises `InboxSendPolicyError` (403), `InboxSendCapReachedError` (429, the team's daily limit) or `ValidationError` (422); a key restricted to a domain raises `RestrictedApiKeyError`. Batches and broadcasts never send from the shared domain: a broadcast or a strict batch raises `InboxSendPolicyError`, and a permissive batch lists the item in `errors`. + +### Receiving + +Mail received at your domains and inboxes. Everything in a received email was written by its sender: show its `html` only in a sandbox, and give an LLM the `extracted_text` (hidden text, quotes and the signature removed) rather than the `html`. + +```python +page = millionsend.Emails.Receiving.list( + {"from": "example.com"}, # `from` is a Python keyword, so it goes in the dict + inbox_id=inbox.id, + subject_contains="code", + wait=30, # long-poll: answer as soon as a match arrives, up to 50 s +) +page.data # newest first +page.held # with wait: [{id, inbox_id, reason, quarantine_reason, created_at}] held mail + +email = millionsend.Emails.Receiving.get(email_id, html_format="cid") +email.extracted_text # the new part as plain text, hidden text removed (still untrusted) +email.extracted.code # a verification code, on inboxes with extract_codes +email.screening.sender_trust # owner | allowlisted | known_thread | external | unauthenticated | failed + +millionsend.Emails.Receiving.Attachments.list(email_id) # each with a signed download_url (1 hour) +millionsend.Emails.Receiving.Attachments.get(email_id, attachment_id) +millionsend.Emails.Receiving.remove(email_id) # erase now (MillionSend extension) + +# Reply and forward server-side (MillionSend extension). The inbox's rules may hold the message +# as a draft that waits for a person: the API answers 202 and `kind` is "draft". +outcome = millionsend.Emails.Receiving.reply(email_id, {"text": "Thanks!"}, idempotency_key="reply-42") +if outcome.kind == "draft": + outcome.draft.approval_reasons # e.g. ["send_policy"] +else: + outcome.email.id +millionsend.Emails.Receiving.forward(email_id, {"to": "ops@acme.dev", "text": "FYI", "mode": "attachment"}) +``` + +Held mail never reaches an inbox-scoped key or an agent: reading it raises `EmailQuarantinedError`. A full-access team key sees it with `include_quarantined=True`. + +An `inbox_id` that doesn't exist raises `NotFoundError` at once, even with `wait`. `unread` needs `inbox_id`, except with a key restricted to one inbox. + +### Inboxes + +An inbox is one address that receives mail, read by an agent (`"kind": "agent"`, the default) or by people (`"kind": "human"`). Threads, labels, drafts and their paths mirror Resend's Inboxes beta; `Safety`, `Codes` and `Quarantine` are MillionSend extensions. + +```python +inbox = millionsend.Inboxes.create({ + "name": "Signup bot", # 1-320 characters + "username": "signup-bot", # no email_address: signup-bot@.; omitted, a random one + "expires_in": "24h", # "1h" to "365d"; omitted or None, the inbox never expires + "safety": {"accept_from": "anyone"}, +}) +millionsend.Inboxes.create({"email_address": "support@acme.dev", "name": "Support", "kind": "human"}) +millionsend.Inboxes.create({"domain_id": domain.id, "username": "billing"}) # billing@ one of your domains +millionsend.Inboxes.list(kind="agent") +millionsend.Inboxes.get("support@acme.dev") # by id or address +millionsend.Inboxes.update(inbox.id, {"from_name": "Acme", "expires_in": "7d"}) +millionsend.Inboxes.remove(inbox.id) # also revokes its inbox-scoped keys + +# Codes and sign-in links, without parsing HTML (wait long-polls up to 50 s) +code = millionsend.Inboxes.Codes.latest(inbox.id, {"from": "example.com"}, wait=30) +code.status # "found" | "none" | "ambiguous" | "timeout"; code.code, code.url +# A second sign-in: only mail received after the first code counts +millionsend.Inboxes.Codes.latest(inbox.id, {"from": "example.com"}, wait=30, since=code.received_at) + +# Safety rules: who can write in, what the inbox may send +millionsend.Inboxes.Safety.get(inbox.id) +millionsend.Inboxes.Safety.update(inbox.id, {"accept_from": "allowlist", "allowlist": ["*@acme.dev"]}) + +# Quarantine: held mail, kept until a person releases it +millionsend.Inboxes.Quarantine.list(inbox.id) +millionsend.Inboxes.Quarantine.release(inbox.id, email_id, trust_sender=True) + +# Threads and their messages +millionsend.Inboxes.Threads.list(inbox.id, folders=["inbox", "archive"], read=False) +millionsend.Inboxes.Threads.search(inbox.id, {"query": "invoice", "from": ["example.com"]}) +millionsend.Inboxes.Threads.get(inbox.id, thread_id) +millionsend.Inboxes.Threads.update(inbox.id, thread_id, {"read": True, "folder": "archive", "label_id": label_id}) +millionsend.Inboxes.Threads.remove(inbox.id, thread_id) # to trash, purged after 30 days +millionsend.Inboxes.Threads.Emails.list(inbox.id, thread_id) # oldest first; include_quarantined=True adds held mail +millionsend.Inboxes.Threads.Emails.get(inbox.id, thread_id, email_id) +millionsend.Inboxes.Threads.Emails.reply(inbox.id, thread_id, email_id, {"text": "Done."}, idempotency_key=key) +millionsend.Inboxes.Threads.Emails.forward(inbox.id, thread_id, email_id, {"to": "ops@acme.dev"}) + +# Labels (up to 100 per inbox) +millionsend.Inboxes.Labels.create(inbox.id, {"name": "Billing", "color": "#FF8800"}) +millionsend.Inboxes.Labels.list(inbox.id) +millionsend.Inboxes.Labels.update(inbox.id, label_id, {"name": "Invoices"}) +millionsend.Inboxes.Labels.remove(inbox.id, label_id) + +# Drafts, and the ones that wait for approval +millionsend.Inboxes.Drafts.create(inbox.id, {"to": "success@simulator.amazonses.com", "subject": "Hi", "text": "..."}) +millionsend.Inboxes.Drafts.list(inbox.id) +millionsend.Inboxes.Drafts.get(inbox.id, draft_id) +millionsend.Inboxes.Drafts.update(inbox.id, draft_id, {"cc": None}) # None clears +millionsend.Inboxes.Drafts.send(inbox.id, draft_id, idempotency_key=key) # {"kind": "sent" | "draft", ...} +millionsend.Inboxes.Drafts.reject(inbox.id, draft_id) +millionsend.Inboxes.Drafts.remove(inbox.id, draft_id) +``` + +Replies, forwards and `Drafts.send` return `{"kind": "sent", "email": ...}` or, when the inbox's rules hold the message for a person (HTTP 202), `{"kind": "draft", "draft": ...}`. A key restricted to the inbox cannot send a draft that waits for approval (`ApprovalRequiredError`); a person in the dashboard or a full-access team key approves it. An agent's reply to a `Reply-To` at another organization is held the same way, with `"reply_to_redirect"` among `approval_reasons`. Hosted agent settings (`/inboxes/{id}/agent`) are not exposed: MillionSend runs no agent of its own. + +With `include_quarantined=True` (full-access team keys), the items of `Threads.list` and `Threads.search` carry `quarantined`: their held messages not yet released. + +#### Inboxes on the shared domain + +Without `email_address`, an inbox gets an address on the instance's shared domain, `@.`. `username` is trimmed and lowercased: 1-64 of `a-z 0-9 . _ -`, starting and ending with a letter or digit. Without it the address is random (`inbox-…`). `domain_id` takes a row of `Domains.list()` instead: the shared row works like omitting `email_address`, and one of your own domains needs `username`, the part before the @, and receiving on (or `"forwarding": True`). A shared inbox's `domain_id` is the shared row's id (see [Domains](#domains)). + +- Names are reviewed: a reserved (`postmaster`, `admin`, `noreply`, …), `fwd-` or brand look-alike `username`, or a brand look-alike `from_name`, raises `ValidationError` (422; 409 when the address is taken). `NameReviewUnavailableError` (503) means the review can't run right now, and `RateLimitExceededError` (429) that the day's review budget is spent; both carry `retry_after`. +- An inbox there with no mail in or out and no API call for 90 days is deleted (`inbox.expired`, then `inbox.deleted`). +- Sending follows the shared row's `sending_mode`: `"reply_only"` lets through only in-thread replies to the thread's own correspondents, and `"receive_only"` nothing. A reply, forward or draft it forbids raises `InboxSendPolicyError` (403), `InboxSendCapReachedError` (429) means the team reached today's limit, and more than 10 recipients raise `ValidationError` (422). A person or a team key sending a draft skips the inbox's rules, not the shared domain's. +- In `Safety`, `send_policy` can't be `"allowlist"`, and is `"open"` only while `sending_mode` is `"open"`. `owner_addresses` don't make senders trusted there, and `forward_to` copies go only to team members' sign-in addresses. + ### Contacts Contacts are team-global — one record per email address, no audiences to manage. @@ -160,7 +304,7 @@ result = millionsend.Contacts.Batch.create( ) result.data[0].status # created | updated | skipped result.counts.failed -result.errors # permissive mode: [{index, message}] +result.errors # permissive mode: [{index, statusCode, name, message}] # Bulk lookup (MillionSend extension) — up to 1000 contacts by id or email in one request, in request order; # unknown entries are listed under `missing`, not errors — one request against the rate limit @@ -290,9 +434,18 @@ millionsend.Domains.list() millionsend.Domains.get(domain.id) millionsend.Domains.verify(domain.id) millionsend.Domains.update({"id": domain.id, "open_tracking": False, "tracking_subdomain": None}) +millionsend.Domains.update({"id": domain.id, "receiving_catch_all": True}) # accept mail for addresses without an inbox millionsend.Domains.remove(domain.id) ``` +`Domains.list()` ends with the team's subdomain of the shared domain, after all its own domains (alone on the next page when they fill a page exactly): the row with `shared: True`, whose id is the `domain_id` of the team's shared inboxes. It's read-only (`update`, `verify` and `remove` raise `ForbiddenError`) and only sends from an inbox's own address (see [Emails](#emails)). Only that row carries `sending_mode`: `"open"` starts conversations, `"reply_only"` replies inside threads, and `"receive_only"` sends nothing. `sending_mode_reason` says what holds it below `"open"`, `"plan"` or `"instance"` (`None` when open). The row is missing when the instance has no shared domain, the server predates v0.14.0, or a new team's subdomain still waits for its name review. + +To pick a sender domain, skip the shared row. Servers before v0.14.0 send no `shared` key, so read it with `get`: + +```python +sender_domains = [d for d in millionsend.Domains.list().data if d.status == "verified" and not d.get("shared")] +``` + ### Webhooks ```python @@ -315,15 +468,20 @@ rotated.signing_secret rotated.previous_secret_expires_at # None once the old secret stops signing; also on get() ``` +Events include `email.received` and the `inbox.*` events (`inbox.created`, `inbox.updated`, `inbox.deleted`, `inbox.expired`, `inbox.thread.created`, `inbox.thread.folder.updated`, `inbox.thread.labels.updated`, `inbox.email.received`, `inbox.email.sent`, `inbox.email.quarantined`, `inbox.draft.created`, `inbox.draft.updated`, `inbox.draft.sent`, `inbox.draft.deleted`). Inbox events reach only endpoints that list them by name. `inbox_ids` limits an endpoint's inbox events and `email.received` to those inboxes (`None` = every inbox). A shared-domain inbox deleted after 90 idle days fires `inbox.expired`, then `inbox.deleted`, and inbox events from a send in the dashboard carry `data.source: "dashboard"`. + ### API keys ```python key = millionsend.ApiKeys.create({"name": "ci", "permission": "sending_access", "domain_id": domain.id}) key.token # shown once +millionsend.ApiKeys.create({"name": "signup-agent", "permission": "full_access", "inbox_id": inbox.id}) millionsend.ApiKeys.list() millionsend.ApiKeys.remove(key.id) ``` +`domain_id` must be one of the team's own domains: the shared row's id raises `ValidationError`. + ### Templates Addressable by id or alias. @@ -369,6 +527,21 @@ account = millionsend.Deliverability.get() # trailing-30-day account print(account.score, account.band, account.guardrail_status) # scores are None until enough data ``` +## Server versions + +Self-hosting? Each feature needs at least this MillionSend version: + +| Feature | Server | +|---|---| +| `Emails.Receiving` list, get and attachments | v0.8.0 | +| `Inboxes`; `Emails.Receiving` reply, forward, remove, `wait` and the quarantine filters; `inbox_ids` on webhooks, `inbox_id` on API keys, `receiving_catch_all` on domains | v0.10.0 | +| Recipients that can never receive mail refused; a batch `idempotency_key` replayed before validation | v0.11.0 | +| `username`; inboxes that never expire by default, `expires_in` `"1h"` to `"365d"`; sending from the shared domain; name reviews (`NameReviewUnavailableError`); `statusCode` and `name` on contacts-batch errors | v0.12.0 | +| Newest-first lists; a JSON 404 for unknown routes; `since` on `Codes.latest`; `quarantined` on thread items; inbox names up to 320 characters; `NotFoundError` at once for an unknown `inbox_id`; `unread` with an inbox-scoped key | v0.13.0 | +| The shared row in `Domains.list()` (`shared`, `sending_mode`, `sending_mode_reason`); `domain_id` on `Inboxes.create`; `subject` up to 998 characters | v0.14.0 | + +Older servers ignore options they don't know: before v0.12.0 `username` is dropped and the address is random, before v0.14.0 `domain_id` is dropped and the inbox lands on the shared domain, and before v0.13.0 `since` is dropped, so a code you already used can come back. If you must support them, check the created inbox's `email_address`, and treat a code whose `received_at` isn't after `since` as none. + ## Migrating from Resend ```diff @@ -386,8 +559,8 @@ Method names and payloads match. Notes: - **Same resources**: `Emails`, `Batch`, `Contacts` (with `.Topics`, `.Segments`), `ContactProperties`, `Topics`, `Broadcasts`, `Suppressions` (with `.Batch`), `Domains`, `Webhooks`, `ApiKeys`, `Templates`. Payloads are sent verbatim, so a resend-python payload works as-is. - **No audiences**: contacts are team-global, so there is no `Audiences` resource and no `audience_id` params. The API's `/audiences/...` routes are a compatibility shim for raw HTTP callers and are deliberately not exposed here. Resend's `Segments` is an alias of audiences; MillionSend's `Segments` is the distinct dynamic-filter feature. -- **MillionSend extensions** (no Resend equivalent): `Segments`, `Contacts.Batch`, `Contacts.list(segment_id=...)`, `Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`, `Emails.get_insights`. -- **Not available**: Resend's `ApiKeys.update`, `Webhooks` event history/replay/`verify`, `Emails.share` / `Emails.metrics` / receiving, `Broadcasts.recipients` / `clicked_links`, `Contacts.Segments.list`, `DomainClaims`, `ContactImports`, `Automations`, `Events`, `Logs`, `OAuthGrants`, and the `*_async` variants. +- **MillionSend extensions** (no Resend equivalent): `Segments`, `Contacts.Batch`, `Contacts.list(segment_id=...)`, `Contacts.preferences_link`, `Webhooks.rotate`, `Usage`, `Deliverability`, `Emails.get_insights`, `Emails.Receiving.reply` / `forward` / `remove`, and `Inboxes` (Resend's Inboxes are not in resend-python yet). +- **Not available**: Resend's `ApiKeys.update`, `Webhooks` event history/replay/`verify`, `Emails.share` / `Emails.metrics`, `Broadcasts.recipients` / `clicked_links`, `Contacts.Segments.list`, `DomainClaims`, `ContactImports`, `Automations`, `Events`, `Logs`, `OAuthGrants`, and the `*_async` variants. - MillionSend raises on API errors just like `resend`; the exception carries `.code` / `.status_code` / `.message`. ## License diff --git a/millionsend/__init__.py b/millionsend/__init__.py index 6e6c8e0..6f61043 100644 --- a/millionsend/__init__.py +++ b/millionsend/__init__.py @@ -34,10 +34,15 @@ from .errors import ( AllRecipientsSuppressedError, ApplicationError, + ApprovalRequiredError, ConcurrentIdempotentRequestsError, ConflictError, DailyQuotaExceededError, + EmailBlockedError, + EmailQuarantinedError, ForbiddenError, + InboxSendCapReachedError, + InboxSendPolicyError, InternalServerError, InvalidApiKeyError, InvalidIdempotentRequestError, @@ -45,14 +50,17 @@ InvalidPayloadError, MillionSendError, MissingApiKeyError, + NameReviewUnavailableError, NotFoundError, PayloadTooLargeError, PlanLimitReachedError, RateLimitExceededError, RestrictedApiKeyError, SendingPausedError, + TooManyWaitsError, ValidationError, ) +from .inboxes import Inboxes from .segments import Segments from .suppressions import Suppressions from .templates import Templates @@ -75,6 +83,7 @@ "allow_insecure_http", "Emails", "Batch", + "Inboxes", "Contacts", "ContactProperties", "Topics", @@ -106,6 +115,13 @@ "PlanLimitReachedError", "InvalidIdempotentRequestError", "ConcurrentIdempotentRequestsError", + "InboxSendPolicyError", + "InboxSendCapReachedError", + "ApprovalRequiredError", + "EmailQuarantinedError", + "EmailBlockedError", + "TooManyWaitsError", + "NameReviewUnavailableError", "InternalServerError", "ApplicationError", "__version__", diff --git a/millionsend/_client.py b/millionsend/_client.py index 61f7e26..da5a22b 100644 --- a/millionsend/_client.py +++ b/millionsend/_client.py @@ -15,9 +15,11 @@ from .errors import MillionSendError, MissingApiKeyError, raise_api_error -VERSION = "0.8.0" +VERSION = "0.9.0" DEFAULT_BASE_URL = "https://api.millionsend.com" DEFAULT_TIMEOUT = 60.0 +# Seconds a long-poll answer may take to arrive after the server's own ``wait`` deadline. +LONG_POLL_MARGIN = 10.0 _JSON_METHODS = ("POST", "PATCH") _LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1") @@ -91,6 +93,15 @@ def path_id(value: Any) -> str: return quote(str(value), safe="") +def _has_dot_segment(path: str) -> bool: + """Whether a path has an empty, "." or ".." segment. + + URL normalization resolves those away, so an id such as ".." would send the + request to another resource (DELETE /inboxes/{id}/labels/.. deletes the inbox). + """ + return any(segment in ("", ".", "..") for segment in path.split("/")[1:]) + + def split_id(params: Dict[str, Any], *keys: str) -> Tuple[str, Dict[str, Any]]: """Pop the target id out of a resend-python style ``{"id": ..., **fields}`` dict. @@ -120,10 +131,21 @@ def list_query( else: query = {"limit": limit, "after": after, "before": before} query.update({k: v for k, v in extra.items() if v is not None}) - query = {k: v for k, v in query.items() if v is not None} + query = {k: _query_value(v) for k, v in query.items() if v is not None} + # An empty list stays off the wire: ``labels=`` is not "no labels". + query = {k: v for k, v in query.items() if v != ""} return query or None +def _query_value(value: Any) -> Any: + """Booleans as the API reads them ("true"/"false", not Python's "True"); lists comma-separated.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + return ",".join(str(v) for v in value) + return value + + def request_options( options: Options = None, idempotency_key: Optional[str] = None, @@ -151,6 +173,32 @@ def request( batch_validation: Optional[str] = None, ) -> Any: """Dispatch one request and return the wrapped JSON body, or raise on error.""" + _status, parsed = _dispatch(method, path, body, query, idempotency_key, batch_validation) + return _wrap(parsed) + + +def request_outcome(path: str, *, body: Any = None, idempotency_key: Optional[str] = None) -> Response: + """POST a send a person may have to approve (replies, forwards, draft sends). + + Returns ``{"kind": "sent", "email": ...}``, or on an HTTP 202 + ``{"kind": "draft", "draft": ...}``: the draft the API held for approval. + """ + status, parsed = _dispatch("POST", path, body, None, idempotency_key, None) + if status == 202: + return Response(kind="draft", draft=_wrap(parsed)) + return Response(kind="sent", email=_wrap(parsed)) + + +def _dispatch( + method: str, + path: str, + body: Any, + query: Optional[Dict[str, Any]], + idempotency_key: Optional[str], + batch_validation: Optional[str], +) -> Tuple[int, Any]: + if _has_dot_segment(path): + raise ValueError('an id in the request path is empty, "." or ".."') url = _base_url() + path headers = { "Authorization": f"Bearer {_api_key()}", @@ -167,6 +215,13 @@ def request( if batch_validation and method == "POST": headers["x-batch-validation"] = batch_validation + timeout = _config("timeout") or DEFAULT_TIMEOUT + # A ``wait`` query asks the API to hold its answer up to that many seconds + # (long-poll), so the deadline has to outlast it. + wait = (query or {}).get("wait") + if isinstance(wait, (int, float)) and not isinstance(wait, bool) and wait > 0: + timeout = max(timeout, wait + LONG_POLL_MARGIN) + try: resp = requests.request( method, @@ -174,7 +229,7 @@ def request( headers=headers, data=data, params=query, - timeout=_config("timeout") or DEFAULT_TIMEOUT, + timeout=timeout, ) except requests.exceptions.RequestException as exc: raise MillionSendError( @@ -190,5 +245,5 @@ def request( parsed = text if not 200 <= resp.status_code < 300: - raise_api_error(resp.status_code, parsed) - return _wrap(parsed) + raise_api_error(resp.status_code, parsed, resp.headers.get("retry-after")) + return resp.status_code, parsed diff --git a/millionsend/api_keys.py b/millionsend/api_keys.py index 7dc3100..1595a57 100644 --- a/millionsend/api_keys.py +++ b/millionsend/api_keys.py @@ -8,7 +8,8 @@ class ApiKeys: @classmethod def create(cls, params: Dict[str, Any]) -> Any: - """POST /api-keys — name, permission? (full_access|sending_access), domain_id?""" + """POST /api-keys — name, permission? (full_access|sending_access), domain_id? (one of the + team's own domains: a GET /domains row with ``shared: False``), inbox_id?""" return request("POST", "/api-keys", body=params) @classmethod diff --git a/millionsend/broadcasts.py b/millionsend/broadcasts.py index 7c708e0..db10e37 100644 --- a/millionsend/broadcasts.py +++ b/millionsend/broadcasts.py @@ -3,6 +3,8 @@ Targeting is an optional ``segment_id`` and/or ``topic_id`` on create/update; neither set means every contact of the team. ``update`` and ``send`` take ``(broadcast_id, ...)`` or resend-python's single ``{"broadcast_id": ..., **fields}`` dict. +Broadcasts never send from the shared domain: ``send`` (or ``create`` with ``send: True``) +raises ``InboxSendPolicyError``. """ from typing import Any, Dict, Optional, Tuple, Union diff --git a/millionsend/contacts.py b/millionsend/contacts.py index a9e755a..fac133d 100644 --- a/millionsend/contacts.py +++ b/millionsend/contacts.py @@ -173,7 +173,5 @@ def list( """ query = list_query(limit, after, before, include=include) or {} segment_id = query.pop("segment_id", segment_id) - if isinstance(query.get("include"), list): - query["include"] = ",".join(query["include"]) path = f"/segments/{path_id(segment_id)}/contacts" if segment_id else "/contacts" return request("GET", path, query=query or None) diff --git a/millionsend/domains.py b/millionsend/domains.py index 07dbc8a..eb8b6d6 100644 --- a/millionsend/domains.py +++ b/millionsend/domains.py @@ -1,4 +1,9 @@ -"""Sending domains — create returns the DNS records to publish; verify re-checks them.""" +"""Sending domains — create returns the DNS records to publish; verify re-checks them. + +GET /domains ends with the team's subdomain of the shared domain (``shared: True``), which +only sends from an inbox's own address. That row is read-only: ``update``, ``verify`` and +``remove`` raise ``ForbiddenError``. Servers before MillionSend v0.14.0 send no ``shared`` key. +""" from typing import Any, Dict, Optional @@ -22,6 +27,7 @@ def list( after: Optional[str] = None, before: Optional[str] = None, ) -> Any: + """GET /domains — the team's own domains newest first, then the shared row.""" return request("GET", "/domains", query=list_query(limit, after, before)) @classmethod diff --git a/millionsend/emails.py b/millionsend/emails.py index d5011fb..5529936 100644 --- a/millionsend/emails.py +++ b/millionsend/emails.py @@ -9,9 +9,13 @@ from typing import Any, Dict, List, Optional from ._client import Options, list_query, path_id, request, request_options, split_id +from .receiving import Receiving class Emails: + # Mirrors resend-python: Emails.Receiving.list(), Emails.Receiving.Attachments.get(...). + Receiving = Receiving + @classmethod def send( cls, @@ -19,7 +23,13 @@ def send( options: Options = None, idempotency_key: Optional[str] = None, ) -> Any: - """POST /emails — supports an Idempotency-Key.""" + """POST /emails — supports an Idempotency-Key. + + ``from`` may be an inbox's address on the shared domain while its ``sending_mode`` is + ``"open"``: that exact address (no +tag), with no display name or the inbox's + ``from_name``, no ``reply_to``, ``attachments``, ``headers`` or ``scheduled_at``, and at + most 10 distinct recipients across to, cc and bcc. + """ return request("POST", "/emails", body=params, **request_options(options, idempotency_key)) @classmethod @@ -72,6 +82,7 @@ def send( ``batch_validation`` is ``"strict"`` (default: any invalid item rejects the batch) or ``"permissive"`` (valid items are sent, the rest come back in ``errors``). + Items never send from the shared domain: such an item is refused as ``inbox_send_policy``. """ return request( "POST", diff --git a/millionsend/errors.py b/millionsend/errors.py index d8eae8b..03e49e7 100644 --- a/millionsend/errors.py +++ b/millionsend/errors.py @@ -17,6 +17,8 @@ class MillionSendError(Exception): or ``None`` for a client-side failure. message: human-readable message. status_code: HTTP status, or ``None`` when the request never reached the API. + retry_after: the response's ``retry-after`` header in seconds, or ``None`` when it + had none. The SDK never retries by itself. """ def __init__( @@ -24,11 +26,13 @@ def __init__( message: str, code: Optional[str] = None, status_code: Optional[int] = None, + retry_after: Optional[int] = None, ) -> None: super().__init__(message) self.message = message self.code = code self.status_code = status_code + self.retry_after = retry_after class MissingApiKeyError(MillionSendError): @@ -99,6 +103,36 @@ class ConcurrentIdempotentRequestsError(MillionSendError): pass +class InboxSendPolicyError(MillionSendError): + """403: the inbox's rules hold this send (a draft a person approves can still go out), or + the shared domain's sending mode refuses it.""" + + +class InboxSendCapReachedError(MillionSendError): + """429: the inbox's daily send cap, or the team's daily limit for mail from the shared + domain, is reached; don't retry before ``retry_after``.""" + + +class NameReviewUnavailableError(MillionSendError): + """503: a name on the shared domain can't be reviewed right now; retry after ``retry_after``.""" + + +class ApprovalRequiredError(MillionSendError): + """403: a key restricted to an inbox cannot send a draft that waits for approval.""" + + +class EmailQuarantinedError(MillionSendError): + """403: the email is held in quarantine until a person releases it.""" + + +class EmailBlockedError(MillionSendError): + """410: downloads of an email that carries malware are blocked.""" + + +class TooManyWaitsError(MillionSendError): + """429: too many concurrent long-polls (``wait``) for the team.""" + + class InternalServerError(MillionSendError): pass @@ -126,13 +160,20 @@ class ApplicationError(MillionSendError): "plan_limit_reached": PlanLimitReachedError, "invalid_idempotent_request": InvalidIdempotentRequestError, "concurrent_idempotent_requests": ConcurrentIdempotentRequestsError, + "inbox_send_policy": InboxSendPolicyError, + "inbox_send_cap_reached": InboxSendCapReachedError, + "approval_required": ApprovalRequiredError, + "email_quarantined": EmailQuarantinedError, + "email_blocked": EmailBlockedError, + "too_many_waits": TooManyWaitsError, + "name_review_unavailable": NameReviewUnavailableError, "internal_server_error": InternalServerError, "application_error": ApplicationError, } -def raise_api_error(status: int, parsed: object) -> None: - """Coerce a parsed non-2xx body into the canonical error and raise it.""" +def raise_api_error(status: int, parsed: object, retry_after: Optional[str] = None) -> None: + """Coerce a parsed non-2xx body and its ``retry-after`` header into the canonical error and raise it.""" if isinstance(parsed, dict): name = parsed.get("name") name = name if isinstance(name, str) else "application_error" @@ -144,4 +185,11 @@ def raise_api_error(status: int, parsed: object) -> None: name = "application_error" message = f"Request failed with status {status}" status_code = status - raise ERROR_TYPES.get(name, MillionSendError)(message, code=name, status_code=status_code) + # Only delta-seconds, the form the API sends; an HTTP-date or junk reads as no hint. + seconds = (retry_after or "").strip() + raise ERROR_TYPES.get(name, MillionSendError)( + message, + code=name, + status_code=status_code, + retry_after=int(seconds) if seconds.isdecimal() else None, + ) diff --git a/millionsend/inboxes.py b/millionsend/inboxes.py new file mode 100644 index 0000000..f1bfbdc --- /dev/null +++ b/millionsend/inboxes.py @@ -0,0 +1,310 @@ +"""Inboxes: an address that receives mail, read by an agent (``kind: "agent"``, the +default) or by people (``kind: "human"``). + +Threads, labels and drafts mirror Resend's Inboxes beta; ``Safety``, ``Codes``, +``Quarantine`` and ``Drafts.reject`` are MillionSend extensions. Params are the +wire's snake_case, passed through as given. +""" + +from typing import Any, Dict, Optional, Union + +from ._client import Options, list_query, path_id, request, request_options, request_outcome + + +def _path(inbox_id: str, *segments: str) -> str: + """``/inboxes/{inbox_id}/{segments…}`` with every id percent-encoded.""" + return "/".join(["/inboxes", path_id(inbox_id), *(path_id(s) for s in segments)]) + + +class InboxSafety: + @classmethod + def get(cls, inbox_id: str) -> Any: + """GET /inboxes/{id}/safety — who can write in and what the inbox may send.""" + return request("GET", _path(inbox_id, "safety")) + + @classmethod + def update(cls, inbox_id: str, params: Dict[str, Any]) -> Any: + """PATCH /inboxes/{id}/safety — omitted fields keep their value; inbox-scoped keys get a 403. + + On the shared domain ``send_policy`` can't be ``"allowlist"``, and is ``"open"`` only while + the shared row's ``sending_mode`` is ``"open"``. + """ + return request("PATCH", _path(inbox_id, "safety"), body=params) + + +class InboxCodes: + @classmethod + def latest(cls, inbox_id: str, params: Optional[Dict[str, Any]] = None, **query: Any) -> Any: + """GET /inboxes/{id}/codes/latest — the newest delivered verification code or sign-in link. + + Query: ``from`` (an address, or a domain covering its subdomains; pass it in ``params``), + ``wait`` (long-poll up to 50 s), ``max_age`` (seconds since delivery, 60..86400), ``since`` + (ISO 8601: only mail received after it; pass the previous code's ``received_at`` so a + second sign-in waits for its own code). + """ + return request("GET", _path(inbox_id, "codes", "latest"), query=list_query(params, **query)) + + +class InboxQuarantine: + @classmethod + def list( + cls, + inbox_id: str, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + ) -> Any: + """GET /inboxes/{id}/quarantine — held mail, newest first; full-access team keys only.""" + return request("GET", _path(inbox_id, "quarantine"), query=list_query(limit, after, before)) + + @classmethod + def release(cls, inbox_id: str, email_id: str, trust_sender: Optional[bool] = None) -> Any: + """POST /inboxes/{id}/quarantine/{email_id}/release — ``trust_sender`` also allowlists the sender.""" + body = {"trust_sender": trust_sender} if trust_sender is not None else {} + return request("POST", _path(inbox_id, "quarantine", email_id, "release"), body=body) + + +class InboxLabels: + @classmethod + def list(cls, inbox_id: str) -> Any: + """GET /inboxes/{id}/labels — every label, oldest first (not paginated).""" + return request("GET", _path(inbox_id, "labels")) + + @classmethod + def create(cls, inbox_id: str, params: Dict[str, Any]) -> Any: + """POST /inboxes/{id}/labels — name, color? (``#RRGGBB``; random when omitted).""" + return request("POST", _path(inbox_id, "labels"), body=params) + + @classmethod + def update(cls, inbox_id: str, label_id: str, params: Dict[str, Any]) -> Any: + return request("PATCH", _path(inbox_id, "labels", label_id), body=params) + + @classmethod + def remove(cls, inbox_id: str, label_id: str) -> Any: + """DELETE /inboxes/{id}/labels/{label_id} — also removes it from every thread.""" + return request("DELETE", _path(inbox_id, "labels", label_id)) + + +class InboxThreadEmails: + @classmethod + def list( + cls, + inbox_id: str, + thread_id: str, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + include_quarantined: Optional[bool] = None, + ) -> Any: + """GET /inboxes/{id}/threads/{thread_id}/emails — oldest first; ``after`` pages toward newer. + + ``include_quarantined=True`` adds held messages (full-access team keys only). + """ + query = list_query(limit, after, before, include_quarantined=include_quarantined) + return request("GET", _path(inbox_id, "threads", thread_id, "emails"), query=query) + + @classmethod + def get( + cls, inbox_id: str, thread_id: str, email_id: str, include_quarantined: Optional[bool] = None + ) -> Any: + """GET …/emails/{email_id} — a quarantined message needs ``include_quarantined=True`` and a + full-access team key; otherwise it raises ``EmailQuarantinedError``.""" + query = list_query(include_quarantined=include_quarantined) + return request("GET", _path(inbox_id, "threads", thread_id, "emails", email_id), query=query) + + @classmethod + def reply( + cls, + inbox_id: str, + thread_id: str, + email_id: str, + params: Dict[str, Any], + options: Options = None, + idempotency_key: Optional[str] = None, + ) -> Any: + """POST …/emails/{email_id}/reply — html/text, cc, bcc, subject, reply_all, requires_approval. + + Returns ``{"kind": "sent", "email": {...}}``, or ``{"kind": "draft", "draft": {...}}`` + when the inbox's rules hold the reply for a person to approve (HTTP 202). + """ + key = request_options(options, idempotency_key)["idempotency_key"] + path = _path(inbox_id, "threads", thread_id, "emails", email_id, "reply") + return request_outcome(path, body=params, idempotency_key=key) + + @classmethod + def forward( + cls, + inbox_id: str, + thread_id: str, + email_id: str, + params: Dict[str, Any], + options: Options = None, + idempotency_key: Optional[str] = None, + ) -> Any: + """POST …/emails/{email_id}/forward — to, cc, bcc, html/text note, subject, requires_approval. + + Same outcome shape as :meth:`reply`. + """ + key = request_options(options, idempotency_key)["idempotency_key"] + path = _path(inbox_id, "threads", thread_id, "emails", email_id, "forward") + return request_outcome(path, body=params, idempotency_key=key) + + +class InboxThreads: + Emails = InboxThreadEmails + + @classmethod + def list( + cls, + inbox_id: str, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + **filters: Any, + ) -> Any: + """GET /inboxes/{id}/threads — most recently active first. + + Filters: ``folders`` and ``labels`` (lists, sent comma-separated), ``read``, + ``include_quarantined`` (full-access team keys only; each item then carries ``quarantined``, + its held messages not yet released). + """ + return request("GET", _path(inbox_id, "threads"), query=list_query(limit, after, before, **filters)) + + @classmethod + def search( + cls, + inbox_id: str, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + **filters: Any, + ) -> Any: + """GET /inboxes/{id}/threads/search — subject, sender and recipients. + + Filters: those of :meth:`list` plus ``query``, ``from``/``to``/``cc``/``bcc`` (lists), + ``has_attachment``, ``start_date``, ``end_date``. Pass ``from`` in the options dict. + """ + return request( + "GET", _path(inbox_id, "threads", "search"), query=list_query(limit, after, before, **filters) + ) + + @classmethod + def get(cls, inbox_id: str, thread_id: str) -> Any: + return request("GET", _path(inbox_id, "threads", thread_id)) + + @classmethod + def update(cls, inbox_id: str, thread_id: str, params: Dict[str, Any]) -> Any: + """PATCH /inboxes/{id}/threads/{thread_id} — read, folder, label_id.""" + return request("PATCH", _path(inbox_id, "threads", thread_id), body=params) + + @classmethod + def remove(cls, inbox_id: str, thread_id: str) -> Any: + """DELETE /inboxes/{id}/threads/{thread_id} — moves it to trash, purged after 30 days.""" + return request("DELETE", _path(inbox_id, "threads", thread_id)) + + +class InboxDrafts: + @classmethod + def list( + cls, + inbox_id: str, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + ) -> Any: + """GET /inboxes/{id}/drafts — unsent drafts, most recently updated first.""" + return request("GET", _path(inbox_id, "drafts"), query=list_query(limit, after, before)) + + @classmethod + def create(cls, inbox_id: str, params: Dict[str, Any]) -> Any: + """POST /inboxes/{id}/drafts — to, cc, bcc, subject, html, text, and for a reply draft both + thread_id and reply_to_email_id. A second reply draft for the same email returns the first.""" + return request("POST", _path(inbox_id, "drafts"), body=params) + + @classmethod + def get(cls, inbox_id: str, draft_id: str) -> Any: + return request("GET", _path(inbox_id, "drafts", draft_id)) + + @classmethod + def update(cls, inbox_id: str, draft_id: str, params: Dict[str, Any]) -> Any: + """PATCH /inboxes/{id}/drafts/{draft_id} — ``None`` clears a field.""" + return request("PATCH", _path(inbox_id, "drafts", draft_id), body=params) + + @classmethod + def remove(cls, inbox_id: str, draft_id: str) -> Any: + return request("DELETE", _path(inbox_id, "drafts", draft_id)) + + @classmethod + def send( + cls, + inbox_id: str, + draft_id: str, + options: Options = None, + idempotency_key: Optional[str] = None, + ) -> Any: + """POST /inboxes/{id}/drafts/{draft_id}/send — no body. + + Returns ``{"kind": "sent", "email": {object, id, thread_id, email_id}}``, or + ``{"kind": "draft", "draft": {...}}`` when the rules hold it for approval. A key restricted + to the inbox cannot send a draft that waits for approval (``approval_required``). + """ + key = request_options(options, idempotency_key)["idempotency_key"] + return request_outcome(_path(inbox_id, "drafts", draft_id, "send"), idempotency_key=key) + + @classmethod + def reject(cls, inbox_id: str, draft_id: str) -> Any: + """POST /inboxes/{id}/drafts/{draft_id}/reject — approvers only.""" + return request("POST", _path(inbox_id, "drafts", draft_id, "reject")) + + +class Inboxes: + Safety = InboxSafety + Codes = InboxCodes + Quarantine = InboxQuarantine + Labels = InboxLabels + Threads = InboxThreads + Drafts = InboxDrafts + + @classmethod + def create(cls, params: Optional[Dict[str, Any]] = None) -> Any: + """POST /inboxes — email_address?, username?, domain_id?, name? (1..320 characters), + from_name?, forwarding?, kind? (default ``"agent"``), expires_in? (``"1h"``..``"365d"``; + omitted or None, it never expires), safety? (partial). + + Without ``email_address``, or with the shared row's ``domain_id``, the address is + ``username@.``, or random without ``username``. The ``domain_id`` + of one of the team's own domains needs ``username``. On the shared domain a ``username`` + or ``from_name`` is reviewed: a refused one raises ``ValidationError`` (422; 409 when the + address is taken), and ``NameReviewUnavailableError`` (503) or ``RateLimitExceededError`` + (429) carry ``retry_after``. + """ + return request("POST", "/inboxes", body=params or {}) + + @classmethod + def list( + cls, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + kind: Optional[str] = None, + ) -> Any: + """GET /inboxes — newest first; ``kind`` is ``"agent"`` or ``"human"``.""" + return request("GET", "/inboxes", query=list_query(limit, after, before, kind=kind)) + + @classmethod + def get(cls, inbox_id: str) -> Any: + """GET /inboxes/{id_or_email} — by id or by address.""" + return request("GET", _path(inbox_id)) + + @classmethod + def update(cls, inbox_id: str, params: Dict[str, Any]) -> Any: + """PATCH /inboxes/{id} — name, from_name (``""`` removes it), expires_in. + + A ``from_name`` on the shared domain is reviewed as on :meth:`create`. + """ + return request("PATCH", _path(inbox_id), body=params) + + @classmethod + def remove(cls, inbox_id: str) -> Any: + """DELETE /inboxes/{id} — removes its threads, drafts and labels, and revokes its keys.""" + return request("DELETE", _path(inbox_id)) diff --git a/millionsend/receiving.py b/millionsend/receiving.py new file mode 100644 index 0000000..57bf442 --- /dev/null +++ b/millionsend/receiving.py @@ -0,0 +1,105 @@ +"""Received mail — ``Emails.Receiving``, mirroring resend-python, plus MillionSend's +server-side reply, forward, erase and long-poll. + +Everything in a received email was written by its sender: show its ``html`` only in +a sandbox, and give an LLM the ``extracted_text`` rather than the ``html``. +""" + +from typing import Any, Dict, Optional, Union + +from ._client import Options, list_query, path_id, request, request_options, request_outcome + + +def _path(email_id: str) -> str: + return f"/emails/receiving/{path_id(email_id)}" + + +class ReceivingAttachments: + @classmethod + def list( + cls, + email_id: str, + limit: Optional[int] = None, + after: Optional[str] = None, + before: Optional[str] = None, + ) -> Any: + """GET /emails/receiving/{email_id}/attachments — every attachment unless ``limit`` is given, + each with a signed ``download_url`` valid for one hour.""" + return request("GET", f"{_path(email_id)}/attachments", query=list_query(limit, after, before)) + + @classmethod + def get(cls, email_id: str, attachment_id: str) -> Any: + """GET /emails/receiving/{email_id}/attachments/{id}""" + return request("GET", f"{_path(email_id)}/attachments/{path_id(attachment_id)}") + + +class Receiving: + Attachments = ReceivingAttachments + + @classmethod + def list( + cls, + limit: Union[int, Dict[str, Any], None] = None, + after: Optional[str] = None, + before: Optional[str] = None, + **filters: Any, + ) -> Any: + """GET /emails/receiving — newest first. + + Filters (wire names): ``wait`` (long-poll up to 50 s for a match; the answer then also + carries ``held``), ``since``, ``inbox_id``, ``to``, ``from``, ``subject_contains``, + ``unread``, ``include_quarantined``, ``quarantined``. ``from`` is a Python keyword, so + pass it in the options dict: ``Receiving.list({"from": "example.com", "wait": 30})``. + An unknown ``inbox_id`` raises ``NotFoundError`` at once, even with ``wait``. ``unread`` + needs ``inbox_id`` unless the key is restricted to one inbox. + """ + return request("GET", "/emails/receiving", query=list_query(limit, after, before, **filters)) + + @classmethod + def get( + cls, + email_id: str, + params: Optional[Dict[str, Any]] = None, + html_format: Optional[str] = None, + ) -> Any: + """GET /emails/receiving/{id} — ``html_format`` is ``"data_uri"`` (default) or ``"cid"``, + as a keyword or in resend-python's params dict.""" + return request("GET", _path(email_id), query=list_query(params, html_format=html_format)) + + @classmethod + def reply( + cls, + email_id: str, + params: Dict[str, Any], + options: Options = None, + idempotency_key: Optional[str] = None, + ) -> Any: + """POST /emails/receiving/{id}/reply — text/html, subject, cc, bcc, reply_all, + requires_approval, from, inbox_id. + + Returns ``{"kind": "sent", "email": {...}}``, or ``{"kind": "draft", "draft": {...}}`` + when the inbox's rules hold the reply for a person to approve (HTTP 202). + """ + key = request_options(options, idempotency_key)["idempotency_key"] + return request_outcome(f"{_path(email_id)}/reply", body=params, idempotency_key=key) + + @classmethod + def forward( + cls, + email_id: str, + params: Dict[str, Any], + options: Options = None, + idempotency_key: Optional[str] = None, + ) -> Any: + """POST /emails/receiving/{id}/forward — to, cc, bcc, text/html note, subject, + requires_approval, from, inbox_id, mode (``"inline"`` or ``"attachment"``). + + Same outcome shape as :meth:`reply`. + """ + key = request_options(options, idempotency_key)["idempotency_key"] + return request_outcome(f"{_path(email_id)}/forward", body=params, idempotency_key=key) + + @classmethod + def remove(cls, email_id: str) -> Any: + """DELETE /emails/receiving/{id} — erases the message now instead of at the end of retention.""" + return request("DELETE", _path(email_id)) diff --git a/pyproject.toml b/pyproject.toml index 251dc2c..0c5fabe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "millionsend" -version = "0.8.0" +version = "0.9.0" description = "Official Python SDK for MillionSend — a self-hostable, Resend-compatible email API." readme = "README.md" requires-python = ">=3.9" diff --git a/tests/conftest.py b/tests/conftest.py index a3dff3b..b25ff8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,14 +3,16 @@ import json import pytest +from requests.structures import CaseInsensitiveDict import millionsend from millionsend import _client class FakeResponse: - def __init__(self, status, body): + def __init__(self, status, body, headers=None): self.status_code = status + self.headers = CaseInsensitiveDict(headers or {}) if body is None: self.text = "" elif isinstance(body, str): @@ -20,12 +22,13 @@ def __init__(self, status, body): class Recorder: - """Records calls and returns a canned response; set .status/.body per test.""" + """Records calls and returns a canned response; set .status/.body/.headers per test.""" def __init__(self): self.calls = [] self.status = 200 self.body = {"id": "id_1"} + self.headers = {} self.exc = None def __call__(self, method, url, headers=None, data=None, params=None, timeout=None): @@ -37,11 +40,12 @@ def __call__(self, method, url, headers=None, data=None, params=None, timeout=No "headers": headers or {}, "params": params, "body": json.loads(data) if data else None, + "timeout": timeout, } ) if self.exc is not None: raise self.exc - return FakeResponse(self.status, self.body) + return FakeResponse(self.status, self.body, self.headers) @pytest.fixture diff --git a/tests/test_client.py b/tests/test_client.py index f0f9cc1..42baff6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -161,6 +161,7 @@ def test_transport_failure_has_status_code_none(http): with pytest.raises(MillionSendError) as ei: millionsend.Emails.get("e1") assert ei.value.status_code is None + assert ei.value.retry_after is None assert "ECONNREFUSED" in ei.value.message # transport failures are the base error, never a typed API subclass assert type(ei.value) is MillionSendError @@ -228,6 +229,13 @@ def test_every_api_error_name_maps_to_a_subclass(http): "daily_quota_exceeded": (429, millionsend.DailyQuotaExceededError), "plan_limit_reached": (402, millionsend.PlanLimitReachedError), "sending_paused": (403, millionsend.SendingPausedError), + "inbox_send_policy": (403, millionsend.InboxSendPolicyError), + "inbox_send_cap_reached": (429, millionsend.InboxSendCapReachedError), + "approval_required": (403, millionsend.ApprovalRequiredError), + "email_quarantined": (403, millionsend.EmailQuarantinedError), + "email_blocked": (410, millionsend.EmailBlockedError), + "too_many_waits": (429, millionsend.TooManyWaitsError), + "name_review_unavailable": (503, millionsend.NameReviewUnavailableError), "internal_server_error": (500, millionsend.InternalServerError), "application_error": (500, millionsend.ApplicationError), } @@ -239,3 +247,57 @@ def test_every_api_error_name_maps_to_a_subclass(http): assert ei.value.code == name assert ei.value.status_code == status assert isinstance(ei.value, MillionSendError) + + +def test_retry_after_header_in_seconds(http): + http.status = 503 + http.body = {"statusCode": 503, "name": "name_review_unavailable", "message": "try again later"} + http.headers = {"Retry-After": "300"} + with pytest.raises(millionsend.NameReviewUnavailableError) as ei: + millionsend.Inboxes.create({"username": "signup-bot"}) + assert ei.value.status_code == 503 + assert ei.value.retry_after == 300 + + for headers in ({}, {"retry-after": "Fri, 16 Oct 2026 12:00:00 GMT"}, {"retry-after": "-1"}): + http.headers = headers + with pytest.raises(millionsend.NameReviewUnavailableError) as ei: + millionsend.Inboxes.create() + assert ei.value.retry_after is None + + +def test_unknown_route_404_is_text_on_old_servers_and_json_since_v0_13(http): + http.status = 404 + http.body = "404 Not Found" + with pytest.raises(millionsend.ApplicationError) as ei: + millionsend.Emails.get("e1") + assert (ei.value.code, ei.value.status_code) == ("application_error", 404) + + http.body = {"statusCode": 404, "name": "not_found", "message": "The requested endpoint does not exist."} + with pytest.raises(NotFoundError) as ei: + millionsend.Emails.get("e1") + assert (ei.value.code, ei.value.status_code) == ("not_found", 404) + + +def test_long_poll_wait_stretches_the_deadline(http, monkeypatch): + monkeypatch.setattr(millionsend, "timeout", 5.0) + millionsend.Emails.Receiving.list() + assert http.calls[0]["timeout"] == 5.0 + + millionsend.Emails.Receiving.list(wait=45) + assert http.calls[1]["timeout"] == 55.0 + + millionsend.Inboxes.Codes.latest("ib1", wait=1) + assert http.calls[2]["timeout"] == 11.0 + + +def test_never_sends_a_path_an_empty_or_dot_id_would_redirect(http): + for call in ( + lambda: millionsend.Inboxes.Labels.remove("ib1", ".."), + lambda: millionsend.Inboxes.Threads.remove("ib1", "."), + lambda: millionsend.Emails.get(""), + ): + with pytest.raises(ValueError, match="empty"): + call() + assert http.calls == [] + millionsend.Inboxes.Labels.remove("ib1", "...") + assert http.calls[0]["path"] == "/inboxes/ib1/labels/..." diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 1b614a9..5b5d744 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -25,7 +25,8 @@ def test_contact_lifecycle(): stamp = int(time.time() * 1000) - email = f"sdk-e2e-{stamp}@example.com" + # The API refuses new contacts at domains that can never receive mail, such as example.com. + email = f"success+sdk-e2e-{stamp}@simulator.amazonses.com" created = millionsend.Contacts.create({"email": email, "first_name": "Ada"}) assert created.id try: diff --git a/tests/test_resources.py b/tests/test_resources.py index 9d5e5b4..2e00a42 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -448,8 +448,7 @@ def test_batch_send_permissive_validation_and_typed_errors(http): assert http.calls[0]["headers"]["x-batch-validation"] == "permissive" assert http.calls[0]["headers"]["Idempotency-Key"] == "batch-2" assert res.data[0].id == "1" - assert res.errors[0].index == 1 - assert res.errors[0].message == "emails.1: to is required" + assert res.errors[0] == {"index": 1, "message": "emails.1: to is required"} def test_emails_list_update_remove(http): @@ -502,7 +501,14 @@ def test_contacts_batch_create(http): http.body = { "data": [{"object": "contact", "index": 0, "id": "c1", "status": "created"}], "counts": {"created": 1, "updated": 0, "skipped": 0, "failed": 1}, - "errors": [{"index": 1, "message": "contacts.1: email is required"}], + "errors": [ + { + "index": 1, + "statusCode": 422, + "name": "validation_error", + "message": "contacts.1: email is required", + } + ], } items = [{"email": "a@x.dev", "first_name": "A"}, {"first_name": "no-email"}] res = millionsend.Contacts.Batch.create(items, on_conflict="upsert", batch_validation="permissive") @@ -514,6 +520,8 @@ def test_contacts_batch_create(http): assert res.data[0].status == "created" assert res.counts.failed == 1 assert res.errors[0].index == 1 + assert res.errors[0].statusCode == 422 + assert res.errors[0].name == "validation_error" millionsend.Contacts.Batch.create(items) assert http.calls[1]["params"] is None @@ -886,3 +894,576 @@ def test_usage_get(http): assert res.limits.emails_per_day == 10000 assert res.today.emails_sent == 12 assert res.app_url is None + + +# ---- receiving and inboxes ------------------------------------------------- +INBOX_ID = "6f1c9a1e-0000-4000-8000-0000000000a1" +THREAD_ID = "6f1c9a1e-0000-4000-8000-0000000000b1" +EMAIL_ID = "6f1c9a1e-0000-4000-8000-0000000000c1" +DRAFT_ID = "6f1c9a1e-0000-4000-8000-0000000000d1" +SHARED_DOMAIN_ID = "6f1c9a1e-0000-4000-8000-0000000000e1" +OWN_DOMAIN_ID = "6f1c9a1e-0000-4000-8000-0000000000e2" + +INBOX = { + "object": "inbox", + "id": INBOX_ID, + "name": "Signup bot", + "email_address": "signup-bot-7f3k@acme.agents.example.com", + "domain_id": SHARED_DOMAIN_ID, + "receiving_address": None, + "from_name": None, + "unread": 1, + "drafts": 0, + "last_received": "2026-10-12T10:05:00.000Z", + "created_at": "2026-10-12T10:00:00.000Z", + "kind": "agent", + "shared": True, + "expires_at": None, + "quarantined": 0, + "pending_approval": 0, +} + +DRAFT = { + "object": "inbox_draft", + "id": DRAFT_ID, + "type": "reply", + "to": ["success@simulator.amazonses.com"], + "cc": [], + "bcc": [], + "subject": "Re: Confirm your email", + "html": None, + "text": "Thanks, confirmed.", + "thread_id": THREAD_ID, + "reply_to_email_id": EMAIL_ID, + "email_id": None, + "created_at": "2026-10-12T10:06:00.000Z", + "updated_at": "2026-10-12T10:06:00.000Z", + "status": "pending_approval", + "approval_reasons": ["send_policy"], + "forward": False, + "decided_by": None, + "decided_at": None, + "expires_at": "2026-10-19T10:06:00.000Z", +} + +THREAD = { + "id": THREAD_ID, + "subject": "Confirm your email", + "from": "hello@example.com", + "to": ["signup-bot-7f3k@acme.agents.example.com"], + "cc": [], + "bcc": [], + "labels": [], + "message_count": 2, + "has_attachment": False, + "has_draft": False, + "read": False, + "received_at": "2026-10-12T10:05:00.000Z", + "folder": "inbox", +} + +SHARED_DOMAIN = { + "object": "domain", + "id": SHARED_DOMAIN_ID, + "name": "acme.agents.example.com", + "status": "verified", + "created_at": "2026-10-12T09:00:00.000Z", + "region": "us-east-1", + "open_tracking": False, + "click_tracking": False, + "tracking_subdomain": None, + "capabilities": {"sending": "enabled", "receiving": "enabled"}, + "receiving_catch_all": False, + "shared": True, + "sending_mode": "reply_only", + "sending_mode_reason": "plan", + "records": [], +} + +OWN_DOMAIN = { + "id": OWN_DOMAIN_ID, + "name": "acme.dev", + "status": "verified", + "created_at": "2026-10-13T09:00:00.000Z", + "region": "us-east-1", + "open_tracking": True, + "click_tracking": True, + "tracking_subdomain": "links", + "capabilities": {"sending": "enabled", "receiving": "disabled"}, + "receiving_catch_all": False, + "shared": False, +} + +SENT_EMAIL = { + "id": EMAIL_ID, + "direction": "outbound", + "from": "signup-bot-7f3k@acme.agents.example.com", + "to": ["success@simulator.amazonses.com"], + "cc": [], + "bcc": [], + "reply_to": [], + "subject": "Re: Confirm your email", + "message_id": None, + "html": None, + "text": "Thanks, confirmed.", + "attachments": [], + "read": True, + "received_at": "2026-10-12T10:06:00.000Z", + "email_id": EMAIL_ID, + "thread_id": THREAD_ID, + "inbox_id": INBOX_ID, + "status": "queued", +} + + +def test_receiving_list_filters_and_flags(http): + http.body = {"object": "list", "has_more": False, "data": [], "held": []} + res = millionsend.Emails.Receiving.list( + {"from": "example.com", "limit": 10}, + wait=30, + since="2026-10-12T10:00:00Z", + inbox_id=INBOX_ID, + to="bot@acme.dev", + subject_contains="code", + unread=False, + include_quarantined=True, + ) + assert http.calls[0]["method"] == "GET" + assert http.calls[0]["path"] == "/emails/receiving" + assert http.calls[0]["params"] == { + "from": "example.com", + "limit": 10, + "wait": 30, + "since": "2026-10-12T10:00:00Z", + "inbox_id": INBOX_ID, + "to": "bot@acme.dev", + "subject_contains": "code", + "unread": "false", + "include_quarantined": "true", + } + assert res.held == [] + + millionsend.Emails.Receiving.list() + assert http.calls[1]["params"] is None + + +def test_receiving_list_unread_alone_and_unknown_inbox(http): + millionsend.Emails.Receiving.list(unread=False) + assert http.calls[0]["params"] == {"unread": "false"} + + http.status = 404 + http.body = {"statusCode": 404, "name": "not_found", "message": "Inbox not found."} + with pytest.raises(millionsend.NotFoundError) as ei: + millionsend.Emails.Receiving.list(inbox_id=INBOX_ID, wait=30) + assert ei.value.status_code == 404 + + +def test_receiving_get_keeps_extension_fields(http): + http.body = { + "object": "email", + "id": EMAIL_ID, + "extracted_text": "Your verification code is 482913", + "extracted": {"kinds": ["code"], "code": "482913", "link": None, "ambiguous": False}, + "screening": {"verdict": "clean", "sender_trust": "brand_new_tier"}, + "future_field": {"nested": True}, + } + res = millionsend.Emails.Receiving.get(EMAIL_ID, html_format="cid") + assert http.calls[0]["path"] == f"/emails/receiving/{EMAIL_ID}" + assert http.calls[0]["params"] == {"html_format": "cid"} + assert res.extracted.code == "482913" + assert res.screening.sender_trust == "brand_new_tier" + assert res.future_field.nested is True + + millionsend.Emails.Receiving.get(EMAIL_ID) + assert http.calls[1]["params"] is None + + millionsend.Emails.Receiving.get(EMAIL_ID, {"html_format": "data_uri"}) + assert http.calls[2]["params"] == {"html_format": "data_uri"} + + +def test_receiving_reply_sent_and_held(http): + sent = {"object": "email", "id": EMAIL_ID, "thread_id": THREAD_ID, "inbox_id": INBOX_ID} + http.body = sent + params = { + "text": "Thanks, confirmed.", + "reply_all": True, + "requires_approval": False, + "inbox_id": INBOX_ID, + } + res = millionsend.Emails.Receiving.reply(EMAIL_ID, params, idempotency_key="reply-1") + assert http.calls[0]["method"] == "POST" + assert http.calls[0]["path"] == f"/emails/receiving/{EMAIL_ID}/reply" + assert http.calls[0]["body"] == params + assert http.calls[0]["headers"]["Idempotency-Key"] == "reply-1" + assert res == {"kind": "sent", "email": sent} + assert res.email.thread_id == THREAD_ID + + http.status = 202 + http.body = DRAFT + res = millionsend.Emails.Receiving.reply(EMAIL_ID, {"text": "hi"}, {"idempotency_key": "reply-2"}) + assert http.calls[1]["headers"]["Idempotency-Key"] == "reply-2" + assert res.kind == "draft" + assert res.draft == DRAFT + assert res.draft.approval_reasons == ["send_policy"] + + +def test_receiving_forward_and_quarantine_error(http): + http.body = {"object": "email", "id": EMAIL_ID, "thread_id": None, "inbox_id": None} + params = {"to": ["ops@acme.dev"], "text": "FYI", "mode": "attachment"} + res = millionsend.Emails.Receiving.forward(EMAIL_ID, params, "fwd-1") + assert http.calls[0]["path"] == f"/emails/receiving/{EMAIL_ID}/forward" + assert http.calls[0]["body"] == params + assert http.calls[0]["headers"]["Idempotency-Key"] == "fwd-1" + assert res.kind == "sent" + + http.status = 403 + http.body = {"statusCode": 403, "name": "email_quarantined", "message": "held"} + with pytest.raises(millionsend.EmailQuarantinedError): + millionsend.Emails.Receiving.forward(EMAIL_ID, {"to": "ops@acme.dev"}) + + +def test_receiving_remove_and_attachments(http): + millionsend.Emails.Receiving.remove(EMAIL_ID) + assert http.calls[0]["method"] == "DELETE" + assert http.calls[0]["path"] == f"/emails/receiving/{EMAIL_ID}" + + millionsend.Emails.Receiving.Attachments.list(EMAIL_ID, limit=5) + assert http.calls[1]["path"] == f"/emails/receiving/{EMAIL_ID}/attachments" + assert http.calls[1]["params"] == {"limit": 5} + + millionsend.Emails.Receiving.Attachments.get(EMAIL_ID, "att-1") + assert http.calls[2]["path"] == f"/emails/receiving/{EMAIL_ID}/attachments/att-1" + + +def test_inboxes_crud(http): + http.status = 201 + http.body = INBOX + params = { + "email_address": "support@acme.dev", + "name": "Support", + "kind": "human", + "expires_in": None, + "safety": {"accept_from": "allowlist", "allowlist": ["*@acme.dev"], "send_cap": None}, + } + res = millionsend.Inboxes.create(params) + assert http.calls[0]["method"] == "POST" + assert http.calls[0]["path"] == "/inboxes" + assert http.calls[0]["body"] == params + assert res == INBOX + + millionsend.Inboxes.create() + assert http.calls[1]["body"] == {} + + http.status = 200 + millionsend.Inboxes.list(limit=2, after=INBOX_ID, kind="agent") + assert http.calls[2]["path"] == "/inboxes" + assert http.calls[2]["params"] == {"limit": 2, "after": INBOX_ID, "kind": "agent"} + + millionsend.Inboxes.get("Support@acme.dev") + assert http.calls[3]["path"] == "/inboxes/Support%40acme.dev" + + millionsend.Inboxes.update(INBOX_ID, {"name": "Bot", "from_name": "", "expires_in": "7d"}) + assert http.calls[4]["method"] == "PATCH" + assert http.calls[4]["path"] == f"/inboxes/{INBOX_ID}" + assert http.calls[4]["body"] == {"name": "Bot", "from_name": "", "expires_in": "7d"} + + millionsend.Inboxes.remove(INBOX_ID) + assert http.calls[5]["method"] == "DELETE" + assert http.calls[5]["path"] == f"/inboxes/{INBOX_ID}" + + +def test_inboxes_create_username_and_domain_id(http): + http.status = 201 + http.body = INBOX + for params in ( + {"username": "signup-bot"}, + {"domain_id": SHARED_DOMAIN_ID}, + {"domain_id": OWN_DOMAIN_ID, "username": "support"}, + ): + millionsend.Inboxes.create(params) + assert http.calls[-1]["body"] == params + + +def test_inbox_codes_latest_since(http): + millionsend.Inboxes.Codes.latest(INBOX_ID, since="2026-10-16T12:00:00.000Z") + assert http.calls[0]["params"] == {"since": "2026-10-16T12:00:00.000Z"} + + +def test_inbox_thread_items_count_quarantined_only_when_asked(http): + http.body = {"object": "list", "has_more": False, "data": [dict(THREAD, quarantined=2)]} + res = millionsend.Inboxes.Threads.list(INBOX_ID, include_quarantined=True) + assert res.data[0].quarantined == 2 + + http.body = {"object": "list", "has_more": False, "data": [THREAD]} + res = millionsend.Inboxes.Threads.search(INBOX_ID, query="confirm") + assert "quarantined" not in res.data[0] + + +def test_domains_shared_row(http): + shared_item = {k: v for k, v in SHARED_DOMAIN.items() if k not in ("object", "records")} + http.body = {"object": "list", "has_more": False, "data": [OWN_DOMAIN, shared_item]} + res = millionsend.Domains.list() + own, shared = res.data + assert own.shared is False + assert "sending_mode" not in own and "sending_mode_reason" not in own + assert (shared.shared, shared.sending_mode, shared.sending_mode_reason) == (True, "reply_only", "plan") + assert [d.id for d in res.data if not d.get("shared")] == [OWN_DOMAIN_ID] + + # Servers before v0.14.0 send no `shared` key and list no shared row. + http.body = {"object": "list", "has_more": False, "data": [dict(OWN_DOMAIN)]} + del http.body["data"][0]["shared"] + assert [d.id for d in millionsend.Domains.list().data if not d.get("shared")] == [OWN_DOMAIN_ID] + + http.body = SHARED_DOMAIN + res = millionsend.Domains.get(SHARED_DOMAIN_ID) + assert res == SHARED_DOMAIN + assert res.records == [] + + http.body = dict(SHARED_DOMAIN, sending_mode="open", sending_mode_reason=None) + res = millionsend.Domains.get(SHARED_DOMAIN_ID) + assert (res.sending_mode, res.sending_mode_reason) == ("open", None) + + http.body = dict(SHARED_DOMAIN, sending_mode="paused_for_review") + assert millionsend.Domains.get(SHARED_DOMAIN_ID).sending_mode == "paused_for_review" + + http.status = 403 + http.body = {"statusCode": 403, "name": "forbidden", "message": "read-only"} + for call in ( + lambda: millionsend.Domains.update({"id": SHARED_DOMAIN_ID, "open_tracking": True}), + lambda: millionsend.Domains.verify(SHARED_DOMAIN_ID), + lambda: millionsend.Domains.remove(SHARED_DOMAIN_ID), + ): + with pytest.raises(millionsend.ForbiddenError) as ei: + call() + assert (ei.value.code, ei.value.status_code) == ("forbidden", 403) + + +def test_inboxes_unknown_keys_and_enum_values_pass_through(http): + http.body = dict(INBOX, kind="team", brand_new=[1, 2]) + res = millionsend.Inboxes.get(INBOX_ID) + assert res.kind == "team" + assert res.brand_new == [1, 2] + + +def test_inbox_safety_codes_and_quarantine(http): + millionsend.Inboxes.Safety.get(INBOX_ID) + assert http.calls[0]["method"] == "GET" + assert http.calls[0]["path"] == f"/inboxes/{INBOX_ID}/safety" + + millionsend.Inboxes.Safety.update(INBOX_ID, {"send_cap": 25, "accept_from": "verified"}) + assert http.calls[1]["method"] == "PATCH" + assert http.calls[1]["path"] == f"/inboxes/{INBOX_ID}/safety" + assert http.calls[1]["body"] == {"send_cap": 25, "accept_from": "verified"} + + http.body = { + "object": "verification_code", + "status": "found", + "kind": "otp", + "code": "482913", + "url": None, + "link_domain_matches_sender": None, + "email_id": EMAIL_ID, + "from": "hello@example.com", + "received_at": "2026-10-12T10:05:00.000Z", + "sender_trust": "external", + } + res = millionsend.Inboxes.Codes.latest(INBOX_ID, {"from": "example.com"}, wait=20, max_age=600) + assert http.calls[2]["path"] == f"/inboxes/{INBOX_ID}/codes/latest" + assert http.calls[2]["params"] == {"from": "example.com", "wait": 20, "max_age": 600} + assert res.code == "482913" + + millionsend.Inboxes.Codes.latest(INBOX_ID) + assert http.calls[3]["params"] is None + + millionsend.Inboxes.Quarantine.list(INBOX_ID, limit=3) + assert http.calls[4]["path"] == f"/inboxes/{INBOX_ID}/quarantine" + assert http.calls[4]["params"] == {"limit": 3} + + http.body = { + "object": "inbox_release", + "inbox_id": INBOX_ID, + "email_id": EMAIL_ID, + "released_at": "2026-10-12T10:07:00.000Z", + "trusted_sender": "hello@example.com", + } + res = millionsend.Inboxes.Quarantine.release(INBOX_ID, EMAIL_ID, trust_sender=True) + assert http.calls[5]["method"] == "POST" + assert http.calls[5]["path"] == f"/inboxes/{INBOX_ID}/quarantine/{EMAIL_ID}/release" + assert http.calls[5]["body"] == {"trust_sender": True} + assert res.trusted_sender == "hello@example.com" + + millionsend.Inboxes.Quarantine.release(INBOX_ID, EMAIL_ID) + assert http.calls[6]["body"] == {} + + +def test_inbox_labels(http): + millionsend.Inboxes.Labels.list(INBOX_ID) + assert http.calls[0]["method"] == "GET" + assert http.calls[0]["path"] == f"/inboxes/{INBOX_ID}/labels" + + millionsend.Inboxes.Labels.create(INBOX_ID, {"name": "Billing", "color": "#FF8800"}) + assert http.calls[1]["method"] == "POST" + assert http.calls[1]["body"] == {"name": "Billing", "color": "#FF8800"} + + millionsend.Inboxes.Labels.update(INBOX_ID, "l1", {"name": "Invoices"}) + assert http.calls[2]["method"] == "PATCH" + assert http.calls[2]["path"] == f"/inboxes/{INBOX_ID}/labels/l1" + assert http.calls[2]["body"] == {"name": "Invoices"} + + millionsend.Inboxes.Labels.remove(INBOX_ID, "l1") + assert http.calls[3]["method"] == "DELETE" + assert http.calls[3]["path"] == f"/inboxes/{INBOX_ID}/labels/l1" + + +def test_inbox_threads_lists_as_csv(http): + http.body = {"object": "list", "has_more": False, "data": []} + millionsend.Inboxes.Threads.list( + INBOX_ID, limit=20, folders=["inbox", "archive"], labels=[], read=False, include_quarantined=True + ) + assert http.calls[0]["path"] == f"/inboxes/{INBOX_ID}/threads" + assert http.calls[0]["params"] == { + "limit": 20, + "folders": "inbox,archive", + "read": "false", + "include_quarantined": "true", + } + + millionsend.Inboxes.Threads.search( + INBOX_ID, + {"from": ["ada", "example.com"], "query": '"invoice 42" -spam'}, + to=["bot@acme.dev"], + has_attachment=True, + start_date="2026-10-01", + end_date="2026-10-12", + ) + assert http.calls[1]["path"] == f"/inboxes/{INBOX_ID}/threads/search" + assert http.calls[1]["params"] == { + "from": "ada,example.com", + "query": '"invoice 42" -spam', + "to": "bot@acme.dev", + "has_attachment": "true", + "start_date": "2026-10-01", + "end_date": "2026-10-12", + } + + millionsend.Inboxes.Threads.get(INBOX_ID, THREAD_ID) + assert http.calls[2]["path"] == f"/inboxes/{INBOX_ID}/threads/{THREAD_ID}" + + millionsend.Inboxes.Threads.update( + INBOX_ID, THREAD_ID, {"read": True, "folder": "archive", "label_id": "l1"} + ) + assert http.calls[3]["method"] == "PATCH" + assert http.calls[3]["body"] == {"read": True, "folder": "archive", "label_id": "l1"} + + millionsend.Inboxes.Threads.remove(INBOX_ID, THREAD_ID) + assert http.calls[4]["method"] == "DELETE" + assert http.calls[4]["path"] == f"/inboxes/{INBOX_ID}/threads/{THREAD_ID}" + + +def test_inbox_thread_emails(http): + base = f"/inboxes/{INBOX_ID}/threads/{THREAD_ID}/emails" + http.body = SENT_EMAIL + millionsend.Inboxes.Threads.Emails.list(INBOX_ID, THREAD_ID, after=EMAIL_ID, include_quarantined=True) + assert http.calls[0]["path"] == base + assert http.calls[0]["params"] == {"after": EMAIL_ID, "include_quarantined": "true"} + + millionsend.Inboxes.Threads.Emails.get(INBOX_ID, THREAD_ID, EMAIL_ID) + assert http.calls[1]["path"] == f"{base}/{EMAIL_ID}" + assert http.calls[1]["params"] is None + + params = {"text": "Thanks, confirmed.", "reply_all": True} + res = millionsend.Inboxes.Threads.Emails.reply( + INBOX_ID, THREAD_ID, EMAIL_ID, params, idempotency_key="r-1" + ) + assert http.calls[2]["method"] == "POST" + assert http.calls[2]["path"] == f"{base}/{EMAIL_ID}/reply" + assert http.calls[2]["body"] == params + assert http.calls[2]["headers"]["Idempotency-Key"] == "r-1" + assert res == {"kind": "sent", "email": SENT_EMAIL} + + http.status = 202 + http.body = DRAFT + res = millionsend.Inboxes.Threads.Emails.forward( + INBOX_ID, THREAD_ID, EMAIL_ID, {"to": "success@simulator.amazonses.com"}, {"idempotency_key": "f-1"} + ) + assert http.calls[3]["path"] == f"{base}/{EMAIL_ID}/forward" + assert http.calls[3]["body"] == {"to": "success@simulator.amazonses.com"} + assert http.calls[3]["headers"]["Idempotency-Key"] == "f-1" + assert res == {"kind": "draft", "draft": DRAFT} + + http.status = 200 + millionsend.Inboxes.Threads.Emails.get(INBOX_ID, THREAD_ID, EMAIL_ID, include_quarantined=True) + assert http.calls[4]["params"] == {"include_quarantined": "true"} + + +def test_inbox_drafts(http): + base = f"/inboxes/{INBOX_ID}/drafts" + http.status = 201 + http.body = DRAFT + millionsend.Inboxes.Drafts.list(INBOX_ID, limit=5) + assert http.calls[0]["path"] == base + assert http.calls[0]["params"] == {"limit": 5} + + params = { + "to": "success@simulator.amazonses.com", + "text": "Hi", + "thread_id": THREAD_ID, + "reply_to_email_id": EMAIL_ID, + } + res = millionsend.Inboxes.Drafts.create(INBOX_ID, params) + assert http.calls[1]["method"] == "POST" + assert http.calls[1]["body"] == params + assert res.status == "pending_approval" + + millionsend.Inboxes.Drafts.get(INBOX_ID, DRAFT_ID) + assert http.calls[2]["path"] == f"{base}/{DRAFT_ID}" + + millionsend.Inboxes.Drafts.update(INBOX_ID, DRAFT_ID, {"cc": None, "subject": None}) + assert http.calls[3]["method"] == "PATCH" + assert http.calls[3]["body"] == {"cc": None, "subject": None} + + millionsend.Inboxes.Drafts.remove(INBOX_ID, DRAFT_ID) + assert http.calls[4]["method"] == "DELETE" + + millionsend.Inboxes.Drafts.reject(INBOX_ID, DRAFT_ID) + assert http.calls[5]["method"] == "POST" + assert http.calls[5]["path"] == f"{base}/{DRAFT_ID}/reject" + assert http.calls[5]["body"] is None + + +def test_inbox_drafts_send(http): + sent = {"object": "inbox_draft", "id": DRAFT_ID, "thread_id": THREAD_ID, "email_id": EMAIL_ID} + http.body = sent + res = millionsend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID, idempotency_key="send-1") + assert http.calls[0]["method"] == "POST" + assert http.calls[0]["path"] == f"/inboxes/{INBOX_ID}/drafts/{DRAFT_ID}/send" + assert http.calls[0]["body"] is None + assert "Content-Type" not in http.calls[0]["headers"] + assert http.calls[0]["headers"]["Idempotency-Key"] == "send-1" + assert res == {"kind": "sent", "email": sent} + + http.status = 202 + http.body = DRAFT + assert millionsend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) == {"kind": "draft", "draft": DRAFT} + + http.status = 403 + http.body = {"statusCode": 403, "name": "approval_required", "message": "waits for a person"} + with pytest.raises(millionsend.ApprovalRequiredError): + millionsend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) + + +def test_inbox_fields_on_existing_resources(http): + millionsend.Webhooks.create( + {"endpoint": "https://acme.dev/hooks", "events": ["inbox.email.received"], "inbox_ids": [INBOX_ID]} + ) + assert http.calls[0]["body"]["inbox_ids"] == [INBOX_ID] + + millionsend.Webhooks.update({"id": "w1", "inbox_ids": None}) + assert http.calls[1]["body"] == {"inbox_ids": None} + + millionsend.ApiKeys.create({"name": "agent", "permission": "full_access", "inbox_id": INBOX_ID}) + assert http.calls[2]["body"] == {"name": "agent", "permission": "full_access", "inbox_id": INBOX_ID} + + millionsend.Domains.update({"id": "d1", "receiving_catch_all": False}) + assert http.calls[3]["path"] == "/domains/d1" + assert http.calls[3]["body"] == {"receiving_catch_all": False}