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 +21 -0
- postilon/_client.py +275 -0
- postilon/_errors.py +54 -0
- postilon/_signature.py +44 -0
- postilon/py.typed +0 -0
- postilon/types.py +401 -0
- postilon-1.0.0.dist-info/METADATA +147 -0
- postilon-1.0.0.dist-info/RECORD +10 -0
- postilon-1.0.0.dist-info/WHEEL +4 -0
- postilon-1.0.0.dist-info/licenses/LICENSE +21 -0
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,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.
|