Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>
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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/sendly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -74,6 +75,7 @@
"SendlyConflictError",
"SendlyConnectionError",
"SendlyError",
"SendlyFile",
"SendlyNotFoundError",
"SendlyPermissionError",
"SendlyRateLimitError",
Expand Down
87 changes: 63 additions & 24 deletions src/sendly/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions src/sendly/resources/domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
DomainDeletedV1,
DomainListResponse,
DomainListV1,
DomainLogoReadinessV1,
DomainLogoV1,
DomainRecord,
DomainSetupSession,
DomainV1,
Expand Down Expand Up @@ -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": "<svg ...>"}``.

It is rebuilt into SVG Tiny PS, the profile BIMI requires: square, shapes
rather than ``<text>``, 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
Loading
Loading