Skip to content

Commit a2be26d

Browse files
authored
Merge pull request #12 from senderkit/claude/gallant-einstein-czq95m
feat: inbound receiving addresses and received mail
2 parents 6e38002 + 99ae882 commit a2be26d

8 files changed

Lines changed: 1023 additions & 4 deletions

File tree

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,51 @@ rendered = sk.templates.render("welcome", {"name": "Ada"})
253253
print(rendered.output, rendered.missing)
254254
```
255255

256+
## Inbound
257+
258+
Provision addresses on your workspace's shared receiving domain and read the mail
259+
sent to them. Requires an API key with the `inbound` scope.
260+
261+
```python
262+
# Provision an address (omit local_part for an auto-generated one).
263+
addr = sk.inbound.addresses.create(local_part="support", forward_to="team@acme.com")
264+
print(addr.address) # "support@acme.in.senderkit.email"
265+
266+
for a in sk.inbound.addresses.list():
267+
print(a.id, a.address)
268+
269+
# Received mail, newest first (filter by address, page with before=).
270+
for m in sk.inbound.messages.list(address=addr.id, limit=50):
271+
print(m.id, m.from_, m.subject)
272+
273+
msg = sk.inbound.messages.get("rcv_123")
274+
print(msg.text, [a.filename for a in msg.attachments])
275+
276+
# Raw MIME source and attachment bytes.
277+
raw = sk.inbound.messages.raw("rcv_123") # raw.content is bytes
278+
pdf = sk.inbound.messages.attachment("rcv_123", 0) # pdf.filename / pdf.content
279+
280+
sk.inbound.addresses.delete(addr.id)
281+
```
282+
283+
Receive on your own domain instead of the shared one, and use a catch-all address:
284+
285+
```python
286+
# Claim a custom domain — publish the returned DNS records to verify it.
287+
domain = sk.inbound.domains.create("inbound.acme.com")
288+
for r in domain.records:
289+
print(r.type, r.name, r.value)
290+
291+
# A catch-all on that domain (receives every local part no exact address claims).
292+
sk.inbound.addresses.create(local_part="*", domain_id=domain.id)
293+
294+
for d in sk.inbound.domains.list():
295+
print(d.domain, d.status)
296+
```
297+
298+
Delivery of received mail is surfaced through the standard webhook engine as a
299+
`message.received` event.
300+
256301
## Webhooks
257302

258303
SenderKit signs each webhook with an HMAC over the raw request body. Verify it against the

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ dev = [
4040
"pytest-cov>=5",
4141
"respx>=0.21",
4242
"mypy>=1.10",
43-
"ruff>=0.5",
43+
# Kept in lockstep with the pinned ruff-pre-commit hook (.pre-commit-config.yaml).
44+
# Pinned below 0.16: ruff 0.16 formats Python code blocks inside Markdown by
45+
# default, which would make `ruff format --check .` reformat README.md.
46+
"ruff==0.15.17",
4447
"pre-commit>=3.7",
4548
"build>=1.2",
4649
"twine>=5",

src/senderkit/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@
3232
Channel,
3333
Context,
3434
EmailContent,
35+
InboundAddress,
36+
InboundAttachment,
37+
InboundBytes,
38+
InboundDnsRecord,
39+
InboundDomain,
40+
InboundMessage,
41+
InboundMessageSummary,
3542
Message,
3643
MessageList,
3744
PushContent,
@@ -82,6 +89,13 @@
8289
"BatchResult",
8390
"Message",
8491
"MessageList",
92+
"InboundAddress",
93+
"InboundMessage",
94+
"InboundMessageSummary",
95+
"InboundAttachment",
96+
"InboundBytes",
97+
"InboundDomain",
98+
"InboundDnsRecord",
8599
"TemplateSummary",
86100
"TemplateDetail",
87101
"TemplateVersion",

src/senderkit/client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@
2727
SendResult,
2828
TemplateSend,
2929
)
30-
from .resources import AsyncMessages, AsyncTemplates, Messages, Templates
30+
from .resources import (
31+
AsyncInbound,
32+
AsyncMessages,
33+
AsyncTemplates,
34+
Inbound,
35+
Messages,
36+
Templates,
37+
)
3138

3239
DEFAULT_BASE_URL = "https://api.senderkit.com"
3340
DEFAULT_TIMEOUT = 30.0
@@ -70,6 +77,7 @@ def __init__(
7077
self._transport = Transport(api_key, base_url, timeout, max_retries, http_client)
7178
self.messages = Messages(self._transport)
7279
self.templates = Templates(self._transport)
80+
self.inbound = Inbound(self._transport)
7381

7482
def send(
7583
self,
@@ -206,6 +214,7 @@ def __init__(
206214
self._transport = AsyncTransport(api_key, base_url, timeout, max_retries, http_client)
207215
self.messages = AsyncMessages(self._transport)
208216
self.templates = AsyncTemplates(self._transport)
217+
self.inbound = AsyncInbound(self._transport)
209218

210219
async def send(
211220
self,

src/senderkit/models.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,175 @@ def from_dict(cls, d: Dict[str, Any]) -> RenderResult:
321321
)
322322

323323

324+
# --------------------------------------------------------------------------- #
325+
# Inbound — receiving addresses and received mail (``inbound`` scope)
326+
# --------------------------------------------------------------------------- #
327+
@dataclass
328+
class InboundAddress:
329+
"""An address provisioned on the workspace's shared receiving domain."""
330+
331+
id: str
332+
address: str
333+
description: Optional[str]
334+
forward_to: Optional[str]
335+
active: bool
336+
livemode: bool
337+
created_at: str
338+
339+
@classmethod
340+
def from_dict(cls, d: Dict[str, Any]) -> InboundAddress:
341+
return cls(
342+
id=str(d.get("id", "")),
343+
address=str(d.get("address", "")),
344+
description=d.get("description"),
345+
forward_to=d.get("forwardTo"),
346+
active=bool(d.get("active", False)),
347+
livemode=bool(d.get("livemode", False)),
348+
created_at=str(d.get("createdAt", "")),
349+
)
350+
351+
352+
@dataclass
353+
class InboundMessageSummary:
354+
"""A received-message summary, as returned by ``inbound.messages.list``."""
355+
356+
id: str
357+
status: str
358+
from_: Optional[str]
359+
subject: Optional[str]
360+
plus_tag: Optional[str]
361+
size_bytes: int
362+
received_at: str
363+
364+
@classmethod
365+
def from_dict(cls, d: Dict[str, Any]) -> InboundMessageSummary:
366+
return cls(
367+
id=str(d.get("id", "")),
368+
status=str(d.get("status", "")),
369+
from_=d.get("from"),
370+
subject=d.get("subject"),
371+
plus_tag=d.get("plusTag"),
372+
size_bytes=int(d.get("sizeBytes", 0)),
373+
received_at=str(d.get("receivedAt", "")),
374+
)
375+
376+
377+
@dataclass
378+
class InboundAttachment:
379+
"""One attachment on a received message. Fetch bytes via ``.attachment(id, index)``."""
380+
381+
index: int
382+
filename: Optional[str]
383+
content_type: str
384+
size: int
385+
#: Authenticated API URL (requires an ``inbound``-scoped key), not a signed link.
386+
url: str
387+
388+
@classmethod
389+
def from_dict(cls, d: Dict[str, Any]) -> InboundAttachment:
390+
return cls(
391+
index=int(d.get("index", 0)),
392+
filename=d.get("filename"),
393+
content_type=str(d.get("contentType", "")),
394+
size=int(d.get("size", 0)),
395+
url=str(d.get("url", "")),
396+
)
397+
398+
399+
@dataclass
400+
class InboundMessage:
401+
"""A received message. Common fields are typed; ``.raw`` holds the full body."""
402+
403+
id: str
404+
status: str
405+
channel: str
406+
address: Optional[str]
407+
subject: Optional[str]
408+
text: Optional[str]
409+
html: Optional[str]
410+
stripped_reply: Optional[str]
411+
size_bytes: int
412+
received_at: str
413+
raw_url: str
414+
attachments: List[InboundAttachment] = field(default_factory=list)
415+
raw: Dict[str, Any] = field(default_factory=dict)
416+
417+
@classmethod
418+
def from_dict(cls, d: Dict[str, Any]) -> InboundMessage:
419+
atts = d.get("attachments") or []
420+
return cls(
421+
id=str(d.get("id", "")),
422+
status=str(d.get("status", "")),
423+
channel=str(d.get("channel", "")),
424+
address=d.get("address"),
425+
subject=d.get("subject"),
426+
text=d.get("text"),
427+
html=d.get("html"),
428+
stripped_reply=d.get("strippedReply"),
429+
size_bytes=int(d.get("sizeBytes", 0)),
430+
received_at=str(d.get("receivedAt", "")),
431+
raw_url=str(d.get("rawUrl", "")),
432+
attachments=[InboundAttachment.from_dict(a) for a in atts if isinstance(a, dict)],
433+
raw=d,
434+
)
435+
436+
437+
@dataclass
438+
class InboundBytes:
439+
"""Raw bytes fetched from an inbound message (raw MIME source or attachment)."""
440+
441+
content: bytes
442+
content_type: str
443+
filename: Optional[str] = None
444+
445+
446+
@dataclass
447+
class InboundDnsRecord:
448+
"""A DNS record a custom inbound domain must publish before it can receive."""
449+
450+
type: str
451+
name: str
452+
value: str
453+
purpose: str
454+
priority: Optional[int] = None
455+
456+
@classmethod
457+
def from_dict(cls, d: Dict[str, Any]) -> InboundDnsRecord:
458+
return cls(
459+
type=str(d.get("type", "")),
460+
name=str(d.get("name", "")),
461+
value=str(d.get("value", "")),
462+
purpose=str(d.get("purpose", "")),
463+
priority=d.get("priority"),
464+
)
465+
466+
467+
@dataclass
468+
class InboundDomain:
469+
"""A custom inbound domain the workspace receives mail on (or the shared one)."""
470+
471+
id: str
472+
domain: str
473+
kind: str
474+
status: str
475+
verified_at: Optional[str]
476+
created_at: str
477+
records: List[InboundDnsRecord] = field(default_factory=list)
478+
479+
@classmethod
480+
def from_dict(cls, d: Dict[str, Any]) -> InboundDomain:
481+
recs = d.get("records") or []
482+
return cls(
483+
id=str(d.get("id", "")),
484+
domain=str(d.get("domain", "")),
485+
kind=str(d.get("kind", "")),
486+
status=str(d.get("status", "")),
487+
verified_at=d.get("verifiedAt"),
488+
created_at=str(d.get("createdAt", "")),
489+
records=[InboundDnsRecord.from_dict(r) for r in recs if isinstance(r, dict)],
490+
)
491+
492+
324493
@dataclass
325494
class Workspace:
326495
id: str
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
"""Resource namespaces exposed on the client (``client.messages``, ``client.templates``)."""
1+
"""Resource namespaces exposed on the client (``client.messages``, ``client.templates``,
2+
``client.inbound``)."""
23

4+
from .inbound import AsyncInbound, Inbound
35
from .messages import AsyncMessages, Messages
46
from .templates import AsyncTemplates, Templates
57

6-
__all__ = ["Messages", "AsyncMessages", "Templates", "AsyncTemplates"]
8+
__all__ = [
9+
"Messages",
10+
"AsyncMessages",
11+
"Templates",
12+
"AsyncTemplates",
13+
"Inbound",
14+
"AsyncInbound",
15+
]

0 commit comments

Comments
 (0)