From 032e1897c1963957ee8d76b21820468c52ae3469 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 21:16:39 +0000 Subject: [PATCH 1/4] feat(inbound): custom receiving domains + catch-all addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends inbound support with the phase-2 capabilities now on the API: claim a custom domain to receive on (alongside the shared receiving domain), and provision catch-all addresses. Covers both the sync and async clients. - client.inbound.domains — list(), create(domain, ...), delete(id) on both Inbound and AsyncInbound. create() returns the DNS records to publish; an existing-MX conflict surfaces as a 409 SenderKitAPIError so callers can confirm before redirecting mail. - inbound.addresses.create() gains domain_id and livemode; local_part "*" provisions a catch-all. - New models: InboundDomain, InboundDnsRecord. Tests: ruff, mypy, and pytest all pass. --- src/senderkit/__init__.py | 4 ++ src/senderkit/models.py | 47 ++++++++++++ src/senderkit/resources/inbound.py | 92 ++++++++++++++++++++++-- tests/test_inbound.py | 112 +++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 5 deletions(-) diff --git a/src/senderkit/__init__.py b/src/senderkit/__init__.py index a3e975f..73c3273 100644 --- a/src/senderkit/__init__.py +++ b/src/senderkit/__init__.py @@ -35,6 +35,8 @@ InboundAddress, InboundAttachment, InboundBytes, + InboundDnsRecord, + InboundDomain, InboundMessage, InboundMessageSummary, Message, @@ -92,6 +94,8 @@ "InboundMessageSummary", "InboundAttachment", "InboundBytes", + "InboundDomain", + "InboundDnsRecord", "TemplateSummary", "TemplateDetail", "TemplateVersion", diff --git a/src/senderkit/models.py b/src/senderkit/models.py index d71e82b..1d63a10 100644 --- a/src/senderkit/models.py +++ b/src/senderkit/models.py @@ -443,6 +443,53 @@ class InboundBytes: 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/inbound.py b/src/senderkit/resources/inbound.py index 65d9237..3437bef 100644 --- a/src/senderkit/resources/inbound.py +++ b/src/senderkit/resources/inbound.py @@ -15,6 +15,7 @@ from ..models import ( InboundAddress, InboundBytes, + InboundDomain, InboundMessage, InboundMessageSummary, ) @@ -33,6 +34,8 @@ def _create_body( 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: @@ -43,6 +46,19 @@ def _create_body( 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 @@ -90,9 +106,15 @@ def create( 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.""" - body = _create_body(local_part, description, forward_to, webhook_endpoint_id) + """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) ) @@ -142,12 +164,43 @@ def attachment(self, id: str, index: int) -> InboundBytes: 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`` and ``messages``.""" + """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: @@ -168,8 +221,12 @@ async def create( 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) + 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) ) @@ -218,9 +275,34 @@ async def attachment(self, id: str, index: int) -> InboundBytes: 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`` and ``messages``.""" + """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 index 166b066..4004899 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -154,3 +154,115 @@ def test_get_inbound_attachment_parses_filename(client): 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}) + ) + assert client.inbound.domains.delete("d3") is True From cb85677dfafedf7ec24990648e4f848055cbb0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 21:21:11 +0000 Subject: [PATCH 2/4] docs(inbound): README example for custom domains + catch-all addresses --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 070b4eb..7aebcf2 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,21 @@ 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. From 70c6c54571e6f8c2555efb2dc05b7a1e40fedf71 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:43:42 +0000 Subject: [PATCH 3/4] ci(ruff): pin ruff below 0.16 to match the pinned pre-commit hook CI installs the dev extra with a floating `ruff>=0.5`, so it picks up ruff 0.16, which formats Python code blocks inside Markdown by default. That makes `ruff format --check .` reformat README.md and fail, even though the pinned ruff-pre-commit hook (0.15.17) and local runs are clean. Pin `ruff==0.15.17` in the dev extra so CI matches the pre-commit hook and the lint gate is deterministic. No source changes. --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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", From 5068bc4498215cfae2c71bb7007e92ee01e3aefe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:51:32 +0000 Subject: [PATCH 4/4] test(inbound): cover the async inbound surface (domains, addresses, messages) Adds async-client tests for the new inbound domains methods and the async address-create params, plus the pre-existing async address/message paths that had no coverage. Raises patch coverage above the project target. --- tests/test_inbound.py | 190 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/tests/test_inbound.py b/tests/test_inbound.py index 4004899..6a36102 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -266,3 +266,193 @@ def test_delete_inbound_domain(client): return_value=httpx.Response(200, json={"deleted": True}) ) assert client.inbound.domains.delete("d3") 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}) + ) + assert await aclient.inbound.domains.delete("d3") 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}) + ) + assert await aclient.inbound.addresses.delete("inb_9") 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"