From 42f3a8cfa01cfe44ed166bcd947bb7ef8be34ce1 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Thu, 22 Oct 2026 03:42:39 +0100 Subject: [PATCH] feat(mailboxes, domains): read and answer mailbox mail, and manage the sender logo Implements the eight operations the last spec re-sync listed in NOT_YET_IMPLEMENTED, which is empty again (in step with sendly-js#18): mailboxes.list_conversations / iter_list_conversations, get_conversation, reply_to_conversation and download_attachment, under the opt-in mailbox-conversations scopes; and domains.get_logo_v1, upload_logo_v1, delete_logo_v1 and get_logo_readiness_v1. download_attachment needs the body as bytes, so the HTTP call is split out of request() and shared with a new request_bytes(), which still raises the same typed errors. The contract suite now reads request_bytes call sites too, and recognises the legacy cursor page ({items, nextCursor}) by shape, so the iterator is checked rather than flagged as stray. --- CHANGELOG.md | 9 ++ README.md | 49 ++++++++++- src/sendly/__init__.py | 2 + src/sendly/client.py | 87 +++++++++++++----- src/sendly/resources/domains.py | 58 ++++++++++++ src/sendly/resources/mailboxes.py | 119 ++++++++++++++++++++++++- src/sendly/types.py | 25 ++++++ tests/test_contract.py | 29 +++--- tests/test_domain_logo.py | 81 +++++++++++++++++ tests/test_mailbox_conversations.py | 132 ++++++++++++++++++++++++++++ 10 files changed, 548 insertions(+), 43 deletions(-) create mode 100644 tests/test_domain_logo.py create mode 100644 tests/test_mailbox_conversations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef241f..14eaa12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to `sendly-python` are documented here. This project adheres ### Added +- **Mailbox mail.** `mailboxes.list_conversations` / `iter_list_conversations`, + `get_conversation`, `reply_to_conversation` and `download_attachment`, under + the opt-in `mailbox-conversations:read` / `:reply` scopes, which no existing + key carries. +- **The sender logo (BIMI).** `domains.get_logo_v1`, `upload_logo_v1`, + `delete_logo_v1` and `get_logo_readiness_v1`. +- **`client.request_bytes`** for an operation that answers a file. It returns a + `SendlyFile(data: bytes, content_type, content_disposition)` and raises the + same typed errors. `request` reads the body as text, which mangles a binary file. - **`emails.send` takes `listUnsubscribe`**, the https URL of your own one-click unsubscribe endpoint. A transactional send carries no unsubscribe of Sendly's, because Sendly cannot know what the reader would be opting out of; with this diff --git a/README.md b/README.md index 8585243..cd169f4 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,28 @@ neither is a spelling of the other. `verify_v1` verifies nothing itself: verification happens in the domain's DNS when its owner publishes the DKIM records SES minted at creation, and this call asks SES what it currently sees. +#### The sender logo (BIMI) + +Each identity serves a logo that BIMI-capable inboxes show beside its mail, once +its DNS is ready: + +```python +logo = sendly.domains.get_logo_v1("d_123") +print(logo["bimi_record"]) # publish this TXT record once; the URL never changes + +sendly.domains.upload_logo_v1("d_123", {"svg": svg_source}) # square, shapes not +for item in sendly.domains.get_logo_readiness_v1("d_123")["items"]: + print(item["key"], item["status"], item["reason"]) +``` + +The upload is rebuilt into SVG Tiny PS, the profile BIMI requires. A file that +cannot be rebuilt (text not converted to outlines, a raster image, a canvas that +is not square, over 32 KB once cleaned) is a 422 `validation_error` naming what to +change, and the previous logo stays. `delete_logo_v1` goes back to the default +(the domain's initial). Providers still decide whether to show a logo, and Gmail +and Apple Mail need a VMC or CMC certificate, which Sendly neither issues nor +validates. + ### Mailboxes Receiving mailboxes on the project's verified domains. The reads are reads; the @@ -459,8 +481,8 @@ for pw in sendly.mailboxes.list_app_passwords("mb_123"): print(pw["name"], pw["lastFour"], pw["lastUsedAt"]) ``` -This lists the mailboxes themselves, never their contents — received messages -are not part of the public API. The mailbox **password** is never returned by +`list` and `get` describe the mailboxes themselves, never their contents (the +mail is below, under a scope of its own). The mailbox **password** is never returned by any of these reads; mailbox credentials are app passwords, created from the dashboard and shown once. `list_app_passwords` returns only the passwords that are still active — a revoked one drops out, so this is not an audit history. @@ -516,6 +538,29 @@ treated strictly as data describing what to write, never as instructions to the model. Capped at 120 requests an hour per project; 502 means the model was unreachable. +#### The mail itself: read, reply, download + +These read correspondence from people outside your project, so they need +`mailbox-conversations:read`, and answering needs `mailbox-conversations:reply`. +Both scopes are **opt-in only**: a key holds one only if it was ticked by name +when the key was created, and neither the legacy full-access key nor the "Full +access" preset carries it. An existing key gets 403 until you mint one that does. + +```python +for thread in sendly.mailboxes.iter_list_conversations("mb_123", {"status": "open"}): + messages = sendly.mailboxes.get_conversation("mb_123", thread["id"]) # oldest first + +sendly.mailboxes.reply_to_conversation("mb_123", thread_id, {"body": "Sorted, thanks."}) + +file = sendly.mailboxes.download_attachment("mb_123", attachment_id) +# file.data is bytes; file.content_type is the SENDER's claim. +``` + +A reply names neither the recipient nor the sender: it answers whoever last wrote +in, from the mailbox's own address, in the same thread. Message bodies and +attachments were written by the sender, so treat `htmlBody`, the bytes and their +content type as untrusted. Reading marks nothing as read. + ### Templates and snippets ```python diff --git a/src/sendly/__init__.py b/src/sendly/__init__.py index e1c0389..2a0efdf 100644 --- a/src/sendly/__init__.py +++ b/src/sendly/__init__.py @@ -50,6 +50,7 @@ from sendly.resources.verify import VerifyResource from sendly.resources.webhooks import WebhooksResource from sendly.resources.workflows import WorkflowsResource +from sendly.types import SendlyFile from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature __version__ = SDK_VERSION @@ -74,6 +75,7 @@ "SendlyConflictError", "SendlyConnectionError", "SendlyError", + "SendlyFile", "SendlyNotFoundError", "SendlyPermissionError", "SendlyRateLimitError", diff --git a/src/sendly/client.py b/src/sendly/client.py index 419a256..94d0e85 100644 --- a/src/sendly/client.py +++ b/src/sendly/client.py @@ -53,6 +53,7 @@ from sendly.resources.verify import VerifyResource from sendly.resources.webhooks import WebhooksResource from sendly.resources.workflows import WorkflowsResource +from sendly.types import SendlyFile if TYPE_CHECKING: from collections.abc import Mapping @@ -174,30 +175,7 @@ def request( success body, or ``None`` for 204 / ``no_content``. Errors are raised as :class:`SendlyError` subclasses based on status. """ - url = self._build_url(path, query) - request_headers: dict[str, str] = { - "Authorization": f"Bearer {self._api_key}", - "Accept": "application/json", - "User-Agent": USER_AGENT, - **self._default_headers, - **(dict(headers) if headers else {}), - } - - content: str | None = None - if body is not None: - request_headers["Content-Type"] = "application/json" - content = json.dumps(body) - - try: - response = self._client.request( - method, - url, - content=content, - headers=request_headers, - timeout=self._timeout, - ) - except httpx.HTTPError as exc: - raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc + response = self._send(method=method, path=path, body=body, query=query, headers=headers) # 204 No Content or caller-forced no-content (DELETE endpoints, which # now respond 200 with an id body the SDK intentionally discards). @@ -229,6 +207,67 @@ def request( return parsed + def request_bytes( + self, + *, + method: str, + path: str, + query: Query | None = None, + headers: Mapping[str, str] | None = None, + ) -> SendlyFile: + """Like :meth:`request`, for an operation whose success body is a file. + + Returns the raw bytes with the response's own ``Content-Type`` and + ``Content-Disposition``. A failure is still a JSON error document and is + raised as the same :class:`SendlyError` subclasses. Reading the body as + text, as :meth:`request` does, would mangle any file that is not UTF-8. + """ + response = self._send( + method=method, path=path, query=query, headers={"Accept": "*/*", **(headers or {})} + ) + if not response.is_success: + self._raise_for_error(response) + return SendlyFile( + data=response.content, + content_type=response.headers.get("content-type"), + content_disposition=response.headers.get("content-disposition"), + ) + + def _send( + self, + *, + method: str, + path: str, + body: Body | None = None, + query: Query | None = None, + headers: Mapping[str, str] | None = None, + ) -> httpx.Response: + """Issue the request; errors and parsing are the caller's.""" + url = self._build_url(path, query) + request_headers: dict[str, str] = { + "Authorization": f"Bearer {self._api_key}", + "Accept": "application/json", + "User-Agent": USER_AGENT, + **self._default_headers, + **(dict(headers) if headers else {}), + } + + content: str | None = None + if body is not None: + request_headers["Content-Type"] = "application/json" + content = json.dumps(body) + + try: + return self._client.request( + method, + url, + content=content, + headers=request_headers, + timeout=self._timeout, + ) + except httpx.HTTPError as exc: + raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc + def unwrap(self, envelope: Any) -> Any: """Return the ``data`` field of a ``{success, data}`` envelope, else the value unchanged. Centralizing this keeps resource code clean. diff --git a/src/sendly/resources/domains.py b/src/sendly/resources/domains.py index 9450d27..7c83986 100644 --- a/src/sendly/resources/domains.py +++ b/src/sendly/resources/domains.py @@ -16,6 +16,8 @@ DomainDeletedV1, DomainListResponse, DomainListV1, + DomainLogoReadinessV1, + DomainLogoV1, DomainRecord, DomainSetupSession, DomainV1, @@ -208,3 +210,59 @@ def delete_v1(self, id: str) -> DomainDeletedV1: method="DELETE", path=f"/api/v1/domains/{encode_path_segment(id)}" ) return response + + def get_logo_v1(self, id: str) -> DomainLogoV1: + """The logo this identity serves to inboxes (BIMI), the + ``default._bimi`` TXT record that makes them fetch it (``bimi_record``), + and a fresh DNS reading of whether they will show it today. + + The URL never changes, so the record is published once. Until a logo is + uploaded it serves a default (the domain's initial), and it serves + nothing until the identity verifies. Providers decide whether to show a + logo; Gmail also needs a VMC or CMC certificate, which Sendly neither + issues nor checks. :meth:`get_logo_readiness_v1` is the full checklist. + """ + response: DomainLogoV1 = self._client.request( + method="GET", path=f"/api/v1/domains/{encode_path_segment(id)}/logo" + ) + return response + + def upload_logo_v1(self, id: str, body: Body) -> DomainLogoV1: + """Replace the logo with an SVG: ``{"svg": ""}``. + + It is rebuilt into SVG Tiny PS, the profile BIMI requires: square, shapes + rather than ````, nothing referenced outside itself, at most 32 KB + once rebuilt. A file that cannot be rebuilt answers 422 + ``validation_error`` with a ``detail`` naming what to change, and the + previous logo stays in place. + """ + response: DomainLogoV1 = self._client.request( + method="PUT", path=f"/api/v1/domains/{encode_path_segment(id)}/logo", body=body + ) + return response + + def delete_logo_v1(self, id: str) -> DomainLogoV1: + """Remove the uploaded logo, so the URL serves the default again. + + Returns the logo now served; removing one that was never uploaded is not + an error. The DNS record does not change. + """ + response: DomainLogoV1 = self._client.request( + method="DELETE", path=f"/api/v1/domains/{encode_path_segment(id)}/logo" + ) + return response + + def get_logo_readiness_v1(self, id: str) -> DomainLogoReadinessV1: + """How ready the identity is to show its logo as the inbox avatar. + + Each step (DMARC at enforcement on the host and its organizational + domain, the BIMI record, the logo file, the mark certificate) comes back + ``met``, ``missing``, ``unknown`` or ``not_applicable`` with a + plain-English ``reason``, plus one checklist per inbox (Gmail, Yahoo, + Apple Mail, Fastmail). For the certificate only "the URL answers with a + PEM file" is checked; its chain is not validated. + """ + response: DomainLogoReadinessV1 = self._client.request( + method="GET", path=f"/api/v1/domains/{encode_path_segment(id)}/logo/readiness" + ) + return response diff --git a/src/sendly/resources/mailboxes.py b/src/sendly/resources/mailboxes.py index e89578b..1a5883b 100644 --- a/src/sendly/resources/mailboxes.py +++ b/src/sendly/resources/mailboxes.py @@ -7,8 +7,21 @@ from sendly.resources._helpers import encode_path_segment if TYPE_CHECKING: + from collections.abc import Iterator + from sendly.client import Sendly - from sendly.types import AppPasswordList, Body, JSONDict, MailboxDetail, MailboxList + from sendly.types import ( + AppPasswordList, + Body, + JSONDict, + MailboxConversation, + MailboxConversationPage, + MailboxDetail, + MailboxList, + MailboxMessageList, + Query, + SendlyFile, + ) class MailboxesResource: @@ -42,8 +55,8 @@ def list(self) -> MailboxList: project's whole allowance, and they are still returned here: a project with a run of failed provisions can therefore list more than 10. - This lists the mailboxes themselves, never their contents: received - messages are not part of the public API. + This lists the mailboxes themselves, never their contents: the mail is + :meth:`list_conversations`, under a scope of its own. """ envelope = self._client.request(method="GET", path="/api/mailboxes") records: MailboxList = self._client.unwrap(envelope) @@ -131,3 +144,103 @@ def draft_message(self, id: str, body: Body) -> JSONDict: ) draft: JSONDict = self._client.unwrap(envelope) return draft + + # The mail itself. The methods below read and answer correspondence from + # people outside your project, so they need ``mailbox-conversations:read`` + # (and ``mailbox-conversations:reply`` to answer). Both scopes are opt-in + # only: a key holds one only if it was ticked by name when it was created, + # and neither the legacy full-access key nor the "Full access" preset has + # it. An existing key gets 403 until you mint one that does. + + def list_conversations(self, id: str, query: Query | None = None) -> MailboxConversationPage: + """One page of a mailbox's threads, newest activity first. + + ``query`` takes ``view`` (``inbox``, the default, or ``spam``), + ``status`` (``open``, ``snoozed``, ``closed`` or ``all``), ``q`` (a + substring of the subject or the correspondent's address), ``cursor`` + and ``limit`` (at most 50). Returns ``{items, nextCursor}``: pass + ``nextCursor`` back as ``cursor`` until it is ``None``, or use + :meth:`iter_list_conversations`. A mailbox in another project answers 404. + """ + envelope = self._client.request( + method="GET", + path=f"/api/mailboxes/{encode_path_segment(id)}/conversations", + query=query, + ) + page: MailboxConversationPage = self._client.unwrap(envelope) + return page + + def iter_list_conversations( + self, id: str, query: Query | None = None + ) -> Iterator[MailboxConversation]: + """Every thread in a mailbox across pages, one conversation at a time. + + Stops when ``nextCursor`` is ``None`` or repeats the cursor it was given, + so a stuck page ends the walk instead of looping. + """ + params = dict(query or {}) + while True: + page = self.list_conversations(id, params) + yield from page.get("items") or [] + cursor = page.get("nextCursor") + if not cursor or cursor == params.get("cursor"): + return + params["cursor"] = cursor + + def get_conversation(self, id: str, conversation_id: str) -> MailboxMessageList: + """Every message in one thread, oldest first, inbound and outbound. + + Not paginated, and reading marks nothing as read. Bodies were written by + whoever sent them: treat ``htmlBody`` as hostile HTML. A conversation + that is not in this mailbox returns an empty list; a mailbox outside the + key's project answers 404. + """ + envelope = self._client.request( + method="GET", + path=( + f"/api/mailboxes/{encode_path_segment(id)}" + f"/conversations/{encode_path_segment(conversation_id)}" + ), + ) + messages: MailboxMessageList = self._client.unwrap(envelope) + return messages + + def reply_to_conversation(self, id: str, conversation_id: str, body: Body) -> JSONDict: + """SENDS a reply, from the mailbox's own address, in the same thread, + to the person who last wrote in. + + You name neither the recipient nor the sender: this only ever answers + someone who wrote first, which is why it has its own scope and is not + :meth:`send_message`. Pass plain text in ``body`` or a structured + ``document``; Sendly renders the HTML and a matching text part. + + Refusals worth handling by name: 422 ``UNPROCESSABLE`` (no inbound + message to answer, or the mailbox is not provisioned), 422 + ``SEND_REFUSED`` (the recipient is suppressed or the project may not + send), 429 ``SENDING_LIMIT_REACHED``. + """ + envelope = self._client.request( + method="POST", + path=( + f"/api/mailboxes/{encode_path_segment(id)}" + f"/conversations/{encode_path_segment(conversation_id)}/reply" + ), + body=body, + ) + submitted: JSONDict = self._client.unwrap(envelope) + return submitted + + def download_attachment(self, id: str, attachment_id: str) -> SendlyFile: + """The bytes of one attachment, the ``url`` a message lists. + + The file and its ``content_type`` were chosen by the sender: treat both + as untrusted, and never serve the bytes back to a browser under your own + origin without a fixed type. + """ + return self._client.request_bytes( + method="GET", + path=( + f"/api/mailboxes/{encode_path_segment(id)}" + f"/attachments/{encode_path_segment(attachment_id)}" + ), + ) diff --git a/src/sendly/types.py b/src/sendly/types.py index b37f74a..13fbd95 100644 --- a/src/sendly/types.py +++ b/src/sendly/types.py @@ -15,6 +15,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from typing import Any # ---------- Generic JSON ---------- @@ -84,6 +85,26 @@ #: types here sidesteps the shadowing and keeps the annotations readable. MailboxList = list[JSONDict] AppPasswordList = list[JSONDict] +#: One page of a mailbox's threads: ``{items, nextCursor}``. +MailboxConversationPage = JSONDict +#: One thread in a mailbox: correspondent, subject, status, last activity. +MailboxConversation = JSONDict +#: A thread's messages, oldest first. Bodies are the sender's and untrusted. +MailboxMessageList = list[JSONDict] + + +@dataclass(frozen=True) +class SendlyFile: + """A file the API answered with, as :meth:`Sendly.request_bytes` returns it.""" + + #: The body, byte for byte. + data: bytes + #: As the server declared it. For a mailbox attachment that is the SENDER's + #: claim: do not trust it. + content_type: str | None + #: ``attachment; filename="..."`` or ``inline; ...``, verbatim. + content_disposition: str | None + # ---------- Projects (v1) ---------- @@ -193,6 +214,10 @@ DomainV1 = JSONDict DomainListV1 = CursorList DomainDeletedV1 = JSONDict +#: The logo an identity serves to inboxes, its BIMI record, and a DNS readiness reading. +DomainLogoV1 = JSONDict +#: The per-step, per-inbox checklist for showing the logo as the inbox avatar. +DomainLogoReadinessV1 = JSONDict # ---------- Webhooks (v1) ---------- diff --git a/tests/test_contract.py b/tests/test_contract.py index 4ff1d12..9a66a08 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -48,19 +48,9 @@ #: every entry is asserted to exist in the vendored spec and to NOT be #: implemented, so a stale entry fails the suite. #: -#: NOT YET -- a genuine gap. Kept in step with sendly-js's NOT_YET_IMPLEMENTED. -NOT_YET_IMPLEMENTED: set[tuple[str, str]] = { - # Reading and answering a mailbox's mail. - ("GET", "/api/mailboxes/{id}/conversations"), - ("GET", "/api/mailboxes/{id}/conversations/{conversationId}"), - ("POST", "/api/mailboxes/{id}/conversations/{conversationId}/reply"), - ("GET", "/api/mailboxes/{id}/attachments/{attachmentId}"), - # A sending domain's sender logo (the inbox avatar) and its readiness checklist. - ("GET", "/api/v1/domains/{id}/logo"), - ("PUT", "/api/v1/domains/{id}/logo"), - ("DELETE", "/api/v1/domains/{id}/logo"), - ("GET", "/api/v1/domains/{id}/logo/readiness"), -} +#: NOT YET -- a genuine gap. Empty: every key-callable operation is mapped to a +#: resource method. Kept in step with sendly-js's NOT_YET_IMPLEMENTED. +NOT_YET_IMPLEMENTED: set[tuple[str, str]] = set() def _is_compat_dialect(path: str) -> bool: @@ -170,6 +160,11 @@ def _resolve_ref(spec: dict[str, Any], node: Any) -> Any: #: dialect fails here instead of being assumed away. CURSOR_ENVELOPES = (frozenset({"data", "has_more", "next_cursor"}),) +#: The legacy ``/api/*`` surface's one cursor list, a mailbox's conversations: +#: ``{success, data: {items, nextCursor}}``, paged by passing ``nextCursor`` back +#: as ``cursor``. Recognised by that nested shape, not by path, for the same reason. +LEGACY_CURSOR_PAGE = frozenset({"items", "nextCursor"}) + def _cursor_list_operations(spec: dict[str, Any]) -> set[tuple[str, str]]: """Spec operations answering the cursor envelope, as ``(VERB, normalized_path)``. @@ -190,6 +185,11 @@ def _cursor_list_operations(spec: dict[str, Any]) -> set[tuple[str, str]]: properties = schema.get("properties", {}) if isinstance(schema, dict) else {} if any(envelope.issubset(properties) for envelope in CURSOR_ENVELOPES): cursor_ops.add((verb, norm)) + data = _resolve_ref(spec, properties.get("data", {})) + if isinstance(data, dict) and LEGACY_CURSOR_PAGE.issubset( + data.get("properties", {}) + ): + cursor_ops.add((verb, norm)) return cursor_ops @@ -239,7 +239,8 @@ def _request_calls(tree: ast.AST) -> list[ast.Call]: for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) - and node.func.attr == "request" + # ``request_bytes`` is the same call for an operation that answers a file. + and node.func.attr in {"request", "request_bytes"} ] diff --git a/tests/test_domain_logo.py b/tests/test_domain_logo.py new file mode 100644 index 0000000..d20bb3b --- /dev/null +++ b/tests/test_domain_logo.py @@ -0,0 +1,81 @@ +"""The sender logo (BIMI) on /api/v1 domains: bare bodies, no envelope.""" + +from __future__ import annotations + +import json + +import pytest + +from sendly import SendlyValidationError +from support import Recorder, json_response, make_client, problem_response + +LOGO = { + "domain_id": "dom_1", + "source": "uploaded", + "url": "https://api.sendly.now/bimi/dom_1.svg", + "serving": True, +} + + +def test_get_logo_returns_the_document_as_is(): + rec = Recorder(json_response(200, LOGO)) + client = make_client(rec) + + logo = client.domains.get_logo_v1("dom_1") + + assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1/logo" + assert logo == LOGO + + +def test_upload_logo_puts_the_svg_source_as_json(): + rec = Recorder(json_response(200, LOGO)) + client = make_client(rec) + svg = '' + + client.domains.upload_logo_v1("dom_1", {"svg": svg}) + + assert rec.request.method == "PUT" + assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1/logo" + assert json.loads(rec.request.content) == {"svg": svg} + + +def test_a_logo_that_cannot_be_rebuilt_is_a_validation_error_naming_what_to_change(): + rec = Recorder( + problem_response( + 422, + { + "type": "https://docs.sendly.now/errors/validation_error", + "title": "Validation error", + "code": "validation_error", + "status": 422, + "detail": "Convert the text to outlines: is not allowed in SVG Tiny PS.", + }, + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyValidationError) as caught: + client.domains.upload_logo_v1("dom_1", {"svg": "Acme"}) + + assert "outlines" in str(caught.value) + + +def test_delete_logo_returns_the_logo_now_served(): + rec = Recorder(json_response(200, {**LOGO, "source": "default"})) + client = make_client(rec) + + logo = client.domains.delete_logo_v1("dom_1") + + assert rec.request.method == "DELETE" + assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1/logo" + assert logo["source"] == "default" + + +def test_get_logo_readiness_reads_the_checklist(): + rec = Recorder(json_response(200, {"domain_id": "dom_1", "items": [], "providers": []})) + client = make_client(rec) + + readiness = client.domains.get_logo_readiness_v1("dom_1") + + assert str(rec.request.url) == "http://localhost/api/v1/domains/dom_1/logo/readiness" + assert readiness["domain_id"] == "dom_1" diff --git a/tests/test_mailbox_conversations.py b/tests/test_mailbox_conversations.py new file mode 100644 index 0000000..5589573 --- /dev/null +++ b/tests/test_mailbox_conversations.py @@ -0,0 +1,132 @@ +"""Mailbox mail: reading threads, answering them, and downloading attachments.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from sendly import SendlyPermissionError +from support import Recorder, SequenceRecorder, json_response, make_client, problem_response + + +def _page(items: list[dict[str, str]], next_cursor: str | None) -> httpx.Response: + return json_response( + 200, {"success": True, "data": {"items": items, "nextCursor": next_cursor}} + ) + + +def test_list_conversations_sends_the_filters_and_unwraps_the_page(): + rec = Recorder(_page([{"id": "c1"}], "c1")) + client = make_client(rec) + + page = client.mailboxes.list_conversations( + "mb_1", {"view": "spam", "status": "open", "limit": 1} + ) + + assert str(rec.request.url) == ( + "http://localhost/api/mailboxes/mb_1/conversations?view=spam&status=open&limit=1" + ) + assert page == {"items": [{"id": "c1"}], "nextCursor": "c1"} + + +def test_iter_list_conversations_follows_next_cursor_as_cursor_keeping_the_filters(): + rec = SequenceRecorder( + _page([{"id": "c1"}, {"id": "c2"}], "c2"), + _page([{"id": "c3"}], None), + ) + client = make_client(rec) + + ids = [ + item["id"] for item in client.mailboxes.iter_list_conversations("mb_1", {"q": "invoice"}) + ] + + assert ids == ["c1", "c2", "c3"] + assert [str(request.url) for request in rec.requests] == [ + "http://localhost/api/mailboxes/mb_1/conversations?q=invoice", + "http://localhost/api/mailboxes/mb_1/conversations?q=invoice&cursor=c2", + ] + + +def test_iter_list_conversations_stops_on_a_page_that_repeats_its_cursor(): + # SequenceRecorder fails on a third request, so a walker that kept going would error. + rec = SequenceRecorder(_page([{"id": "c1"}], "c1"), _page([{"id": "c1"}], "c1")) + client = make_client(rec) + + ids = [item["id"] for item in client.mailboxes.iter_list_conversations("mb_1")] + + assert ids == ["c1", "c1"] + assert len(rec.requests) == 2 + + +def test_get_conversation_returns_the_messages(): + rec = Recorder(json_response(200, {"success": True, "data": [{"id": "m1"}, {"id": "m2"}]})) + client = make_client(rec) + + messages = client.mailboxes.get_conversation("mb_1", "c1") + + assert str(rec.request.url) == "http://localhost/api/mailboxes/mb_1/conversations/c1" + assert [message["id"] for message in messages] == ["m1", "m2"] + + +def test_reply_posts_only_the_body_and_names_no_recipient_or_sender(): + rec = Recorder( + json_response(200, {"success": True, "data": {"submitted": True, "status": "queued"}}) + ) + client = make_client(rec) + + reply = client.mailboxes.reply_to_conversation("mb_1", "c1", {"body": "Thanks, sorted."}) + + assert rec.request.method == "POST" + assert str(rec.request.url) == "http://localhost/api/mailboxes/mb_1/conversations/c1/reply" + assert json.loads(rec.request.content) == {"body": "Thanks, sorted."} + assert reply == {"submitted": True, "status": "queued"} + + +def test_a_key_without_the_opt_in_scope_gets_a_permission_error_not_an_empty_inbox(): + rec = Recorder( + problem_response( + 403, {"type": "about:blank", "title": "Forbidden", "code": "forbidden", "status": 403} + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyPermissionError): + client.mailboxes.list_conversations("mb_1") + + +def test_download_attachment_returns_the_raw_bytes_and_the_senders_headers(): + # Not valid UTF-8: reading this as text would replace bytes with U+FFFD. + raw = bytes([0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE, 0x00, 0x80]) + rec = Recorder( + httpx.Response( + 200, + content=raw, + headers={ + "content-type": "image/png", + "content-disposition": 'attachment; filename="logo.png"', + }, + ) + ) + client = make_client(rec) + + file = client.mailboxes.download_attachment("mb_1", "att_1") + + assert str(rec.request.url) == "http://localhost/api/mailboxes/mb_1/attachments/att_1" + assert rec.request.headers["accept"] == "*/*" + assert file.data == raw + assert file.content_type == "image/png" + assert file.content_disposition == 'attachment; filename="logo.png"' + + +def test_a_refused_download_raises_the_same_typed_error(): + rec = Recorder( + problem_response( + 403, {"type": "about:blank", "title": "Forbidden", "code": "forbidden", "status": 403} + ) + ) + client = make_client(rec) + + with pytest.raises(SendlyPermissionError): + client.mailboxes.download_attachment("mb_1", "att_1")