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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
14 changes: 14 additions & 0 deletions src/senderkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
Channel,
Context,
EmailContent,
InboundAddress,
InboundAttachment,
InboundBytes,
InboundDnsRecord,
InboundDomain,
InboundMessage,
InboundMessageSummary,
Message,
MessageList,
PushContent,
Expand Down Expand Up @@ -82,6 +89,13 @@
"BatchResult",
"Message",
"MessageList",
"InboundAddress",
"InboundMessage",
"InboundMessageSummary",
"InboundAttachment",
"InboundBytes",
"InboundDomain",
"InboundDnsRecord",
"TemplateSummary",
"TemplateDetail",
"TemplateVersion",
Expand Down
11 changes: 10 additions & 1 deletion src/senderkit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
169 changes: 169 additions & 0 deletions src/senderkit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/senderkit/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading