From a92510cc25d8ef5f206b520a27410db9bdf6038a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 03:24:59 -0300 Subject: [PATCH 1/3] Receiving and inboxes: Emails.Receiving and the Inboxes tree (0.9.0) - Emails.Receiving: list (wait long-poll and the inbox, address, subject, unread and quarantine filters), get, reply, forward, remove, and Attachments.list / Attachments.get. resend-python's call shapes work. - Inboxes: create, list, get, update, remove, plus Safety, Codes.latest, Quarantine, Labels, Threads (with Threads.Emails) and Drafts (with send and reject). - Replies, forwards and Drafts.send return {"kind": "sent", "email": ...} or, on an HTTP 202, {"kind": "draft", "draft": ...}. - Query booleans go out as "true"/"false" and lists comma-separated; a request with a wait query gets a timeout that outlasts it. - New error subclasses for inbox_send_policy, inbox_send_cap_reached, approval_required, email_quarantined, email_blocked and too_many_waits. Co-Authored-By: Claude Opus 5.5 --- README.md | 102 ++++++++- millionsend/__init__.py | 14 ++ millionsend/_client.py | 52 ++++- millionsend/contacts.py | 2 - millionsend/emails.py | 4 + millionsend/errors.py | 30 +++ millionsend/inboxes.py | 286 +++++++++++++++++++++++++ millionsend/receiving.py | 103 +++++++++ pyproject.toml | 2 +- tests/conftest.py | 1 + tests/test_client.py | 18 ++ tests/test_resources.py | 436 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 1040 insertions(+), 10 deletions(-) create mode 100644 millionsend/inboxes.py create mode 100644 millionsend/receiving.py diff --git a/README.md b/README.md index 9fd1347..8c67b26 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ except MillionSendError as e: - `e.status_code` is the HTTP status, or `None` for client-side/transport failures (connection refused, DNS, timeout). - `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`, `InternalServerError`, `ApplicationError`. Unknown names raise the base `MillionSendError`. ## Resources @@ -125,6 +125,98 @@ 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. +### 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, created_at}] held for screening + +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`. + +### 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", # no email_address: a random address on the shared domain + "expires_in": "24h", # "h" or "d"; None = never (custom domains only) + "safety": {"accept_from": "anyone"}, +}) +millionsend.Inboxes.create({"email_address": "support@acme.dev", "name": "Support", "kind": "human"}) +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": "github.com"}, wait=30) +code.status # "found" | "none" | "ambiguous" | "timeout"; code.code, code.url + +# 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 +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": "ada@example.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. Hosted agent settings (`/inboxes/{id}/agent`) are not exposed: MillionSend runs no agent of its own. + ### Contacts Contacts are team-global — one record per email address, no audiences to manage. @@ -290,6 +382,7 @@ 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) ``` @@ -315,11 +408,14 @@ 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). + ### 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) ``` @@ -386,8 +482,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 f491cc2..b191d58 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, @@ -51,8 +56,10 @@ RateLimitExceededError, RestrictedApiKeyError, SendingPausedError, + TooManyWaitsError, ValidationError, ) +from .inboxes import Inboxes from .segments import Segments from .suppressions import Suppressions from .templates import Templates @@ -75,6 +82,7 @@ "allow_insecure_http", "Emails", "Batch", + "Inboxes", "Contacts", "ContactProperties", "Topics", @@ -106,6 +114,12 @@ "PlanLimitReachedError", "InvalidIdempotentRequestError", "ConcurrentIdempotentRequestsError", + "InboxSendPolicyError", + "InboxSendCapReachedError", + "ApprovalRequiredError", + "EmailQuarantinedError", + "EmailBlockedError", + "TooManyWaitsError", "InternalServerError", "ApplicationError", "__version__", diff --git a/millionsend/_client.py b/millionsend/_client.py index 61f7e26..7300e36 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") @@ -120,10 +122,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 +164,30 @@ 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]: url = _base_url() + path headers = { "Authorization": f"Bearer {_api_key()}", @@ -167,6 +204,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 +218,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( @@ -191,4 +235,4 @@ def request( if not 200 <= resp.status_code < 300: raise_api_error(resp.status_code, parsed) - return _wrap(parsed) + return resp.status_code, parsed 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/emails.py b/millionsend/emails.py index d5011fb..74fc5bf 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, diff --git a/millionsend/errors.py b/millionsend/errors.py index d8eae8b..56a95d6 100644 --- a/millionsend/errors.py +++ b/millionsend/errors.py @@ -99,6 +99,30 @@ class ConcurrentIdempotentRequestsError(MillionSendError): pass +class InboxSendPolicyError(MillionSendError): + """403: the inbox's rules hold this send; create a draft for a person to approve instead.""" + + +class InboxSendCapReachedError(MillionSendError): + """429: the inbox reached its daily send cap.""" + + +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,6 +150,12 @@ 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, "internal_server_error": InternalServerError, "application_error": ApplicationError, } diff --git a/millionsend/inboxes.py b/millionsend/inboxes.py new file mode 100644 index 0000000..6360f77 --- /dev/null +++ b/millionsend/inboxes.py @@ -0,0 +1,286 @@ +"""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.""" + 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, 60..86400). + """ + 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, + ) -> Any: + """GET /inboxes/{id}/threads/{thread_id}/emails — oldest first; ``after`` pages toward newer.""" + return request( + "GET", _path(inbox_id, "threads", thread_id, "emails"), query=list_query(limit, after, before) + ) + + @classmethod + def get(cls, inbox_id: str, thread_id: str, email_id: str) -> Any: + return request("GET", _path(inbox_id, "threads", thread_id, "emails", email_id)) + + @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``. + """ + 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?, name?, from_name?, forwarding?, kind? (default + ``"agent"``), expires_in? (``"24h"``, ``"7d"``, or None for never), safety? (partial). + + Without ``email_address`` the inbox gets a random address on the instance's shared domain. + """ + 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.""" + 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..2c4c36d --- /dev/null +++ b/millionsend/receiving.py @@ -0,0 +1,103 @@ +"""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})``. + """ + 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..28c6f9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,7 @@ 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: diff --git a/tests/test_client.py b/tests/test_client.py index f0f9cc1..e9cd8d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -228,6 +228,12 @@ 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), "internal_server_error": (500, millionsend.InternalServerError), "application_error": (500, millionsend.ApplicationError), } @@ -239,3 +245,15 @@ 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_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 diff --git a/tests/test_resources.py b/tests/test_resources.py index 9d5e5b4..8fc0a41 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -886,3 +886,439 @@ 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" + +INBOX = { + "object": "inbox", + "id": INBOX_ID, + "name": "Signup bot", + "email_address": "signup-bot-7f3k@acme.agents.example.com", + "domain_id": "6f1c9a1e-0000-4000-8000-0000000000e1", + "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": "2026-10-13T10:00:00.000Z", + "quarantined": 0, + "pending_approval": 0, +} + +DRAFT = { + "object": "inbox_draft", + "id": DRAFT_ID, + "type": "reply", + "to": ["ada@example.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", +} + +SENT_EMAIL = { + "id": EMAIL_ID, + "direction": "outbound", + "from": "signup-bot-7f3k@acme.agents.example.com", + "to": ["ada@example.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_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_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) + assert http.calls[0]["path"] == base + assert http.calls[0]["params"] == {"after": EMAIL_ID} + + millionsend.Inboxes.Threads.Emails.get(INBOX_ID, THREAD_ID, EMAIL_ID) + assert http.calls[1]["path"] == f"{base}/{EMAIL_ID}" + + 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": "stranger@example.com"}, {"idempotency_key": "f-1"} + ) + assert http.calls[3]["path"] == f"{base}/{EMAIL_ID}/forward" + assert http.calls[3]["body"] == {"to": "stranger@example.com"} + assert http.calls[3]["headers"]["Idempotency-Key"] == "f-1" + assert res == {"kind": "draft", "draft": DRAFT} + + +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": "ada@example.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} From 351aa3c04f3d10e5328ce0f3989dab89b3330486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 15:58:25 -0300 Subject: [PATCH 2/3] Refuse empty and dot ids in request paths URL normalization resolves an empty, "." or ".." path segment away, so an id such as ".." turned DELETE /inboxes/{id}/labels/.. into DELETE /inboxes/{id}/. The request dispatch now checks every path once, and such a call raises ValueError without sending anything. Co-Authored-By: Claude Opus 5.5 --- millionsend/_client.py | 11 +++++++++++ tests/test_client.py | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/millionsend/_client.py b/millionsend/_client.py index 7300e36..d77c2a5 100644 --- a/millionsend/_client.py +++ b/millionsend/_client.py @@ -93,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. @@ -188,6 +197,8 @@ def _dispatch( 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()}", diff --git a/tests/test_client.py b/tests/test_client.py index e9cd8d3..9d61c4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -257,3 +257,16 @@ def test_long_poll_wait_stretches_the_deadline(http, monkeypatch): 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/..." From 8c621ff5b13582ae6aecc9f6eecd129e13f58f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 16:55:00 -0300 Subject: [PATCH 3/3] Match the inboxes API: include_quarantined on thread emails - Inboxes.Threads.Emails.list and .get take include_quarantined, which the API accepts on both (owner views of held mail); before, get could not send it at all and list only through the options dict. - README: the long-poll's held items carry quarantine_reason; the codes example filters on example.com. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- millionsend/inboxes.py | 20 ++++++++++++++------ tests/test_resources.py | 9 +++++++-- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8c67b26..22ee540 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ page = millionsend.Emails.Receiving.list( 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, created_at}] held for screening +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) @@ -177,7 +177,7 @@ 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": "github.com"}, wait=30) +code = millionsend.Inboxes.Codes.latest(inbox.id, {"from": "example.com"}, wait=30) code.status # "found" | "none" | "ambiguous" | "timeout"; code.code, code.url # Safety rules: who can write in, what the inbox may send @@ -194,7 +194,7 @@ millionsend.Inboxes.Threads.search(inbox.id, {"query": "invoice", "from": ["exam 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 +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"}) diff --git a/millionsend/inboxes.py b/millionsend/inboxes.py index 6360f77..19a042c 100644 --- a/millionsend/inboxes.py +++ b/millionsend/inboxes.py @@ -88,15 +88,23 @@ def list( 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.""" - return request( - "GET", _path(inbox_id, "threads", thread_id, "emails"), query=list_query(limit, after, before) - ) + """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) -> Any: - return request("GET", _path(inbox_id, "threads", thread_id, "emails", email_id)) + 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( diff --git a/tests/test_resources.py b/tests/test_resources.py index 8fc0a41..4fe61c9 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1228,12 +1228,13 @@ def test_inbox_threads_lists_as_csv(http): 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) + 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} + 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( @@ -1255,6 +1256,10 @@ def test_inbox_thread_emails(http): 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"