postilon 1.0.0__tar.gz
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-1.0.0/.gitignore +6 -0
- postilon-1.0.0/LICENSE +21 -0
- postilon-1.0.0/PKG-INFO +147 -0
- postilon-1.0.0/README.md +125 -0
- postilon-1.0.0/pyproject.toml +36 -0
- postilon-1.0.0/src/postilon/__init__.py +21 -0
- postilon-1.0.0/src/postilon/_client.py +275 -0
- postilon-1.0.0/src/postilon/_errors.py +54 -0
- postilon-1.0.0/src/postilon/_signature.py +44 -0
- postilon-1.0.0/src/postilon/py.typed +0 -0
- postilon-1.0.0/src/postilon/types.py +401 -0
- postilon-1.0.0/tests/test_client.py +222 -0
- postilon-1.0.0/tests/test_signature.py +86 -0
postilon-1.0.0/LICENSE
ADDED
|
@@ -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.
|
postilon-1.0.0/PKG-INFO
ADDED
|
@@ -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).
|
postilon-1.0.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# postilon
|
|
2
|
+
|
|
3
|
+
Python client for the [Postilon](https://postilon.com) transactional email API. Typed, no dependencies, Python 3.9 or later.
|
|
4
|
+
|
|
5
|
+
Full API documentation: [postilon.com/docs](https://postilon.com/docs).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install postilon
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Send an email
|
|
14
|
+
|
|
15
|
+
Create an API key in the dashboard under **API Keys**, and keep it in an environment variable.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from postilon import Postilon
|
|
20
|
+
|
|
21
|
+
postilon = Postilon(os.environ["POSTILON_API_KEY"])
|
|
22
|
+
|
|
23
|
+
email = postilon.emails.send({
|
|
24
|
+
"from": "Acme <hello@acme.com>",
|
|
25
|
+
"to": "jane@example.com",
|
|
26
|
+
"subject": "Your sign-in code",
|
|
27
|
+
"html": "<p>Your code is <strong>482913</strong>.</p>",
|
|
28
|
+
"text": "Your code is 482913.",
|
|
29
|
+
})
|
|
30
|
+
# {"id": "msg_…", "status": "queued", …}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
## Test keys
|
|
38
|
+
|
|
39
|
+
`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.
|
|
40
|
+
|
|
41
|
+
## Idempotency
|
|
42
|
+
|
|
43
|
+
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:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
postilon.emails.send(
|
|
47
|
+
{"from": "billing@acme.com", "to": "jane@example.com", "subject": "Receipt for order #123", "text": "Thanks."},
|
|
48
|
+
idempotency_key="receipt-order-123",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from postilon import PostilonError
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
postilon.emails.send({"from": "hello@acme.com", "to": "jane@example.com", "subject": "Hi", "text": "Hi"})
|
|
63
|
+
except PostilonError as err:
|
|
64
|
+
if err.code == "rate_limit_exceeded":
|
|
65
|
+
... # err.rate_limit_reset: when the window resets, in Unix seconds (X-RateLimit-Reset)
|
|
66
|
+
else:
|
|
67
|
+
print(err.status, err.code, err.message) # 403 domain_not_verified …
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
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`.
|
|
71
|
+
|
|
72
|
+
## Pagination
|
|
73
|
+
|
|
74
|
+
Lists return `{"object": "list", "data", "count", "total", "has_more"}` and take `limit` (default 100, at most 1000) and `offset`, newest first:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
offset = 0
|
|
78
|
+
while True:
|
|
79
|
+
page = postilon.emails.list(status="bounced", limit=100, offset=offset)
|
|
80
|
+
for email in page["data"]:
|
|
81
|
+
print(email["id"], email["to"])
|
|
82
|
+
if not page["has_more"]:
|
|
83
|
+
break
|
|
84
|
+
offset += page["count"]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Verify webhooks
|
|
88
|
+
|
|
89
|
+
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=`.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import json
|
|
93
|
+
import os
|
|
94
|
+
from flask import Flask, request
|
|
95
|
+
from postilon import verify_webhook_signature
|
|
96
|
+
|
|
97
|
+
app = Flask(__name__)
|
|
98
|
+
|
|
99
|
+
@app.post("/webhooks/postilon")
|
|
100
|
+
def postilon_webhook():
|
|
101
|
+
body = request.get_data() # the raw bytes
|
|
102
|
+
if not verify_webhook_signature(body, request.headers.get("X-Postilon-Signature"), os.environ["POSTILON_WEBHOOK_SECRET"]):
|
|
103
|
+
return "", 400
|
|
104
|
+
event = json.loads(body)
|
|
105
|
+
if event["type"] == "email.bounced":
|
|
106
|
+
... # event["data"]["recipient"] bounced
|
|
107
|
+
return "", 200
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`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).
|
|
111
|
+
|
|
112
|
+
## Receive email
|
|
113
|
+
|
|
114
|
+
Turn on inbound for a verified domain (or a subdomain of it), and add the MX record it returns:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
domain = postilon.domains.update("dom_…", {"inbound_domain": "reply.acme.com"})
|
|
118
|
+
# domain["inbound_record"]: {"type": "MX", "name": "reply.acme.com", "value": "inbound.postilon.com", "priority": 10, …}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
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).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT, see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "postilon"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python SDK for the Postilon transactional email API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Kulina Pte. Ltd.", email = "hello@postilon.com" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["postilon", "email", "transactional-email", "smtp", "webhooks"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: Communications :: Email",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://postilon.com"
|
|
28
|
+
Documentation = "https://postilon.com/docs"
|
|
29
|
+
Source = "https://github.com/postilon/postilon-python"
|
|
30
|
+
Issues = "https://github.com/postilon/postilon-python/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/postilon"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["src/postilon", "tests", "README.md", "LICENSE"]
|
|
@@ -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
|
+
]
|
|
@@ -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"]
|