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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/senderkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
InboundAddress,
InboundAttachment,
InboundBytes,
InboundDnsRecord,
InboundDomain,
InboundMessage,
InboundMessageSummary,
Message,
Expand Down Expand Up @@ -92,6 +94,8 @@
"InboundMessageSummary",
"InboundAttachment",
"InboundBytes",
"InboundDomain",
"InboundDnsRecord",
"TemplateSummary",
"TemplateDetail",
"TemplateVersion",
Expand Down
47 changes: 47 additions & 0 deletions src/senderkit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 87 additions & 5 deletions src/senderkit/resources/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..models import (
InboundAddress,
InboundBytes,
InboundDomain,
InboundMessage,
InboundMessageSummary,
)
Expand All @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
)
Expand Down Expand Up @@ -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)
Loading