postilon 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
postilon/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Python client for the Postilon transactional email API (https://postilon.com/docs)."""
2
+
3
+ from ._client import Domains, Emails, Files, Inbound, Postilon, Suppressions, Templates, Webhooks
4
+ from ._errors import PostilonError
5
+ from ._signature import verify_webhook_signature
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ __all__ = [
10
+ "Postilon",
11
+ "PostilonError",
12
+ "verify_webhook_signature",
13
+ "Emails",
14
+ "Domains",
15
+ "Templates",
16
+ "Files",
17
+ "Suppressions",
18
+ "Webhooks",
19
+ "Inbound",
20
+ "__version__",
21
+ ]
postilon/_client.py ADDED
@@ -0,0 +1,275 @@
1
+ import json
2
+ import urllib.error
3
+ import urllib.parse
4
+ import urllib.request
5
+ from typing import Any, Dict, List, Optional, Union, cast
6
+
7
+ from ._errors import error_from_response
8
+ from ._signature import verify_webhook_signature
9
+ from .types import (
10
+ CreateDomainParams,
11
+ CreateSuppressionParams,
12
+ CreateTemplateParams,
13
+ CreateWebhookParams,
14
+ Deleted,
15
+ Domain,
16
+ DomainVerification,
17
+ Email,
18
+ FileObject,
19
+ InboundEmail,
20
+ ListPage,
21
+ SendBatchParams,
22
+ SendBatchResponse,
23
+ SendEmailParams,
24
+ SendEmailResponse,
25
+ Suppression,
26
+ Template,
27
+ UpdateDomainParams,
28
+ UpdateTemplateParams,
29
+ UpdateWebhookParams,
30
+ UploadFileParams,
31
+ Webhook,
32
+ WebhookDelivery,
33
+ )
34
+
35
+ DEFAULT_BASE_URL = "https://api.postilon.com"
36
+ USER_AGENT = "postilon-python/1.0.0"
37
+
38
+ Query = Dict[str, Union[str, int, None]]
39
+
40
+
41
+ def _id(value: str) -> str:
42
+ return urllib.parse.quote(value, safe="")
43
+
44
+
45
+ class _Transport:
46
+ def __init__(self, api_key: str, base_url: str, timeout: float) -> None:
47
+ self._api_key = api_key
48
+ self._base_url = base_url.rstrip("/")
49
+ self._timeout = timeout
50
+
51
+ def request(
52
+ self,
53
+ method: str,
54
+ path: str,
55
+ *,
56
+ query: Optional[Query] = None,
57
+ body: Any = None,
58
+ idempotency_key: Optional[str] = None,
59
+ raw: bool = False,
60
+ ) -> Any:
61
+ url = self._base_url + path
62
+ params = {k: str(v) for k, v in (query or {}).items() if v is not None}
63
+ if params:
64
+ url += "?" + urllib.parse.urlencode(params)
65
+ headers = {
66
+ "Authorization": f"Bearer {self._api_key}",
67
+ "Accept": "application/json",
68
+ "User-Agent": USER_AGENT,
69
+ }
70
+ data = None
71
+ if body is not None:
72
+ headers["Content-Type"] = "application/json"
73
+ data = json.dumps(body).encode("utf-8")
74
+ if idempotency_key:
75
+ headers["Idempotency-Key"] = idempotency_key
76
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
77
+ try:
78
+ with urllib.request.urlopen(req, timeout=self._timeout) as res:
79
+ content = res.read()
80
+ except urllib.error.HTTPError as err:
81
+ raise error_from_response(err.code, err.headers, err.read()) from None
82
+ if raw:
83
+ return content
84
+ return json.loads(content.decode("utf-8")) if content else None
85
+
86
+
87
+ class Postilon:
88
+ """Client for the Postilon API. Every method returns the parsed JSON response as a ``dict``,
89
+ and raises ``PostilonError`` when the API answers with an error.
90
+
91
+ >>> postilon = Postilon(os.environ["POSTILON_API_KEY"])
92
+ >>> postilon.emails.send({"from": "hello@acme.com", "to": "jane@example.com", "subject": "Hi", "text": "Hello"})
93
+ """
94
+
95
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0) -> None:
96
+ """``api_key`` is a ``ps_live_…`` or ``ps_test_…`` key. ``timeout`` is in seconds."""
97
+ if not api_key:
98
+ raise ValueError("Postilon: an API key is required (ps_live_… or ps_test_…).")
99
+ transport = _Transport(api_key, base_url, timeout)
100
+ self.emails = Emails(transport)
101
+ self.domains = Domains(transport)
102
+ self.templates = Templates(transport)
103
+ self.files = Files(transport)
104
+ self.suppressions = Suppressions(transport)
105
+ self.webhooks = Webhooks(transport)
106
+ self.inbound = Inbound(transport)
107
+
108
+
109
+ class _Resource:
110
+ def __init__(self, transport: _Transport) -> None:
111
+ self._t = transport
112
+
113
+
114
+ class Emails(_Resource):
115
+ def send(self, params: SendEmailParams, *, idempotency_key: Optional[str] = None) -> SendEmailResponse:
116
+ """``POST /v1/emails``. ``idempotency_key`` is sent as the ``Idempotency-Key`` header: a retry
117
+ with the same key and body returns the original response instead of sending again."""
118
+ return cast(SendEmailResponse, self._t.request("POST", "/v1/emails", body=params, idempotency_key=idempotency_key))
119
+
120
+ def send_batch(self, params: SendBatchParams) -> SendBatchResponse:
121
+ """``POST /v1/emails/batch``: up to 100 emails, all or nothing."""
122
+ return cast(SendBatchResponse, self._t.request("POST", "/v1/emails/batch", body=params))
123
+
124
+ def get(self, email_id: str) -> Email:
125
+ """``GET /v1/emails/{id}``, with its events."""
126
+ return cast(Email, self._t.request("GET", f"/v1/emails/{_id(email_id)}"))
127
+
128
+ def list(
129
+ self,
130
+ *,
131
+ limit: Optional[int] = None,
132
+ offset: Optional[int] = None,
133
+ status: Optional[str] = None,
134
+ q: Optional[str] = None,
135
+ source: Optional[str] = None,
136
+ days: Optional[int] = None,
137
+ ) -> ListPage:
138
+ """``GET /v1/emails``, newest first. ``q`` searches the subject, ID, sender and recipients;
139
+ ``source`` is ``api`` or ``smtp``; ``days`` keeps emails from the last N days."""
140
+ query: Query = {"limit": limit, "offset": offset, "status": status, "q": q, "source": source, "days": days}
141
+ return cast(ListPage, self._t.request("GET", "/v1/emails", query=query))
142
+
143
+ def cancel(self, email_id: str) -> Email:
144
+ """A queued or scheduled email; fails with ``409 cannot_cancel`` once delivery started."""
145
+ return cast(Email, self._t.request("POST", f"/v1/emails/{_id(email_id)}/cancel"))
146
+
147
+
148
+ class Domains(_Resource):
149
+ def create(self, params: CreateDomainParams) -> Domain:
150
+ return cast(Domain, self._t.request("POST", "/v1/domains", body=params))
151
+
152
+ def list(self, *, limit: Optional[int] = None, offset: Optional[int] = None) -> ListPage:
153
+ return cast(ListPage, self._t.request("GET", "/v1/domains", query={"limit": limit, "offset": offset}))
154
+
155
+ def get(self, domain_id: str) -> Domain:
156
+ return cast(Domain, self._t.request("GET", f"/v1/domains/{_id(domain_id)}"))
157
+
158
+ def verify(self, domain_id: str) -> DomainVerification:
159
+ """Checks the domain's DNS records now."""
160
+ return cast(DomainVerification, self._t.request("POST", f"/v1/domains/{_id(domain_id)}/verify"))
161
+
162
+ def update(self, domain_id: str, params: UpdateDomainParams) -> Domain:
163
+ """Turns open or click tracking on or off, or inbound email (``inbound_domain``, ``""`` for off)."""
164
+ return cast(Domain, self._t.request("PATCH", f"/v1/domains/{_id(domain_id)}", body=params))
165
+
166
+ def delete(self, domain_id: str) -> Deleted:
167
+ return cast(Deleted, self._t.request("DELETE", f"/v1/domains/{_id(domain_id)}"))
168
+
169
+
170
+ class Templates(_Resource):
171
+ def create(self, params: CreateTemplateParams) -> Template:
172
+ return cast(Template, self._t.request("POST", "/v1/templates", body=params))
173
+
174
+ def list(self, *, limit: Optional[int] = None, offset: Optional[int] = None) -> ListPage:
175
+ return cast(ListPage, self._t.request("GET", "/v1/templates", query={"limit": limit, "offset": offset}))
176
+
177
+ def get(self, template_id: str) -> Template:
178
+ return cast(Template, self._t.request("GET", f"/v1/templates/{_id(template_id)}"))
179
+
180
+ def update(self, template_id: str, params: UpdateTemplateParams) -> Template:
181
+ return cast(Template, self._t.request("PATCH", f"/v1/templates/{_id(template_id)}", body=params))
182
+
183
+ def delete(self, template_id: str) -> Deleted:
184
+ return cast(Deleted, self._t.request("DELETE", f"/v1/templates/{_id(template_id)}"))
185
+
186
+
187
+ class Files(_Resource):
188
+ def upload(self, params: UploadFileParams) -> FileObject:
189
+ """Uploads an attachment to reuse in many emails as ``{"file_id": …}``."""
190
+ return cast(FileObject, self._t.request("POST", "/v1/files", body=params))
191
+
192
+ def list(self, *, limit: Optional[int] = None, offset: Optional[int] = None) -> ListPage:
193
+ return cast(ListPage, self._t.request("GET", "/v1/files", query={"limit": limit, "offset": offset}))
194
+
195
+ def delete(self, file_id: str) -> Deleted:
196
+ return cast(Deleted, self._t.request("DELETE", f"/v1/files/{_id(file_id)}"))
197
+
198
+
199
+ class Suppressions(_Resource):
200
+ def list(self, *, limit: Optional[int] = None, offset: Optional[int] = None) -> ListPage:
201
+ return cast(ListPage, self._t.request("GET", "/v1/suppressions", query={"limit": limit, "offset": offset}))
202
+
203
+ def create(self, params: CreateSuppressionParams) -> Suppression:
204
+ return cast(Suppression, self._t.request("POST", "/v1/suppressions", body=params))
205
+
206
+ def delete(self, suppression_id: str) -> Deleted:
207
+ """Removes a suppression (``sup_…``), so the address can be sent to again."""
208
+ return cast(Deleted, self._t.request("DELETE", f"/v1/suppressions/{_id(suppression_id)}"))
209
+
210
+
211
+ class Webhooks(_Resource):
212
+ def create(self, params: CreateWebhookParams) -> Webhook:
213
+ """The response's ``signing_secret`` is the key for ``verify_signature``."""
214
+ return cast(Webhook, self._t.request("POST", "/v1/webhooks", body=params))
215
+
216
+ def list(self, *, limit: Optional[int] = None, offset: Optional[int] = None) -> ListPage:
217
+ return cast(ListPage, self._t.request("GET", "/v1/webhooks", query={"limit": limit, "offset": offset}))
218
+
219
+ def get(self, webhook_id: str) -> Webhook:
220
+ return cast(Webhook, self._t.request("GET", f"/v1/webhooks/{_id(webhook_id)}"))
221
+
222
+ def update(self, webhook_id: str, params: UpdateWebhookParams) -> Webhook:
223
+ return cast(Webhook, self._t.request("PATCH", f"/v1/webhooks/{_id(webhook_id)}", body=params))
224
+
225
+ def delete(self, webhook_id: str) -> Deleted:
226
+ return cast(Deleted, self._t.request("DELETE", f"/v1/webhooks/{_id(webhook_id)}"))
227
+
228
+ def test(self, webhook_id: str) -> WebhookDelivery:
229
+ """Sends a ``webhook.test`` event to the endpoint."""
230
+ return cast(WebhookDelivery, self._t.request("POST", f"/v1/webhooks/{_id(webhook_id)}/test"))
231
+
232
+ def list_event_types(self) -> Dict[str, List[str]]:
233
+ """``GET /v1/webhooks/events``: the event types an endpoint can subscribe to."""
234
+ return cast(Dict[str, List[str]], self._t.request("GET", "/v1/webhooks/events"))
235
+
236
+ def list_deliveries(
237
+ self, *, webhook_id: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None
238
+ ) -> ListPage:
239
+ """Recent deliveries, optionally of one endpoint (``webhook_id``)."""
240
+ query: Query = {"webhook_id": webhook_id, "limit": limit, "offset": offset}
241
+ return cast(ListPage, self._t.request("GET", "/v1/webhooks/deliveries", query=query))
242
+
243
+ def replay_delivery(self, delivery_id: str) -> WebhookDelivery:
244
+ """Sends a delivery again, as a new delivery."""
245
+ return cast(WebhookDelivery, self._t.request("POST", f"/v1/webhooks/deliveries/{_id(delivery_id)}/replay"))
246
+
247
+ @staticmethod
248
+ def verify_signature(
249
+ raw_body: Union[str, bytes], header: Optional[str], secret: str, *, tolerance_seconds: int = 300
250
+ ) -> bool:
251
+ """Same as ``postilon.verify_webhook_signature``."""
252
+ return verify_webhook_signature(raw_body, header, secret, tolerance_seconds=tolerance_seconds)
253
+
254
+
255
+ class Inbound(_Resource):
256
+ """Email received on an inbound domain (https://postilon.com/docs/inbound)."""
257
+
258
+ def list(self, *, q: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None) -> ListPage:
259
+ """``GET /v1/inbound``, newest first; ``q`` searches the subject, sender and recipient."""
260
+ return cast(ListPage, self._t.request("GET", "/v1/inbound", query={"q": q, "limit": limit, "offset": offset}))
261
+
262
+ def get(self, inbound_id: str) -> InboundEmail:
263
+ """``GET /v1/inbound/{id}``, with its headers."""
264
+ return cast(InboundEmail, self._t.request("GET", f"/v1/inbound/{_id(inbound_id)}"))
265
+
266
+ def attachment(self, inbound_id: str, index: int) -> bytes:
267
+ """Attachment ``index`` (from 0, as in ``attachments``), as bytes."""
268
+ return cast(bytes, self._t.request("GET", f"/v1/inbound/{_id(inbound_id)}/attachments/{int(index)}", raw=True))
269
+
270
+ def raw(self, inbound_id: str) -> bytes:
271
+ """The message as received (RFC 5322), as bytes for ``email.message_from_bytes``."""
272
+ return cast(bytes, self._t.request("GET", f"/v1/inbound/{_id(inbound_id)}/raw", raw=True))
273
+
274
+
275
+ __all__ = ["Postilon", "Emails", "Domains", "Templates", "Files", "Suppressions", "Webhooks", "Inbound"]
postilon/_errors.py ADDED
@@ -0,0 +1,54 @@
1
+ import json
2
+ from typing import Any, Optional, Protocol
3
+
4
+
5
+ class _Headers(Protocol):
6
+ def get(self, name: str, /) -> Any: ...
7
+
8
+
9
+ class PostilonError(Exception):
10
+ """An error answered by the Postilon API: ``{"error": {"code", "message"}}`` with an HTTP status.
11
+
12
+ See https://postilon.com/docs/errors for the codes. Network failures are not wrapped: they
13
+ raise the ``urllib.error.URLError`` (or ``OSError``) that ``urllib`` raised.
14
+ """
15
+
16
+ status: int
17
+ """HTTP status, such as 403."""
18
+ code: str
19
+ """Error code, such as ``domain_not_verified``; ``http_error`` when the body was not a Postilon error."""
20
+ message: str
21
+ rate_limit_reset: Optional[int]
22
+ """For ``429 rate_limit_exceeded``: when the rate limit window resets, in Unix seconds
23
+ (the ``X-RateLimit-Reset`` header). Retry after this time."""
24
+
25
+ def __init__(self, status: int, code: str, message: str, rate_limit_reset: Optional[int] = None) -> None:
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.code = code
29
+ self.message = message
30
+ self.rate_limit_reset = rate_limit_reset
31
+
32
+ def __repr__(self) -> str:
33
+ return f"PostilonError(status={self.status}, code={self.code!r}, message={self.message!r})"
34
+
35
+
36
+ def error_from_response(status: int, headers: _Headers, body: bytes) -> PostilonError:
37
+ """Builds the error for a non-2xx response from its status, headers and body."""
38
+ code, message = "http_error", f"HTTP {status}"
39
+ try:
40
+ err = json.loads(body.decode("utf-8")).get("error")
41
+ if isinstance(err, dict) and isinstance(err.get("code"), str):
42
+ code = err["code"]
43
+ if isinstance(err.get("message"), str) and err["message"]:
44
+ message = err["message"]
45
+ except (ValueError, AttributeError):
46
+ pass # Not JSON (a proxy's error page, say): keep the generic code and message.
47
+ reset = None
48
+ if status == 429 and code == "rate_limit_exceeded":
49
+ try:
50
+ n = int(headers.get("X-RateLimit-Reset") or "")
51
+ reset = n if n > 0 else None
52
+ except ValueError:
53
+ pass
54
+ return PostilonError(status, code, message, reset)
postilon/_signature.py ADDED
@@ -0,0 +1,44 @@
1
+ import hashlib
2
+ import hmac
3
+ import re
4
+ import time
5
+ from typing import Optional, Union
6
+
7
+ _HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
8
+ _DIGITS = re.compile(r"^\d+$")
9
+
10
+
11
+ def verify_webhook_signature(
12
+ raw_body: Union[str, bytes],
13
+ header: Optional[str],
14
+ secret: str,
15
+ *,
16
+ tolerance_seconds: int = 300,
17
+ ) -> bool:
18
+ """Checks the ``X-Postilon-Signature`` header of a webhook request.
19
+
20
+ The header is ``t=<unix>,v1=<hex>``, where ``v1`` is the HMAC-SHA256 of ``"<t>.<raw body>"``
21
+ keyed with the endpoint's signing secret. Pass the body exactly as received, before any JSON
22
+ parsing. Returns False, never raises, for a missing or malformed header, a wrong signature or
23
+ a timestamp more than ``tolerance_seconds`` away from now.
24
+ """
25
+ if not isinstance(header, str) or not secret:
26
+ return False
27
+ timestamp = None
28
+ signatures = []
29
+ for part in header.split(","):
30
+ key, eq, value = part.partition("=")
31
+ if not eq:
32
+ continue
33
+ key, value = key.strip(), value.strip()
34
+ if key == "t":
35
+ timestamp = value
36
+ elif key == "v1" and _HEX64.match(value):
37
+ signatures.append(bytes.fromhex(value))
38
+ if not timestamp or not _DIGITS.match(timestamp) or not signatures:
39
+ return False
40
+ if abs(time.time() - int(timestamp)) > tolerance_seconds:
41
+ return False
42
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
43
+ expected = hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + body, hashlib.sha256).digest()
44
+ return any(hmac.compare_digest(sig, expected) for sig in signatures)
postilon/py.typed ADDED
File without changes
postilon/types.py ADDED
@@ -0,0 +1,401 @@
1
+ """Request and response shapes of the Postilon API (https://postilon.com/docs/api).
2
+
3
+ Every shape is a plain ``dict`` at runtime; these ``TypedDict`` definitions are for type
4
+ checkers and editors. Field names are the API's own, in snake_case, so a field named ``from``
5
+ uses the functional ``TypedDict`` syntax. Times are RFC 3339 strings in UTC.
6
+ """
7
+
8
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
9
+
10
+ # Lists
11
+
12
+
13
+ class ListPage(TypedDict):
14
+ """One page of a list. Ask for the next with ``offset + count`` while ``has_more`` is true."""
15
+
16
+ object: Literal["list"]
17
+ data: List[Dict[str, Any]]
18
+ count: int
19
+ total: int
20
+ has_more: bool
21
+
22
+
23
+ class Deleted(TypedDict):
24
+ deleted: bool
25
+
26
+
27
+ # Emails
28
+
29
+ EmailStatus = Literal[
30
+ "scheduled", "queued", "sent", "delivered", "opened", "clicked", "bounced", "complained", "suppressed", "failed", "canceled"
31
+ ]
32
+
33
+
34
+ class Tag(TypedDict):
35
+ name: str
36
+ value: str
37
+
38
+
39
+ class InlineAttachment(TypedDict):
40
+ """A file sent inline, its ``content`` encoded in Base64."""
41
+
42
+ filename: str
43
+ content_type: str
44
+ content: str
45
+
46
+
47
+ class _FileAttachmentBase(TypedDict):
48
+ file_id: str
49
+
50
+
51
+ class FileAttachment(_FileAttachmentBase, total=False):
52
+ """A file uploaded earlier with ``files.upload``."""
53
+
54
+ filename: str
55
+ content_type: str
56
+
57
+
58
+ Attachment = Union[InlineAttachment, FileAttachment]
59
+
60
+ _SendEmailRequired = TypedDict("_SendEmailRequired", {"from": str, "to": Union[str, List[str]]})
61
+
62
+
63
+ class SendEmailParams(_SendEmailRequired, total=False):
64
+ """``from`` is ``hello@acme.com`` or ``Acme <hello@acme.com>``; with a live key its domain must
65
+ be verified. ``subject`` is required unless ``template_id`` is set."""
66
+
67
+ cc: Union[str, List[str]]
68
+ bcc: Union[str, List[str]]
69
+ subject: str
70
+ html: str
71
+ text: str
72
+ reply_to: str
73
+ template_id: str
74
+ variables: Dict[str, str]
75
+ attachments: List[Attachment]
76
+ headers: Dict[str, str]
77
+ tags: List[Tag]
78
+ scheduled_at: str
79
+
80
+
81
+ SendEmailResponse = TypedDict(
82
+ "SendEmailResponse",
83
+ {
84
+ "id": str,
85
+ "status": Literal["queued", "scheduled"],
86
+ "from": str,
87
+ "to": List[str],
88
+ "environment": Literal["live", "test"],
89
+ "credits_used": int,
90
+ "scheduled_at": Optional[str],
91
+ "created_at": str,
92
+ },
93
+ )
94
+
95
+
96
+ class BatchRecipient(TypedDict, total=False):
97
+ to: str
98
+ variables: Dict[str, str]
99
+
100
+
101
+ _SendBatchRequired = TypedDict("_SendBatchRequired", {"from": str, "batch": List[BatchRecipient]})
102
+
103
+
104
+ class SendBatchParams(_SendBatchRequired, total=False):
105
+ """``batch`` holds 1 to 100 emails, each with its own recipient and variables."""
106
+
107
+ subject: str
108
+ html: str
109
+ text: str
110
+ template_id: str
111
+
112
+
113
+ class SendBatchResponse(TypedDict):
114
+ object: Literal["batch_result"]
115
+ count: int
116
+ data: List[Dict[str, str]]
117
+
118
+
119
+ class EmailEvent(TypedDict, total=False):
120
+ id: str
121
+ email_id: str
122
+ event_type: str
123
+ recipient: str
124
+ metadata: Dict[str, str]
125
+ occurred_at: str
126
+
127
+
128
+ Email = TypedDict(
129
+ "Email",
130
+ {
131
+ "id": str,
132
+ "workspace_id": str,
133
+ "from": str,
134
+ "to": List[str],
135
+ "cc": List[str],
136
+ "bcc": List[str],
137
+ "reply_to": str,
138
+ "subject": str,
139
+ "html": str,
140
+ "text": str,
141
+ "attachments": List[Attachment],
142
+ "variables": Dict[str, str],
143
+ "headers": Dict[str, str],
144
+ "template_id": str,
145
+ "tags": List[Tag],
146
+ "status": EmailStatus,
147
+ "environment": Literal["live", "test"],
148
+ "transport": Literal["rest_api", "rest_api_batch", "smtp_relay"],
149
+ "source": Literal["api", "smtp"],
150
+ "credits_used": int,
151
+ "dkim_signed": bool,
152
+ "events": List[EmailEvent],
153
+ "scheduled_at": str,
154
+ "sent_at": str,
155
+ "delivered_at": str,
156
+ "created_at": str,
157
+ "content_purged_at": str,
158
+ },
159
+ total=False,
160
+ )
161
+
162
+ # Domains
163
+
164
+
165
+ class DnsRecord(TypedDict, total=False):
166
+ type: str
167
+ name: str
168
+ value: str
169
+ priority: int
170
+ purpose: str
171
+ status: Literal["pending", "verified"]
172
+ required: bool
173
+ """False for a recommended record (DMARC), which is not needed to verify the domain."""
174
+
175
+
176
+ class Domain(TypedDict, total=False):
177
+ id: str
178
+ workspace_id: str
179
+ domain_name: str
180
+ status: Literal["pending", "verified", "failed"]
181
+ dkim_selector: str
182
+ dkim_public_key: str
183
+ dmarc_record: str
184
+ return_path_domain: str
185
+ dns_records: List[DnsRecord]
186
+ open_tracking_enabled: bool
187
+ click_tracking_enabled: bool
188
+ inbound_domain: str
189
+ """The domain (or its subdomain) that receives email; empty while inbound is off."""
190
+ inbound_record: DnsRecord
191
+ """The MX record the inbound domain needs; on a single domain while inbound is on."""
192
+ verified_at: str
193
+ created_at: str
194
+
195
+
196
+ class CreateDomainParams(TypedDict):
197
+ domain_name: str
198
+
199
+
200
+ class UpdateDomainParams(TypedDict, total=False):
201
+ open_tracking_enabled: bool
202
+ click_tracking_enabled: bool
203
+ inbound_domain: str
204
+ """Receive email on this domain or a subdomain of it; ``""`` turns inbound off."""
205
+
206
+
207
+ class DomainVerification(TypedDict):
208
+ domain: Domain
209
+ dns_verification: Dict[str, Any]
210
+
211
+
212
+ # Templates
213
+
214
+
215
+ class Template(TypedDict, total=False):
216
+ id: str
217
+ workspace_id: str
218
+ name: str
219
+ subject_template: str
220
+ html_template: str
221
+ text_template: str
222
+ variables: List[str]
223
+ updated_at: str
224
+ created_at: str
225
+
226
+
227
+ class _CreateTemplateRequired(TypedDict):
228
+ name: str
229
+
230
+
231
+ class CreateTemplateParams(_CreateTemplateRequired, total=False):
232
+ subject_template: str
233
+ html_template: str
234
+ text_template: str
235
+ variables: List[str]
236
+
237
+
238
+ class UpdateTemplateParams(TypedDict, total=False):
239
+ """Fields left out (or empty) keep their current value."""
240
+
241
+ name: str
242
+ subject_template: str
243
+ html_template: str
244
+ text_template: str
245
+ variables: List[str]
246
+
247
+
248
+ # Files
249
+
250
+
251
+ class _UploadFileRequired(TypedDict):
252
+ filename: str
253
+ content: str
254
+ """The file encoded in Base64, such as ``base64.b64encode(data).decode()``."""
255
+
256
+
257
+ class UploadFileParams(_UploadFileRequired, total=False):
258
+ content_type: str
259
+
260
+
261
+ class FileObject(TypedDict, total=False):
262
+ id: str
263
+ workspace_id: str
264
+ filename: str
265
+ content_type: str
266
+ size_bytes: int
267
+ expires_at: str
268
+ created_at: str
269
+
270
+
271
+ # Suppressions
272
+
273
+
274
+ class Suppression(TypedDict, total=False):
275
+ id: str
276
+ workspace_id: str
277
+ email_address: str
278
+ reason: Literal["hard_bounce", "complaint", "unsubscribe", "manual"]
279
+ source_email_id: str
280
+ created_at: str
281
+
282
+
283
+ class CreateSuppressionParams(TypedDict):
284
+ email_address: str
285
+
286
+
287
+ # Inbound
288
+
289
+ AuthResult = str
290
+ """An SPF, DKIM or DMARC result: ``pass``, ``fail``, ``softfail``, ``neutral``, ``none``,
291
+ ``temperror`` or ``permerror``."""
292
+
293
+
294
+ class InboundAttachment(TypedDict, total=False):
295
+ filename: str
296
+ content_type: str
297
+ size: int
298
+ """Bytes."""
299
+ content_id: str
300
+ """Set for an inline image the HTML shows as ``cid:<content_id>``."""
301
+
302
+
303
+ InboundEmail = TypedDict(
304
+ "InboundEmail",
305
+ {
306
+ "id": str,
307
+ "workspace_id": str,
308
+ "domain_id": str,
309
+ "envelope_from": str,
310
+ "recipient": str,
311
+ "remote_ip": str,
312
+ "from": str,
313
+ "to": List[str],
314
+ "cc": List[str],
315
+ "reply_to": str,
316
+ "subject": str,
317
+ "message_id": str,
318
+ "in_reply_to": str,
319
+ "references": str,
320
+ "text": str,
321
+ "html": str,
322
+ "headers": Dict[str, List[str]],
323
+ "attachments": List[InboundAttachment],
324
+ "size_bytes": int,
325
+ "spf": AuthResult,
326
+ "dkim": AuthResult,
327
+ "dmarc": AuthResult,
328
+ "spam": bool,
329
+ "credits_used": int,
330
+ "content_purged_at": str,
331
+ "created_at": str,
332
+ },
333
+ total=False,
334
+ )
335
+ """One inbound email per recipient; ``headers`` only from ``inbound.get``, and the content is
336
+ deleted 30 days after it arrived (``content_purged_at``)."""
337
+
338
+ # Webhooks
339
+
340
+ WebhookEventType = Literal[
341
+ "email.sent", "email.delivered", "email.bounced", "email.complained", "email.opened", "email.clicked", "email.failed", "email.received"
342
+ ]
343
+
344
+
345
+ class Webhook(TypedDict, total=False):
346
+ id: str
347
+ workspace_id: str
348
+ url: str
349
+ description: str
350
+ signing_secret: str
351
+ """``whsec_…``: the key for ``verify_signature``."""
352
+ subscribed_events: List[WebhookEventType]
353
+ is_active: bool
354
+ created_at: str
355
+
356
+
357
+ class _CreateWebhookRequired(TypedDict):
358
+ url: str
359
+ """HTTPS and reachable from the internet."""
360
+
361
+
362
+ class CreateWebhookParams(_CreateWebhookRequired, total=False):
363
+ description: str
364
+ subscribed_events: List[WebhookEventType]
365
+ """Leave out to receive every event."""
366
+
367
+
368
+ class UpdateWebhookParams(TypedDict, total=False):
369
+ url: str
370
+ description: str
371
+ subscribed_events: List[WebhookEventType]
372
+ is_active: bool
373
+
374
+
375
+ class WebhookDelivery(TypedDict, total=False):
376
+ id: str
377
+ webhook_id: str
378
+ workspace_id: str
379
+ url: str
380
+ event_type: str
381
+ payload: str
382
+ """The JSON body that was sent, as a string."""
383
+ signature: str
384
+ response_status: int
385
+ attempt_count: int
386
+ status: Literal["pending", "delivered", "failed"]
387
+ next_attempt_at: str
388
+ last_error: str
389
+ created_at: str
390
+
391
+
392
+ class WebhookEvent(TypedDict):
393
+ """The JSON body Postilon posts to a webhook endpoint. ``data`` depends on ``type``: an email
394
+ event carries ``email_id``, ``recipient`` and ``status``; ``email.received`` carries the
395
+ received email (``inbound_id``, ``text``, ``html``, ``attachments``, ``auth``, ``spam`` …)."""
396
+
397
+ id: str
398
+ type: str
399
+ created_at: str
400
+ workspace_id: str
401
+ data: Dict[str, Any]
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: postilon
3
+ Version: 1.0.0
4
+ Summary: Python SDK for the Postilon transactional email API
5
+ Project-URL: Homepage, https://postilon.com
6
+ Project-URL: Documentation, https://postilon.com/docs
7
+ Project-URL: Source, https://github.com/postilon/postilon-python
8
+ Project-URL: Issues, https://github.com/postilon/postilon-python/issues
9
+ Author-email: "Kulina Pte. Ltd." <hello@postilon.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: email,postilon,smtp,transactional-email,webhooks
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Communications :: Email
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+
23
+ # postilon
24
+
25
+ Python client for the [Postilon](https://postilon.com) transactional email API. Typed, no dependencies, Python 3.9 or later.
26
+
27
+ Full API documentation: [postilon.com/docs](https://postilon.com/docs).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install postilon
33
+ ```
34
+
35
+ ## Send an email
36
+
37
+ Create an API key in the dashboard under **API Keys**, and keep it in an environment variable.
38
+
39
+ ```python
40
+ import os
41
+ from postilon import Postilon
42
+
43
+ postilon = Postilon(os.environ["POSTILON_API_KEY"])
44
+
45
+ email = postilon.emails.send({
46
+ "from": "Acme <hello@acme.com>",
47
+ "to": "jane@example.com",
48
+ "subject": "Your sign-in code",
49
+ "html": "<p>Your code is <strong>482913</strong>.</p>",
50
+ "text": "Your code is 482913.",
51
+ })
52
+ # {"id": "msg_…", "status": "queued", …}
53
+ ```
54
+
55
+ Requests are plain dicts with the API's own field names (`from` is a Python keyword, so there are no keyword arguments for them), and every method returns the parsed JSON response as a dict. `send` takes every field of [`POST /v1/emails`](https://postilon.com/docs/sending): `cc`, `bcc`, `reply_to`, `headers`, `tags`, `attachments` (Base64 `content`, or a `file_id` from `postilon.files.upload`), `template_id` with `variables`, and `scheduled_at`. `postilon.types` has `TypedDict` definitions for type checkers.
56
+
57
+ The client also covers `emails.send_batch`, `get`, `list` and `cancel`, and `domains`, `templates`, `files`, `suppressions`, `webhooks` and `inbound`. `Postilon(key, base_url=…, timeout=30.0)` changes the API address or the timeout in seconds.
58
+
59
+ ## Test keys
60
+
61
+ `ps_test_…` keys are for development: emails are accepted, rendered and reported like real ones, but nothing is delivered and no credits are used, and any `from` address works. `ps_live_…` keys send real mail from a [verified domain](https://postilon.com/docs/domains). The SDK treats both the same.
62
+
63
+ ## Idempotency
64
+
65
+ Pass an idempotency key, such as the order or event ID the email is for, so a retried request never sends twice. It is sent as the `Idempotency-Key` header:
66
+
67
+ ```python
68
+ postilon.emails.send(
69
+ {"from": "billing@acme.com", "to": "jane@example.com", "subject": "Receipt for order #123", "text": "Thanks."},
70
+ idempotency_key="receipt-order-123",
71
+ )
72
+ ```
73
+
74
+ A retry with the same key and body returns the original response. The same key with a different body fails with `409 idempotency_key_reused`, so use one key per email.
75
+
76
+ ## Errors
77
+
78
+ When the API answers with an error, the call raises `PostilonError` with the HTTP `status`, the `code` and the `message` ([list of codes](https://postilon.com/docs/errors)). Network failures raise the `urllib.error.URLError` that `urllib` raised.
79
+
80
+ ```python
81
+ from postilon import PostilonError
82
+
83
+ try:
84
+ postilon.emails.send({"from": "hello@acme.com", "to": "jane@example.com", "subject": "Hi", "text": "Hi"})
85
+ except PostilonError as err:
86
+ if err.code == "rate_limit_exceeded":
87
+ ... # err.rate_limit_reset: when the window resets, in Unix seconds (X-RateLimit-Reset)
88
+ else:
89
+ print(err.status, err.code, err.message) # 403 domain_not_verified …
90
+ ```
91
+
92
+ A workspace can make 100 requests per second; use `emails.send_batch` for many emails at once. `429 daily_limit_reached` means the daily sending limit is reached, and retrying within the second will not help. An error response that is not JSON (from a proxy, say) gets the code `http_error`.
93
+
94
+ ## Pagination
95
+
96
+ Lists return `{"object": "list", "data", "count", "total", "has_more"}` and take `limit` (default 100, at most 1000) and `offset`, newest first:
97
+
98
+ ```python
99
+ offset = 0
100
+ while True:
101
+ page = postilon.emails.list(status="bounced", limit=100, offset=offset)
102
+ for email in page["data"]:
103
+ print(email["id"], email["to"])
104
+ if not page["has_more"]:
105
+ break
106
+ offset += page["count"]
107
+ ```
108
+
109
+ ## Verify webhooks
110
+
111
+ Each webhook request carries `X-Postilon-Signature: t=<unix>,v1=<hex>`. Verify it against the **raw** body, before any JSON parsing, with the endpoint's signing secret (`whsec_…`, returned when the endpoint is created). Signatures older than 5 minutes are refused; change that with `tolerance_seconds=`.
112
+
113
+ ```python
114
+ import json
115
+ import os
116
+ from flask import Flask, request
117
+ from postilon import verify_webhook_signature
118
+
119
+ app = Flask(__name__)
120
+
121
+ @app.post("/webhooks/postilon")
122
+ def postilon_webhook():
123
+ body = request.get_data() # the raw bytes
124
+ if not verify_webhook_signature(body, request.headers.get("X-Postilon-Signature"), os.environ["POSTILON_WEBHOOK_SECRET"]):
125
+ return "", 400
126
+ event = json.loads(body)
127
+ if event["type"] == "email.bounced":
128
+ ... # event["data"]["recipient"] bounced
129
+ return "", 200
130
+ ```
131
+
132
+ `postilon.webhooks.verify_signature(...)` is the same function. Answer with a `2xx` within 8 seconds and do slow work afterwards. Events can arrive more than once and out of order, so use the event `id` to ignore duplicates. See [Webhooks](https://postilon.com/docs/webhooks).
133
+
134
+ ## Receive email
135
+
136
+ Turn on inbound for a verified domain (or a subdomain of it), and add the MX record it returns:
137
+
138
+ ```python
139
+ domain = postilon.domains.update("dom_…", {"inbound_domain": "reply.acme.com"})
140
+ # domain["inbound_record"]: {"type": "MX", "name": "reply.acme.com", "value": "inbound.postilon.com", "priority": 10, …}
141
+ ```
142
+
143
+ Each message then reaches your webhooks as `email.received`, with its text and HTML (up to 256 KB), attachment list, SPF, DKIM and DMARC results and a `spam` flag. Read received email with `postilon.inbound.list`, `get`, `attachment(id, index)` and `raw(id)`; the last two return `bytes` (`email.message_from_bytes` parses the raw message). See [Inbound email](https://postilon.com/docs/inbound).
144
+
145
+ ## License
146
+
147
+ MIT, see [LICENSE](./LICENSE).
@@ -0,0 +1,10 @@
1
+ postilon/__init__.py,sha256=FW4m2LE27hfwwMg-qrca9Ysmpft5qWO1TcZ3mK3sfw4,509
2
+ postilon/_client.py,sha256=pu_WqPyDc3tN-QpNs4IZiKmrNC_7lOd7WW1qy56urbc,12226
3
+ postilon/_errors.py,sha256=46hS5hIDNKKTwsQFXJWkZViK_ma3EXveWYzxIajABi4,2170
4
+ postilon/_signature.py,sha256=Zlpj3UkJDsWwkRRG0oLu4RvCcKaQVCpjDLE3fJDiJwU,1639
5
+ postilon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ postilon/types.py,sha256=i2ZOo4CFLjELNcVCbNfUqpj1U5NRUIHlXEWwMwDRjSI,9626
7
+ postilon-1.0.0.dist-info/METADATA,sha256=cNcuihwUcxmFx_6W2YTxSTueZW0zQ_Hv98n_UppySiU,6579
8
+ postilon-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ postilon-1.0.0.dist-info/licenses/LICENSE,sha256=lYZN5_OMo6MvDAbxXk0W0RtH4U572Ytb01OA-X6090c,1073
10
+ postilon-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kulina Pte. Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.