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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
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.
@@ -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,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"]