diff --git a/README.md b/README.md index b66389b..7aebcf2 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,51 @@ rendered = sk.templates.render("welcome", {"name": "Ada"}) print(rendered.output, rendered.missing) ``` +## Inbound + +Provision addresses on your workspace's shared receiving domain and read the mail +sent to them. Requires an API key with the `inbound` scope. + +```python +# Provision an address (omit local_part for an auto-generated one). +addr = sk.inbound.addresses.create(local_part="support", forward_to="team@acme.com") +print(addr.address) # "support@acme.in.senderkit.email" + +for a in sk.inbound.addresses.list(): + print(a.id, a.address) + +# Received mail, newest first (filter by address, page with before=). +for m in sk.inbound.messages.list(address=addr.id, limit=50): + print(m.id, m.from_, m.subject) + +msg = sk.inbound.messages.get("rcv_123") +print(msg.text, [a.filename for a in msg.attachments]) + +# Raw MIME source and attachment bytes. +raw = sk.inbound.messages.raw("rcv_123") # raw.content is bytes +pdf = sk.inbound.messages.attachment("rcv_123", 0) # pdf.filename / pdf.content + +sk.inbound.addresses.delete(addr.id) +``` + +Receive on your own domain instead of the shared one, and use a catch-all address: + +```python +# Claim a custom domain — publish the returned DNS records to verify it. +domain = sk.inbound.domains.create("inbound.acme.com") +for r in domain.records: + print(r.type, r.name, r.value) + +# A catch-all on that domain (receives every local part no exact address claims). +sk.inbound.addresses.create(local_part="*", domain_id=domain.id) + +for d in sk.inbound.domains.list(): + print(d.domain, d.status) +``` + +Delivery of received mail is surfaced through the standard webhook engine as a +`message.received` event. + ## Webhooks SenderKit signs each webhook with an HMAC over the raw request body. Verify it against the diff --git a/pyproject.toml b/pyproject.toml index a58952a..65e9eee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,10 @@ dev = [ "pytest-cov>=5", "respx>=0.21", "mypy>=1.10", - "ruff>=0.5", + # Kept in lockstep with the pinned ruff-pre-commit hook (.pre-commit-config.yaml). + # Pinned below 0.16: ruff 0.16 formats Python code blocks inside Markdown by + # default, which would make `ruff format --check .` reformat README.md. + "ruff==0.15.17", "pre-commit>=3.7", "build>=1.2", "twine>=5", diff --git a/src/senderkit/__init__.py b/src/senderkit/__init__.py index d42e71d..73c3273 100644 --- a/src/senderkit/__init__.py +++ b/src/senderkit/__init__.py @@ -32,6 +32,13 @@ Channel, Context, EmailContent, + InboundAddress, + InboundAttachment, + InboundBytes, + InboundDnsRecord, + InboundDomain, + InboundMessage, + InboundMessageSummary, Message, MessageList, PushContent, @@ -82,6 +89,13 @@ "BatchResult", "Message", "MessageList", + "InboundAddress", + "InboundMessage", + "InboundMessageSummary", + "InboundAttachment", + "InboundBytes", + "InboundDomain", + "InboundDnsRecord", "TemplateSummary", "TemplateDetail", "TemplateVersion", diff --git a/src/senderkit/client.py b/src/senderkit/client.py index ce6b51a..1f8d15d 100644 --- a/src/senderkit/client.py +++ b/src/senderkit/client.py @@ -27,7 +27,14 @@ SendResult, TemplateSend, ) -from .resources import AsyncMessages, AsyncTemplates, Messages, Templates +from .resources import ( + AsyncInbound, + AsyncMessages, + AsyncTemplates, + Inbound, + Messages, + Templates, +) DEFAULT_BASE_URL = "https://api.senderkit.com" DEFAULT_TIMEOUT = 30.0 @@ -70,6 +77,7 @@ def __init__( self._transport = Transport(api_key, base_url, timeout, max_retries, http_client) self.messages = Messages(self._transport) self.templates = Templates(self._transport) + self.inbound = Inbound(self._transport) def send( self, @@ -206,6 +214,7 @@ def __init__( self._transport = AsyncTransport(api_key, base_url, timeout, max_retries, http_client) self.messages = AsyncMessages(self._transport) self.templates = AsyncTemplates(self._transport) + self.inbound = AsyncInbound(self._transport) async def send( self, diff --git a/src/senderkit/models.py b/src/senderkit/models.py index 3384c46..1d63a10 100644 --- a/src/senderkit/models.py +++ b/src/senderkit/models.py @@ -321,6 +321,175 @@ def from_dict(cls, d: Dict[str, Any]) -> RenderResult: ) +# --------------------------------------------------------------------------- # +# Inbound — receiving addresses and received mail (``inbound`` scope) +# --------------------------------------------------------------------------- # +@dataclass +class InboundAddress: + """An address provisioned on the workspace's shared receiving domain.""" + + id: str + address: str + description: Optional[str] + forward_to: Optional[str] + active: bool + livemode: bool + created_at: str + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> InboundAddress: + return cls( + id=str(d.get("id", "")), + address=str(d.get("address", "")), + description=d.get("description"), + forward_to=d.get("forwardTo"), + active=bool(d.get("active", False)), + livemode=bool(d.get("livemode", False)), + created_at=str(d.get("createdAt", "")), + ) + + +@dataclass +class InboundMessageSummary: + """A received-message summary, as returned by ``inbound.messages.list``.""" + + id: str + status: str + from_: Optional[str] + subject: Optional[str] + plus_tag: Optional[str] + size_bytes: int + received_at: str + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> InboundMessageSummary: + return cls( + id=str(d.get("id", "")), + status=str(d.get("status", "")), + from_=d.get("from"), + subject=d.get("subject"), + plus_tag=d.get("plusTag"), + size_bytes=int(d.get("sizeBytes", 0)), + received_at=str(d.get("receivedAt", "")), + ) + + +@dataclass +class InboundAttachment: + """One attachment on a received message. Fetch bytes via ``.attachment(id, index)``.""" + + index: int + filename: Optional[str] + content_type: str + size: int + #: Authenticated API URL (requires an ``inbound``-scoped key), not a signed link. + url: str + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> InboundAttachment: + return cls( + index=int(d.get("index", 0)), + filename=d.get("filename"), + content_type=str(d.get("contentType", "")), + size=int(d.get("size", 0)), + url=str(d.get("url", "")), + ) + + +@dataclass +class InboundMessage: + """A received message. Common fields are typed; ``.raw`` holds the full body.""" + + id: str + status: str + channel: str + address: Optional[str] + subject: Optional[str] + text: Optional[str] + html: Optional[str] + stripped_reply: Optional[str] + size_bytes: int + received_at: str + raw_url: str + attachments: List[InboundAttachment] = field(default_factory=list) + raw: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> InboundMessage: + atts = d.get("attachments") or [] + return cls( + id=str(d.get("id", "")), + status=str(d.get("status", "")), + channel=str(d.get("channel", "")), + address=d.get("address"), + subject=d.get("subject"), + text=d.get("text"), + html=d.get("html"), + stripped_reply=d.get("strippedReply"), + size_bytes=int(d.get("sizeBytes", 0)), + received_at=str(d.get("receivedAt", "")), + raw_url=str(d.get("rawUrl", "")), + attachments=[InboundAttachment.from_dict(a) for a in atts if isinstance(a, dict)], + raw=d, + ) + + +@dataclass +class InboundBytes: + """Raw bytes fetched from an inbound message (raw MIME source or attachment).""" + + content: bytes + content_type: str + filename: Optional[str] = None + + +@dataclass +class InboundDnsRecord: + """A DNS record a custom inbound domain must publish before it can receive.""" + + type: str + name: str + value: str + purpose: str + priority: Optional[int] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> InboundDnsRecord: + return cls( + type=str(d.get("type", "")), + name=str(d.get("name", "")), + value=str(d.get("value", "")), + purpose=str(d.get("purpose", "")), + priority=d.get("priority"), + ) + + +@dataclass +class InboundDomain: + """A custom inbound domain the workspace receives mail on (or the shared one).""" + + id: str + domain: str + kind: str + status: str + verified_at: Optional[str] + created_at: str + records: List[InboundDnsRecord] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> InboundDomain: + recs = d.get("records") or [] + return cls( + id=str(d.get("id", "")), + domain=str(d.get("domain", "")), + kind=str(d.get("kind", "")), + status=str(d.get("status", "")), + verified_at=d.get("verifiedAt"), + created_at=str(d.get("createdAt", "")), + records=[InboundDnsRecord.from_dict(r) for r in recs if isinstance(r, dict)], + ) + + @dataclass class Workspace: id: str diff --git a/src/senderkit/resources/__init__.py b/src/senderkit/resources/__init__.py index ee8d263..d140c4f 100644 --- a/src/senderkit/resources/__init__.py +++ b/src/senderkit/resources/__init__.py @@ -1,6 +1,15 @@ -"""Resource namespaces exposed on the client (``client.messages``, ``client.templates``).""" +"""Resource namespaces exposed on the client (``client.messages``, ``client.templates``, +``client.inbound``).""" +from .inbound import AsyncInbound, Inbound from .messages import AsyncMessages, Messages from .templates import AsyncTemplates, Templates -__all__ = ["Messages", "AsyncMessages", "Templates", "AsyncTemplates"] +__all__ = [ + "Messages", + "AsyncMessages", + "Templates", + "AsyncTemplates", + "Inbound", + "AsyncInbound", +] diff --git a/src/senderkit/resources/inbound.py b/src/senderkit/resources/inbound.py new file mode 100644 index 0000000..3437bef --- /dev/null +++ b/src/senderkit/resources/inbound.py @@ -0,0 +1,308 @@ +"""The ``inbound`` resource: provision receiving addresses and read received mail. + +Requires an API key with the ``inbound`` scope. Exposed on the client as +``client.inbound.addresses`` and ``client.inbound.messages``. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any, Dict, List, Optional, Union +from urllib.parse import unquote + +from .._http import AsyncTransport, Transport +from ..models import ( + InboundAddress, + InboundBytes, + InboundDomain, + InboundMessage, + InboundMessageSummary, +) + +BeforeLike = Union[str, datetime] + + +def _iso(value: Optional[BeforeLike]) -> Optional[str]: + if value is None: + return None + return value.isoformat() if isinstance(value, datetime) else value + + +def _create_body( + local_part: Optional[str], + description: Optional[str], + forward_to: Optional[str], + webhook_endpoint_id: Optional[str], + domain_id: Optional[str], + livemode: Optional[bool], +) -> Dict[str, Any]: + body: Dict[str, Any] = {} + if local_part is not None: + body["localPart"] = local_part + if description is not None: + body["description"] = description + if forward_to is not None: + body["forwardTo"] = forward_to + if webhook_endpoint_id is not None: + body["webhookEndpointId"] = webhook_endpoint_id + if domain_id is not None: + body["domainId"] = domain_id + if livemode is not None: + body["livemode"] = livemode + return body + + +def _domain_create_body(domain: str, acknowledge_existing_mx: Optional[bool]) -> Dict[str, Any]: + if not domain: + raise ValueError("inbound.domains.create: domain is required") + body: Dict[str, Any] = {"domain": domain} + if acknowledge_existing_mx is not None: + body["acknowledgeExistingMx"] = acknowledge_existing_mx + return body + + +def _list_query( + limit: Optional[int], before: Optional[BeforeLike], address: Optional[str] +) -> Dict[str, Any]: + return {"limit": limit, "before": _iso(before), "address": address} + + +def _filename(content_disposition: Optional[str]) -> Optional[str]: + """Pull a filename from a Content-Disposition header (RFC 5987 aware).""" + if not content_disposition: + return None + extended = re.search(r"filename\*\s*=\s*(?:[^']*'[^']*')?([^;]+)", content_disposition, re.I) + if extended: + return unquote(extended.group(1).strip()) + plain = re.search(r'filename\s*=\s*"?([^";]+)"?', content_disposition, re.I) + return plain.group(1).strip() if plain else None + + +def _to_bytes(response: Any) -> InboundBytes: + return InboundBytes( + content=response.content, + content_type=response.headers.get("content-type", "application/octet-stream"), + filename=_filename(response.headers.get("content-disposition")), + ) + + +class InboundAddresses: + """Synchronous inbound-address operations.""" + + def __init__(self, transport: Transport) -> None: + self._t = transport + + def list(self) -> List[InboundAddress]: + """Return every inbound address on the workspace's shared domain, oldest first.""" + data = self._t.request_json("GET", "/v1/inbound/addresses") + rows = data.get("addresses") or [] + return [InboundAddress.from_dict(a) for a in rows] + + def create( + self, + *, + local_part: Optional[str] = None, + description: Optional[str] = None, + forward_to: Optional[str] = None, + webhook_endpoint_id: Optional[str] = None, + domain_id: Optional[str] = None, + livemode: Optional[bool] = None, + ) -> InboundAddress: + """Provision a new address. Omit ``local_part`` for an auto-generated one; + pass ``"*"`` for a catch-all. ``domain_id`` mints on a verified custom + domain; ``livemode`` sets the mode (defaults to live).""" + body = _create_body( + local_part, description, forward_to, webhook_endpoint_id, domain_id, livemode + ) + return InboundAddress.from_dict( + self._t.request_json("POST", "/v1/inbound/addresses", body=body) + ) + + def delete(self, id: str) -> bool: + """Soft-delete an address; later mail to it is dropped. Returns ``True``.""" + data = self._t.request_json("DELETE", f"/v1/inbound/addresses/{id}") + return bool(data.get("deleted", False)) + + +class InboundMessages: + """Synchronous received-message operations.""" + + def __init__(self, transport: Transport) -> None: + self._t = transport + + def list( + self, + *, + limit: Optional[int] = None, + before: Optional[BeforeLike] = None, + address: Optional[str] = None, + ) -> List[InboundMessageSummary]: + """Return received-message summaries, newest first.""" + data = self._t.request_json( + "GET", "/v1/inbound/messages", query=_list_query(limit, before, address) + ) + rows = data.get("messages") or [] + return [InboundMessageSummary.from_dict(m) for m in rows] + + def get(self, id: str) -> InboundMessage: + """Retrieve a received message, including body, headers, and verdicts.""" + return InboundMessage.from_dict(self._t.request_json("GET", f"/v1/inbound/messages/{id}")) + + def raw(self, id: str) -> InboundBytes: + """Fetch the raw RFC 822 source. 410s past the 30-day retention window.""" + resp = self._t.request("GET", f"/v1/inbound/messages/{id}/raw", accept="message/rfc822") + return _to_bytes(resp) + + def attachment(self, id: str, index: int) -> InboundBytes: + """Fetch one attachment's bytes by its zero-based ``index``.""" + resp = self._t.request( + "GET", + f"/v1/inbound/messages/{id}/attachments/{index}", + accept="application/octet-stream", + ) + return _to_bytes(resp) + + +class InboundDomains: + """Synchronous custom-inbound-domain operations.""" + + def __init__(self, transport: Transport) -> None: + self._t = transport + + def list(self) -> List[InboundDomain]: + """Return the workspace's inbound domains (shared + custom).""" + data = self._t.request_json("GET", "/v1/inbound/domains") + rows = data.get("domains") or [] + return [InboundDomain.from_dict(d) for d in rows] + + def create( + self, domain: str, *, acknowledge_existing_mx: Optional[bool] = None + ) -> InboundDomain: + """Claim a custom domain for receiving. The result carries the DNS + records to publish. If the domain already has live MX records elsewhere, + this raises a 409 ``SenderKitAPIError`` (``existing_mx``) — confirm with + the user, then retry with ``acknowledge_existing_mx=True``.""" + body = _domain_create_body(domain, acknowledge_existing_mx) + return InboundDomain.from_dict( + self._t.request_json("POST", "/v1/inbound/domains", body=body) + ) + + def delete(self, id: str) -> bool: + """Delete a custom inbound domain. The shared domain cannot be deleted.""" + data = self._t.request_json("DELETE", f"/v1/inbound/domains/{id}") + return bool(data.get("deleted", False)) + + +class Inbound: + """Synchronous ``inbound`` namespace: ``addresses``, ``messages``, ``domains``.""" + + def __init__(self, transport: Transport) -> None: + self.addresses = InboundAddresses(transport) + self.messages = InboundMessages(transport) + self.domains = InboundDomains(transport) + + +class AsyncInboundAddresses: + """Asynchronous inbound-address operations.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._t = transport + + async def list(self) -> List[InboundAddress]: + data = await self._t.request_json("GET", "/v1/inbound/addresses") + rows = data.get("addresses") or [] + return [InboundAddress.from_dict(a) for a in rows] + + async def create( + self, + *, + local_part: Optional[str] = None, + description: Optional[str] = None, + forward_to: Optional[str] = None, + webhook_endpoint_id: Optional[str] = None, + domain_id: Optional[str] = None, + livemode: Optional[bool] = None, + ) -> InboundAddress: + body = _create_body( + local_part, description, forward_to, webhook_endpoint_id, domain_id, livemode + ) + return InboundAddress.from_dict( + await self._t.request_json("POST", "/v1/inbound/addresses", body=body) + ) + + async def delete(self, id: str) -> bool: + data = await self._t.request_json("DELETE", f"/v1/inbound/addresses/{id}") + return bool(data.get("deleted", False)) + + +class AsyncInboundMessages: + """Asynchronous received-message operations.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._t = transport + + async def list( + self, + *, + limit: Optional[int] = None, + before: Optional[BeforeLike] = None, + address: Optional[str] = None, + ) -> List[InboundMessageSummary]: + data = await self._t.request_json( + "GET", "/v1/inbound/messages", query=_list_query(limit, before, address) + ) + rows = data.get("messages") or [] + return [InboundMessageSummary.from_dict(m) for m in rows] + + async def get(self, id: str) -> InboundMessage: + return InboundMessage.from_dict( + await self._t.request_json("GET", f"/v1/inbound/messages/{id}") + ) + + async def raw(self, id: str) -> InboundBytes: + resp = await self._t.request( + "GET", f"/v1/inbound/messages/{id}/raw", accept="message/rfc822" + ) + return _to_bytes(resp) + + async def attachment(self, id: str, index: int) -> InboundBytes: + resp = await self._t.request( + "GET", + f"/v1/inbound/messages/{id}/attachments/{index}", + accept="application/octet-stream", + ) + return _to_bytes(resp) + + +class AsyncInboundDomains: + """Asynchronous custom-inbound-domain operations.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._t = transport + + async def list(self) -> List[InboundDomain]: + data = await self._t.request_json("GET", "/v1/inbound/domains") + rows = data.get("domains") or [] + return [InboundDomain.from_dict(d) for d in rows] + + async def create( + self, domain: str, *, acknowledge_existing_mx: Optional[bool] = None + ) -> InboundDomain: + body = _domain_create_body(domain, acknowledge_existing_mx) + return InboundDomain.from_dict( + await self._t.request_json("POST", "/v1/inbound/domains", body=body) + ) + + async def delete(self, id: str) -> bool: + data = await self._t.request_json("DELETE", f"/v1/inbound/domains/{id}") + return bool(data.get("deleted", False)) + + +class AsyncInbound: + """Asynchronous ``inbound`` namespace: ``addresses``, ``messages``, ``domains``.""" + + def __init__(self, transport: AsyncTransport) -> None: + self.addresses = AsyncInboundAddresses(transport) + self.messages = AsyncInboundMessages(transport) + self.domains = AsyncInboundDomains(transport) diff --git a/tests/test_inbound.py b/tests/test_inbound.py new file mode 100644 index 0000000..84a4c31 --- /dev/null +++ b/tests/test_inbound.py @@ -0,0 +1,462 @@ +import httpx +import respx + +from tests.helpers import BASE_URL, request_body + + +@respx.mock +def test_list_inbound_addresses(client): + respx.get(f"{BASE_URL}/v1/inbound/addresses").mock( + return_value=httpx.Response( + 200, + json={ + "addresses": [ + { + "id": "inb_1", + "address": "support@acme.in.senderkit.email", + "description": "Support intake", + "forwardTo": None, + "active": True, + "livemode": False, + "createdAt": "2026-05-10T00:00:00Z", + } + ] + }, + ) + ) + addresses = client.inbound.addresses.list() + assert len(addresses) == 1 + assert addresses[0].id == "inb_1" + assert addresses[0].address == "support@acme.in.senderkit.email" + + +@respx.mock +def test_create_inbound_address_sends_only_provided_fields(client): + route = respx.post(f"{BASE_URL}/v1/inbound/addresses").mock( + return_value=httpx.Response( + 201, + json={ + "id": "inb_2", + "address": "support@acme.in.senderkit.email", + "description": None, + "forwardTo": None, + "active": True, + "livemode": False, + "createdAt": "2026-05-10T00:00:00Z", + }, + ) + ) + created = client.inbound.addresses.create(local_part="support") + assert created.id == "inb_2" + assert request_body(route.calls.last.request) == {"localPart": "support"} + + +@respx.mock +def test_delete_inbound_address(client): + respx.delete(f"{BASE_URL}/v1/inbound/addresses/inb_1").mock( + return_value=httpx.Response(200, json={"deleted": True}) + ) + deleted = client.inbound.addresses.delete("inb_1") + assert deleted is True + + +@respx.mock +def test_list_inbound_messages_with_filters(client): + route = respx.get(f"{BASE_URL}/v1/inbound/messages").mock( + return_value=httpx.Response( + 200, + json={ + "messages": [ + { + "id": "rcv_1", + "status": "received", + "from": "sender@example.com", + "subject": "Hello", + "plusTag": None, + "sizeBytes": 42, + "receivedAt": "2026-05-10T00:00:00Z", + } + ] + }, + ) + ) + messages = client.inbound.messages.list(limit=10, address="inb_1") + assert len(messages) == 1 + assert messages[0].id == "rcv_1" + assert messages[0].from_ == "sender@example.com" + params = dict(route.calls.last.request.url.params) + assert params["limit"] == "10" + assert params["address"] == "inb_1" + + +@respx.mock +def test_get_inbound_message(client): + respx.get(f"{BASE_URL}/v1/inbound/messages/rcv_1").mock( + return_value=httpx.Response( + 200, + json={ + "id": "rcv_1", + "status": "received", + "channel": "email", + "address": "support@acme.in.senderkit.email", + "subject": "Hello", + "text": "Hi there", + "html": None, + "strippedReply": "Hi there", + "sizeBytes": 42, + "receivedAt": "2026-05-10T00:00:00Z", + "rawUrl": "https://api.test/v1/inbound/messages/rcv_1/raw", + "attachments": [ + { + "index": 0, + "filename": "invoice.pdf", + "contentType": "application/pdf", + "size": 1024, + "url": "https://api.test/v1/inbound/messages/rcv_1/attachments/0", + } + ], + }, + ) + ) + msg = client.inbound.messages.get("rcv_1") + assert msg.subject == "Hello" + assert msg.text == "Hi there" + assert len(msg.attachments) == 1 + assert msg.attachments[0].filename == "invoice.pdf" + + +@respx.mock +def test_get_inbound_message_raw_returns_bytes(client): + respx.get(f"{BASE_URL}/v1/inbound/messages/rcv_1/raw").mock( + return_value=httpx.Response( + 200, + content=b"From: a@b.com\r\nSubject: Hi\r\n\r\nBody", + headers={"content-type": "message/rfc822"}, + ) + ) + raw = client.inbound.messages.raw("rcv_1") + assert raw.content_type == "message/rfc822" + assert b"Subject: Hi" in raw.content + + +@respx.mock +def test_get_inbound_attachment_parses_filename(client): + respx.get(f"{BASE_URL}/v1/inbound/messages/rcv_1/attachments/0").mock( + return_value=httpx.Response( + 200, + content=b"PDFBYTES", + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="invoice.pdf"', + }, + ) + ) + att = client.inbound.messages.attachment("rcv_1", 0) + assert att.content == b"PDFBYTES" + assert att.content_type == "application/pdf" + assert att.filename == "invoice.pdf" + + +@respx.mock +def test_create_inbound_address_with_domain_and_livemode(client): + route = respx.post(f"{BASE_URL}/v1/inbound/addresses").mock( + return_value=httpx.Response( + 201, + json={ + "id": "inb_3", + "address": "*@inbound.acme.com", + "description": None, + "forwardTo": None, + "active": True, + "livemode": False, + "createdAt": "2026-05-10T00:00:00Z", + }, + ) + ) + created = client.inbound.addresses.create( + local_part="*", + domain_id="11111111-1111-1111-1111-111111111111", + livemode=False, + ) + assert created.id == "inb_3" + assert request_body(route.calls.last.request) == { + "localPart": "*", + "domainId": "11111111-1111-1111-1111-111111111111", + "livemode": False, + } + + +@respx.mock +def test_list_inbound_domains(client): + respx.get(f"{BASE_URL}/v1/inbound/domains").mock( + return_value=httpx.Response( + 200, + json={ + "domains": [ + { + "id": "d1", + "domain": "acme.in.senderkit.email", + "kind": "shared", + "status": "verified", + "records": [], + "verifiedAt": "2026-05-10T00:00:00Z", + "createdAt": "2026-05-10T00:00:00Z", + }, + { + "id": "d2", + "domain": "inbound.acme.com", + "kind": "custom", + "status": "pending", + "records": [ + { + "type": "MX", + "name": "inbound.acme.com", + "value": "inbound-smtp.senderkit.email", + "priority": 10, + "purpose": "receiving", + } + ], + "verifiedAt": None, + "createdAt": "2026-05-10T00:00:00Z", + }, + ] + }, + ) + ) + domains = client.inbound.domains.list() + assert len(domains) == 2 + assert domains[1].domain == "inbound.acme.com" + assert domains[1].records[0].type == "MX" + assert domains[1].records[0].priority == 10 + + +@respx.mock +def test_create_inbound_domain(client): + route = respx.post(f"{BASE_URL}/v1/inbound/domains").mock( + return_value=httpx.Response( + 201, + json={ + "id": "d3", + "domain": "inbound.acme.com", + "kind": "custom", + "status": "pending", + "records": [], + "verifiedAt": None, + "createdAt": "2026-05-10T00:00:00Z", + }, + ) + ) + created = client.inbound.domains.create("inbound.acme.com", acknowledge_existing_mx=True) + assert created.id == "d3" + assert request_body(route.calls.last.request) == { + "domain": "inbound.acme.com", + "acknowledgeExistingMx": True, + } + + +def test_create_inbound_domain_requires_domain(client): + import pytest + + with pytest.raises(ValueError): + client.inbound.domains.create("") + + +@respx.mock +def test_delete_inbound_domain(client): + respx.delete(f"{BASE_URL}/v1/inbound/domains/d3").mock( + return_value=httpx.Response(200, json={"deleted": True}) + ) + deleted = client.inbound.domains.delete("d3") + assert deleted is True + + +@respx.mock +async def test_create_inbound_address_with_domain_and_livemode_async(aclient): + route = respx.post(f"{BASE_URL}/v1/inbound/addresses").mock( + return_value=httpx.Response( + 201, + json={ + "id": "inb_3", + "address": "*@inbound.acme.com", + "description": None, + "forwardTo": None, + "active": True, + "livemode": False, + "createdAt": "2026-05-10T00:00:00Z", + }, + ) + ) + created = await aclient.inbound.addresses.create( + local_part="*", + domain_id="11111111-1111-1111-1111-111111111111", + livemode=False, + ) + assert created.id == "inb_3" + assert request_body(route.calls.last.request) == { + "localPart": "*", + "domainId": "11111111-1111-1111-1111-111111111111", + "livemode": False, + } + + +@respx.mock +async def test_list_inbound_domains_async(aclient): + respx.get(f"{BASE_URL}/v1/inbound/domains").mock( + return_value=httpx.Response( + 200, + json={ + "domains": [ + { + "id": "d2", + "domain": "inbound.acme.com", + "kind": "custom", + "status": "pending", + "records": [ + { + "type": "MX", + "name": "inbound.acme.com", + "value": "inbound-smtp.senderkit.email", + "priority": 10, + "purpose": "receiving", + } + ], + "verifiedAt": None, + "createdAt": "2026-05-10T00:00:00Z", + } + ] + }, + ) + ) + domains = await aclient.inbound.domains.list() + assert len(domains) == 1 + assert domains[0].domain == "inbound.acme.com" + assert domains[0].records[0].priority == 10 + + +@respx.mock +async def test_create_inbound_domain_async(aclient): + route = respx.post(f"{BASE_URL}/v1/inbound/domains").mock( + return_value=httpx.Response( + 201, + json={ + "id": "d3", + "domain": "inbound.acme.com", + "kind": "custom", + "status": "pending", + "records": [], + "verifiedAt": None, + "createdAt": "2026-05-10T00:00:00Z", + }, + ) + ) + created = await aclient.inbound.domains.create("inbound.acme.com", acknowledge_existing_mx=True) + assert created.id == "d3" + assert request_body(route.calls.last.request) == { + "domain": "inbound.acme.com", + "acknowledgeExistingMx": True, + } + + +@respx.mock +async def test_delete_inbound_domain_async(aclient): + respx.delete(f"{BASE_URL}/v1/inbound/domains/d3").mock( + return_value=httpx.Response(200, json={"deleted": True}) + ) + deleted = await aclient.inbound.domains.delete("d3") + assert deleted is True + + +@respx.mock +async def test_async_inbound_addresses_full_surface(aclient): + # create with every field (covers the create-body branches) ... + create = respx.post(f"{BASE_URL}/v1/inbound/addresses").mock( + return_value=httpx.Response( + 201, + json={ + "id": "inb_9", + "address": "support@acme.in.senderkit.email", + "description": "Support", + "forwardTo": "team@acme.com", + "active": True, + "livemode": True, + "createdAt": "2026-05-10T00:00:00Z", + }, + ) + ) + created = await aclient.inbound.addresses.create( + local_part="support", + description="Support", + forward_to="team@acme.com", + webhook_endpoint_id="22222222-2222-2222-2222-222222222222", + ) + assert created.id == "inb_9" + assert request_body(create.calls.last.request) == { + "localPart": "support", + "description": "Support", + "forwardTo": "team@acme.com", + "webhookEndpointId": "22222222-2222-2222-2222-222222222222", + } + + # ... list ... + respx.get(f"{BASE_URL}/v1/inbound/addresses").mock( + return_value=httpx.Response(200, json={"addresses": [{"id": "inb_9", "address": "x@y.z"}]}) + ) + addrs = await aclient.inbound.addresses.list() + assert addrs[0].id == "inb_9" + + # ... delete. + respx.delete(f"{BASE_URL}/v1/inbound/addresses/inb_9").mock( + return_value=httpx.Response(200, json={"deleted": True}) + ) + deleted = await aclient.inbound.addresses.delete("inb_9") + assert deleted is True + + +@respx.mock +async def test_async_inbound_messages_full_surface(aclient): + # list with a datetime `before` cursor (exercises the ISO conversion) ... + lst = respx.get(f"{BASE_URL}/v1/inbound/messages").mock( + return_value=httpx.Response( + 200, + json={"messages": [{"id": "rcv_1", "status": "received", "sizeBytes": 42}]}, + ) + ) + from datetime import datetime, timezone + + msgs = await aclient.inbound.messages.list( + limit=10, before=datetime(2026, 5, 10, tzinfo=timezone.utc), address="inb_1" + ) + assert msgs[0].id == "rcv_1" + assert "before=2026-05-10" in str(lst.calls.last.request.url) + + # ... get ... + respx.get(f"{BASE_URL}/v1/inbound/messages/rcv_1").mock( + return_value=httpx.Response( + 200, json={"id": "rcv_1", "status": "received", "subject": "Hi"} + ) + ) + msg = await aclient.inbound.messages.get("rcv_1") + assert msg.subject == "Hi" + + # ... raw bytes ... + respx.get(f"{BASE_URL}/v1/inbound/messages/rcv_1/raw").mock( + return_value=httpx.Response( + 200, content=b"From: a@b\r\n\r\nBody", headers={"content-type": "message/rfc822"} + ) + ) + raw = await aclient.inbound.messages.raw("rcv_1") + assert raw.content_type == "message/rfc822" + + # ... attachment bytes (with a filename to parse). + respx.get(f"{BASE_URL}/v1/inbound/messages/rcv_1/attachments/0").mock( + return_value=httpx.Response( + 200, + content=b"PDF", + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="invoice.pdf"', + }, + ) + ) + att = await aclient.inbound.messages.attachment("rcv_1", 0) + assert att.filename == "invoice.pdf"