capimail 0.1.1__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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `capimail` Python package. It follows [semantic versioning](https://semver.org/); while it is `0.x`, minor versions may add features and patch versions only fix bugs.
4
+
5
+ ## 0.1.1 - 2026-11-08
6
+
7
+ ### Security
8
+
9
+ - **Redirects are never followed.** The default `urllib` transport previously followed 3xx responses and re-sent the `Authorization` header (your API key) to whatever host the `Location` named, then accepted that host's JSON as the result. It now refuses every redirect, and the client raises `CapiMailError` with `code="unexpected_redirect"`, `status` set to the 3xx and a message naming the `Location` host. No request is sent onward. A custom `transport` that returns a 3xx gets the same error.
10
+ - **Webhook verifier no longer raises on malformed headers.** A `v1` value that is not exactly 64 lowercase hex characters (for example one containing non-ASCII characters, which made `hmac.compare_digest` raise `TypeError`) is ignored, and `t` must be ASCII digits only (`^[0-9]+$`, matching the PHP SDK). Such headers now return `False`.
11
+ - **Webhook secrets** may be `str` or `bytes` (or a list mixing both); previously `bytes` crashed with `AttributeError`. Any other type raises a clear `TypeError`.
12
+ - **Webhook tolerance**: a non-finite (`nan`, `inf`) or non-positive `tolerance` falls back to the 300 second default instead of disabling or breaking the replay window.
13
+ - **Path ids**: `emails.get`, `validation.jobs.get/results/iter_results`, `otp.verify` and `domains.monitors.delete` raise `ValueError` for an empty id, `.`, `..` or any id containing `/`, before any request is built.
14
+ - **Timeout**: `CapiMail(timeout=...)` raises `ValueError` unless it is a positive, finite number of seconds (also re-checked per request and in `urllib_transport`), so a request can never wait forever.
15
+
16
+ ## 0.1.0 - 2026-11-08
17
+
18
+ First release. Covers every public CapiMail API endpoint (13 of 13), matching `@capimail/sdk` 0.2.0.
19
+
20
+ ### Added
21
+
22
+ - `CapiMail(api_key, base_url="https://capimail.com", timeout=30, transport=None)` with zero runtime dependencies (`urllib.request`, `json`, `hmac`), full type hints and `py.typed`.
23
+ - **Emails**: `emails.send(from_=, to=, idempotency_key=, ...)` (inline or `template={"key", "locale", "variables"}`, attachments, test mode) and `emails.get(message_id)`.
24
+ - **Account**: `account.credits()`.
25
+ - **Suppressions**: `suppressions.list()`, `get(email)`, `add(email, reason=None)`, `remove(email)` (returns `False` on 404).
26
+ - **Email Validation add-on**: `validation.check(emails, idempotency_key=None)`, `validation.jobs.create/get/list/results`, and the `validation.jobs.iter_results(job_id, verdict=None)` generator over every page.
27
+ - **OTP add-on**: `otp.send(to, ...)` and `otp.verify(otp_id, code)`. Test keys return `test_code`.
28
+ - **Domain Health add-on**: `domains.check(domain, dkim_selectors=None, idempotency_key=None)` and `domains.monitors.create/list/delete` (`delete` returns `False` on 404).
29
+ - **Webhooks**: `capimail.webhooks.verify_signature(payload, signature, secret, tolerance=300, now=None)` and `construct_event(...)`, accepting several secrets during rotation.
30
+ - `CapiMailError` (`status`, `code`, `message`, `details`, `request_id`, `retry_after_seconds`, `is_retryable`) and `CapiMailConnectionError`.
31
+ - `Idempotency-Key` header on validation and domain-check requests; `User-Agent: capimail-python/<version>` on every request.
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.4
2
+ Name: capimail
3
+ Version: 0.1.1
4
+ Summary: Official Python client for the CapiMail API: transactional email, suppressions, credits, Email Validation, OTP, Domain Health and webhook verification.
5
+ Project-URL: Homepage, https://capimail.com
6
+ Project-URL: Documentation, https://capimail.com/docs
7
+ Project-URL: Repository, https://github.com/lelianto/capimail
8
+ Project-URL: Changelog, https://github.com/lelianto/capimail/blob/main/sdk-python/CHANGELOG.md
9
+ Author: CapiMail
10
+ License-Expression: MIT
11
+ Keywords: capimail,email,email-validation,indonesia,otp,transactional-email,webhooks
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Email
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+
28
+ # capimail
29
+
30
+ Official Python client for the **CapiMail** API: transactional email, suppressions, credits, the Email Validation, OTP and Domain Health add-ons, and webhook signature verification. Zero dependencies (standard library `urllib`, `json`, `hmac`), fully type-hinted, Python 3.9+.
31
+
32
+ > Send the email your product depends on - verification links, OTP codes, password resets, receipts - from your own verified domain.
33
+
34
+ ## What's inside
35
+
36
+ | Resource | Methods | Scope |
37
+ | --- | --- | --- |
38
+ | `emails` | `send`, `get` | `email:send` / `email:read` |
39
+ | `account` | `credits` | `email:read` |
40
+ | `suppressions` | `list`, `get`, `add`, `remove` | `email:read` / `email:send` |
41
+ | `validation` | `check`, `jobs.create`, `jobs.get`, `jobs.list`, `jobs.results`, `jobs.iter_results` | `email:send` / `email:read` |
42
+ | `otp` | `send`, `verify` | `email:send` |
43
+ | `domains` | `check`, `monitors.create`, `monitors.list`, `monitors.delete` | `email:send` / `email:read` |
44
+ | `capimail.webhooks` | `verify_signature`, `construct_event` | none (uses your endpoint secret) |
45
+
46
+ Methods return frozen `dataclasses` with snake_case fields (for example `SendEmailResult.message_id`, `ValidationJob.credits_charged`) and a `request_id` you can quote to support. Webhook events are plain dicts. Full API reference: https://capimail.com/docs (add-ons: https://capimail.com/docs#addons). Release notes: [CHANGELOG.md](./CHANGELOG.md).
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install capimail
52
+ ```
53
+
54
+ ## Quickstart
55
+
56
+ ```python
57
+ import os
58
+ from capimail import CapiMail
59
+
60
+ # Start with a cm_test_ key: it runs the full simulated lifecycle without
61
+ # sending, reserving credits, or touching live data. Swap to cm_live_ later.
62
+ capimail = CapiMail(os.environ["CAPIMAIL_API_KEY"])
63
+
64
+ result = capimail.emails.send(
65
+ from_="hello@your-verified-domain.com",
66
+ to="customer@example.com",
67
+ subject="Welcome to Acme",
68
+ html="<h1>Welcome</h1><p>Your account is ready.</p>",
69
+ idempotency_key="welcome-user-123", # required; makes retries safe
70
+ )
71
+
72
+ print(result.message_id, result.status) # e.g. "…uuid…" "queued"
73
+ ```
74
+
75
+ Keep your key **server-side**. Never ship it in client code or a public repo.
76
+
77
+ ## Sending
78
+
79
+ `emails.send(...)` takes keyword arguments:
80
+
81
+ | Argument | Type | Notes |
82
+ | --- | --- | --- |
83
+ | `from_` | `str` | Verified sender. Bare address or `"Acme <hello@acme.com>"`. (`from` is a Python keyword.) |
84
+ | `to` | `str \| list[str]` | 1–50 unique recipients. |
85
+ | `idempotency_key` | `str` | **Required.** 8–128 of `A–Z a–z 0–9 . _ : -`. Reuse returns the original message; reuse with a different payload is a 409. |
86
+ | `subject` | `str` | Required unless provided by a template. |
87
+ | `html` / `text` | `str` | At least one is required (unless templated). |
88
+ | `reply_to` | `str` | Optional Reply-To address. |
89
+ | `headers` | `dict[str, str]` | Optional allow-listed headers. |
90
+ | `tags` | `dict[str, str]` | Optional metadata. |
91
+ | `attachments` | `list[Attachment \| dict]` | `filename`, `content_type`, `content` (base64), `content_id?`, `disposition?`. |
92
+ | `template` | `{"key", "locale"?, "variables"?}` | Send from a stored template instead of inline content. |
93
+
94
+ ### Template send
95
+
96
+ ```python
97
+ capimail.emails.send(
98
+ from_="accounts@your-verified-domain.com",
99
+ to=user.email,
100
+ template={"key": "email-verification", "locale": "id", "variables": {"name": user.name, "verify_url": link}},
101
+ idempotency_key=f"verify-{user.id}",
102
+ )
103
+ ```
104
+
105
+ ### Attachments
106
+
107
+ ```python
108
+ from capimail import Attachment
109
+
110
+ with open("invoice.pdf", "rb") as fh:
111
+ invoice = Attachment.from_bytes("invoice.pdf", fh.read(), "application/pdf")
112
+
113
+ capimail.emails.send(from_=..., to=..., subject="Invoice", text="Attached.", attachments=[invoice], idempotency_key="invoice-1001")
114
+ ```
115
+
116
+ ## Checking status
117
+
118
+ A `202` means the send is durably queued - not delivered. Poll for the real state:
119
+
120
+ ```python
121
+ status = capimail.emails.get(result.message_id)
122
+ print(status.email.status) # queued | processing | provider_accepted | delivered | hard_bounced | …
123
+ for event in status.email.events:
124
+ print(event.event_type, event.occurred_at)
125
+ ```
126
+
127
+ ## Credits and suppressions
128
+
129
+ ```python
130
+ available = capimail.account.credits().available
131
+
132
+ capimail.suppressions.get("user@example.com").suppressed # True / False
133
+ capimail.suppressions.add("user@example.com", "unsubscribe")
134
+ capimail.suppressions.remove("user@example.com") # False if it was not suppressed
135
+
136
+ page = capimail.suppressions.list(limit=100)
137
+ while True:
138
+ for s in page.suppressions:
139
+ print(s.email_hash, s.reason) # the plaintext address is never returned
140
+ if not page.next_cursor:
141
+ break
142
+ page = capimail.suppressions.list(limit=100, cursor=page.next_cursor)
143
+ ```
144
+
145
+ ## Add-ons
146
+
147
+ Add-ons are paid from the same credit balance and need a live key (`cm_live_`), except OTP which also works with test keys. Prices: https://capimail.com/docs#addons.
148
+
149
+ ### Email Validation
150
+
151
+ ```python
152
+ # Up to 100 addresses, answered immediately. 2 credits per address with a verdict.
153
+ check = capimail.validation.check(["budi@perusahaan.co.id"], idempotency_key="signup-123")
154
+ check.results[0].verdict # "valid" | "risky" | "invalid" | "unknown"
155
+ check.results[0].reasons # e.g. ["role_address"]
156
+
157
+ # Up to 10,000 addresses in the background. Subscribe your webhook to
158
+ # validation.completed, or poll jobs.get(job.id).
159
+ job = capimail.validation.jobs.create(
160
+ emails,
161
+ suppress_invalid=True, # invalid addresses join your suppression list
162
+ metadata={"list_id": "march"}, # echoed in the webhook
163
+ idempotency_key="list-march", # a retry returns the same job
164
+ )
165
+
166
+ # Later: iterate every result across pages (a generator; fetches lazily).
167
+ for result in capimail.validation.jobs.iter_results(job.id, verdict="invalid"):
168
+ mark_invalid(result.email)
169
+ ```
170
+
171
+ `jobs.results(job_id, verdict=None, after=None, limit=None)` returns one page if you want to drive pagination yourself (`page.next_after`), and `jobs.list(limit=None, before=None)` pages through your jobs.
172
+
173
+ ### OTP
174
+
175
+ ```python
176
+ otp = capimail.otp.send("user@example.com", purpose="login")
177
+ # store otp.id with the login attempt; never store the code
178
+
179
+ check = capimail.otp.verify(otp.id, code_typed_by_user)
180
+ if not check.valid:
181
+ # check.status: "invalid_code" | "locked" | "expired" | "already_verified" | "not_found"
182
+ ...
183
+ ```
184
+
185
+ Sender, template, language, code length, validity and attempts come from **Dashboard > Add-ons > OTP** (per sending domain) and can be overridden per call (`template_key`, `locale`, `code_length`, `ttl_seconds`, `max_attempts`, `domain`, `variables`). Your template receives `{{code}}`, `{{expires_minutes}}` and `{{purpose}}`. With a `cm_test_` key nothing is delivered and the result includes `test_code`.
186
+
187
+ ### Domain Health
188
+
189
+ ```python
190
+ health = capimail.domains.check("client.co.id", dkim_selectors=["google"])
191
+ health.status # "pass" | "warn" | "fail"
192
+ health.issues # [DomainIssue(check="dmarc", code="dmarc_policy_none"), ...]
193
+ health.checks # raw {"mx": {...}, "spf": {...}, "dkim": {...}, "dmarc": {...}}
194
+
195
+ # Daily monitoring; domain.health_changed fires when the status changes.
196
+ monitor = capimail.domains.monitors.create("client.co.id")
197
+ capimail.domains.monitors.list()
198
+ capimail.domains.monitors.delete(monitor.id) # False if it no longer exists
199
+ ```
200
+
201
+ ## Webhooks
202
+
203
+ CapiMail signs every webhook with `X-CapiMail-Signature: t=<unix>,v1=<hex>`. Verify it against the **raw** request body (bytes or str, never a re-serialized dict). `construct_event` raises `capimail.WebhookSignatureError` (a `ValueError`) when the signature is missing, stale (default tolerance 300 s) or wrong; `verify_signature` returns a bool instead.
204
+
205
+ ### FastAPI
206
+
207
+ ```python
208
+ import os
209
+ from fastapi import FastAPI, Header, HTTPException, Request
210
+ from capimail.webhooks import construct_event
211
+
212
+ app = FastAPI()
213
+
214
+ @app.post("/webhooks/capimail")
215
+ async def capimail_webhook(request: Request, x_capimail_signature: str = Header(None)):
216
+ payload = await request.body()
217
+ try:
218
+ event = construct_event(
219
+ payload,
220
+ x_capimail_signature,
221
+ os.environ["CAPIMAIL_WEBHOOK_SECRET"], # or [current, previous] while rotating
222
+ )
223
+ except ValueError:
224
+ raise HTTPException(status_code=400, detail="invalid signature")
225
+
226
+ if event["type"] == "email.bounced":
227
+ ... # event["data"]["message_id"]
228
+ elif event["type"] == "validation.completed":
229
+ ... # event["data"]["job_id"], ["summary"], ["metadata"]
230
+ elif event["type"] == "domain.health_changed":
231
+ ... # event["data"]["domain"], ["status"], ["issues"]
232
+ return {"ok": True} # any 2xx; dedupe on the X-CapiMail-Event-Id header
233
+ ```
234
+
235
+ ### Flask
236
+
237
+ ```python
238
+ import os
239
+ from flask import Flask, abort, request
240
+ from capimail.webhooks import construct_event
241
+
242
+ app = Flask(__name__)
243
+
244
+ @app.post("/webhooks/capimail")
245
+ def capimail_webhook():
246
+ try:
247
+ event = construct_event(
248
+ request.get_data(), # raw bytes
249
+ request.headers.get("X-CapiMail-Signature"),
250
+ [os.environ["CAPIMAIL_WEBHOOK_SECRET"], os.environ.get("CAPIMAIL_WEBHOOK_SECRET_PREVIOUS", "")],
251
+ )
252
+ except ValueError:
253
+ abort(400)
254
+ handle(event)
255
+ return "", 204
256
+ ```
257
+
258
+ `secret` may be a `str`, `bytes`, or a list of them (current and previous while rotating); any other type raises `TypeError`. A malformed `X-CapiMail-Signature` header never raises from `verify_signature`; it returns `False` (and `construct_event` raises `WebhookSignatureError`, a `ValueError`). The replay window is `tolerance=300` seconds; a non-positive or non-finite value falls back to 300.
259
+
260
+ ## Errors
261
+
262
+ Non-2xx responses raise `CapiMailError` with a stable `code`:
263
+
264
+ ```python
265
+ from capimail import CapiMailConnectionError, CapiMailError
266
+
267
+ try:
268
+ capimail.emails.send(...)
269
+ except CapiMailError as err:
270
+ print(err.status, err.code, err.message, err.request_id) # 402 "insufficient_credits" …
271
+ # err.details: field-level problems for 422 validation_error.
272
+ # Add-on codes include live_key_required, otp_rate_limited, domain_required,
273
+ # invalid_domain and monitor_limit_reached; see https://capimail.com/docs#addons-errors
274
+ if err.is_retryable:
275
+ # 429 / 503 - back off and retry with the same idempotency key;
276
+ # err.retry_after_seconds is set when the response carried Retry-After.
277
+ ...
278
+ except CapiMailConnectionError as err:
279
+ # Network failure or timeout (err.cause holds the underlying exception).
280
+ ...
281
+ ```
282
+
283
+ ## Test mode
284
+
285
+ A `cm_test_` key simulates the full lifecycle (real `message_id`, signed webhooks) without sending. The simulated outcome is driven by the first recipient's local-part:
286
+
287
+ | Recipient | Simulated outcome |
288
+ | --- | --- |
289
+ | `bounce@…` | hard bounce |
290
+ | `complaint@…` | complaint |
291
+ | `fail@…` | failure |
292
+ | anything else | delivered |
293
+
294
+ ```python
295
+ capimail = CapiMail("cm_test_…")
296
+ capimail.is_test_mode # True
297
+ r = capimail.emails.send(from_="x@acme.com", to="bounce@example.com", subject="t", text="t", idempotency_key="test-bounce-1")
298
+ r.test # True
299
+ r.outcome # the simulated bounce outcome
300
+ ```
301
+
302
+ ## Options
303
+
304
+ ```python
305
+ CapiMail(
306
+ api_key,
307
+ base_url="https://capimail.com", # default
308
+ timeout=30, # seconds, default
309
+ transport=None, # optional callable(HttpRequest) -> HttpResponse
310
+ )
311
+ ```
312
+
313
+ `timeout` must be a positive, finite number of seconds; anything else raises `ValueError`.
314
+
315
+ `transport` lets you plug in another HTTP stack or a fake in your own tests; the default uses `urllib.request`.
316
+
317
+ The default transport never follows HTTP redirects, so your API key is only ever sent to `base_url`. A 3xx response raises `CapiMailError` with `code="unexpected_redirect"` (and `status` set to the 3xx); point `base_url` at the final API origin, for example `https://capimail.com` rather than a host that redirects to it. If you write your own transport, do not follow redirects either.
318
+
319
+ ## License
320
+
321
+ MIT
@@ -0,0 +1,294 @@
1
+ # capimail
2
+
3
+ Official Python client for the **CapiMail** API: transactional email, suppressions, credits, the Email Validation, OTP and Domain Health add-ons, and webhook signature verification. Zero dependencies (standard library `urllib`, `json`, `hmac`), fully type-hinted, Python 3.9+.
4
+
5
+ > Send the email your product depends on - verification links, OTP codes, password resets, receipts - from your own verified domain.
6
+
7
+ ## What's inside
8
+
9
+ | Resource | Methods | Scope |
10
+ | --- | --- | --- |
11
+ | `emails` | `send`, `get` | `email:send` / `email:read` |
12
+ | `account` | `credits` | `email:read` |
13
+ | `suppressions` | `list`, `get`, `add`, `remove` | `email:read` / `email:send` |
14
+ | `validation` | `check`, `jobs.create`, `jobs.get`, `jobs.list`, `jobs.results`, `jobs.iter_results` | `email:send` / `email:read` |
15
+ | `otp` | `send`, `verify` | `email:send` |
16
+ | `domains` | `check`, `monitors.create`, `monitors.list`, `monitors.delete` | `email:send` / `email:read` |
17
+ | `capimail.webhooks` | `verify_signature`, `construct_event` | none (uses your endpoint secret) |
18
+
19
+ Methods return frozen `dataclasses` with snake_case fields (for example `SendEmailResult.message_id`, `ValidationJob.credits_charged`) and a `request_id` you can quote to support. Webhook events are plain dicts. Full API reference: https://capimail.com/docs (add-ons: https://capimail.com/docs#addons). Release notes: [CHANGELOG.md](./CHANGELOG.md).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install capimail
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```python
30
+ import os
31
+ from capimail import CapiMail
32
+
33
+ # Start with a cm_test_ key: it runs the full simulated lifecycle without
34
+ # sending, reserving credits, or touching live data. Swap to cm_live_ later.
35
+ capimail = CapiMail(os.environ["CAPIMAIL_API_KEY"])
36
+
37
+ result = capimail.emails.send(
38
+ from_="hello@your-verified-domain.com",
39
+ to="customer@example.com",
40
+ subject="Welcome to Acme",
41
+ html="<h1>Welcome</h1><p>Your account is ready.</p>",
42
+ idempotency_key="welcome-user-123", # required; makes retries safe
43
+ )
44
+
45
+ print(result.message_id, result.status) # e.g. "…uuid…" "queued"
46
+ ```
47
+
48
+ Keep your key **server-side**. Never ship it in client code or a public repo.
49
+
50
+ ## Sending
51
+
52
+ `emails.send(...)` takes keyword arguments:
53
+
54
+ | Argument | Type | Notes |
55
+ | --- | --- | --- |
56
+ | `from_` | `str` | Verified sender. Bare address or `"Acme <hello@acme.com>"`. (`from` is a Python keyword.) |
57
+ | `to` | `str \| list[str]` | 1–50 unique recipients. |
58
+ | `idempotency_key` | `str` | **Required.** 8–128 of `A–Z a–z 0–9 . _ : -`. Reuse returns the original message; reuse with a different payload is a 409. |
59
+ | `subject` | `str` | Required unless provided by a template. |
60
+ | `html` / `text` | `str` | At least one is required (unless templated). |
61
+ | `reply_to` | `str` | Optional Reply-To address. |
62
+ | `headers` | `dict[str, str]` | Optional allow-listed headers. |
63
+ | `tags` | `dict[str, str]` | Optional metadata. |
64
+ | `attachments` | `list[Attachment \| dict]` | `filename`, `content_type`, `content` (base64), `content_id?`, `disposition?`. |
65
+ | `template` | `{"key", "locale"?, "variables"?}` | Send from a stored template instead of inline content. |
66
+
67
+ ### Template send
68
+
69
+ ```python
70
+ capimail.emails.send(
71
+ from_="accounts@your-verified-domain.com",
72
+ to=user.email,
73
+ template={"key": "email-verification", "locale": "id", "variables": {"name": user.name, "verify_url": link}},
74
+ idempotency_key=f"verify-{user.id}",
75
+ )
76
+ ```
77
+
78
+ ### Attachments
79
+
80
+ ```python
81
+ from capimail import Attachment
82
+
83
+ with open("invoice.pdf", "rb") as fh:
84
+ invoice = Attachment.from_bytes("invoice.pdf", fh.read(), "application/pdf")
85
+
86
+ capimail.emails.send(from_=..., to=..., subject="Invoice", text="Attached.", attachments=[invoice], idempotency_key="invoice-1001")
87
+ ```
88
+
89
+ ## Checking status
90
+
91
+ A `202` means the send is durably queued - not delivered. Poll for the real state:
92
+
93
+ ```python
94
+ status = capimail.emails.get(result.message_id)
95
+ print(status.email.status) # queued | processing | provider_accepted | delivered | hard_bounced | …
96
+ for event in status.email.events:
97
+ print(event.event_type, event.occurred_at)
98
+ ```
99
+
100
+ ## Credits and suppressions
101
+
102
+ ```python
103
+ available = capimail.account.credits().available
104
+
105
+ capimail.suppressions.get("user@example.com").suppressed # True / False
106
+ capimail.suppressions.add("user@example.com", "unsubscribe")
107
+ capimail.suppressions.remove("user@example.com") # False if it was not suppressed
108
+
109
+ page = capimail.suppressions.list(limit=100)
110
+ while True:
111
+ for s in page.suppressions:
112
+ print(s.email_hash, s.reason) # the plaintext address is never returned
113
+ if not page.next_cursor:
114
+ break
115
+ page = capimail.suppressions.list(limit=100, cursor=page.next_cursor)
116
+ ```
117
+
118
+ ## Add-ons
119
+
120
+ Add-ons are paid from the same credit balance and need a live key (`cm_live_`), except OTP which also works with test keys. Prices: https://capimail.com/docs#addons.
121
+
122
+ ### Email Validation
123
+
124
+ ```python
125
+ # Up to 100 addresses, answered immediately. 2 credits per address with a verdict.
126
+ check = capimail.validation.check(["budi@perusahaan.co.id"], idempotency_key="signup-123")
127
+ check.results[0].verdict # "valid" | "risky" | "invalid" | "unknown"
128
+ check.results[0].reasons # e.g. ["role_address"]
129
+
130
+ # Up to 10,000 addresses in the background. Subscribe your webhook to
131
+ # validation.completed, or poll jobs.get(job.id).
132
+ job = capimail.validation.jobs.create(
133
+ emails,
134
+ suppress_invalid=True, # invalid addresses join your suppression list
135
+ metadata={"list_id": "march"}, # echoed in the webhook
136
+ idempotency_key="list-march", # a retry returns the same job
137
+ )
138
+
139
+ # Later: iterate every result across pages (a generator; fetches lazily).
140
+ for result in capimail.validation.jobs.iter_results(job.id, verdict="invalid"):
141
+ mark_invalid(result.email)
142
+ ```
143
+
144
+ `jobs.results(job_id, verdict=None, after=None, limit=None)` returns one page if you want to drive pagination yourself (`page.next_after`), and `jobs.list(limit=None, before=None)` pages through your jobs.
145
+
146
+ ### OTP
147
+
148
+ ```python
149
+ otp = capimail.otp.send("user@example.com", purpose="login")
150
+ # store otp.id with the login attempt; never store the code
151
+
152
+ check = capimail.otp.verify(otp.id, code_typed_by_user)
153
+ if not check.valid:
154
+ # check.status: "invalid_code" | "locked" | "expired" | "already_verified" | "not_found"
155
+ ...
156
+ ```
157
+
158
+ Sender, template, language, code length, validity and attempts come from **Dashboard > Add-ons > OTP** (per sending domain) and can be overridden per call (`template_key`, `locale`, `code_length`, `ttl_seconds`, `max_attempts`, `domain`, `variables`). Your template receives `{{code}}`, `{{expires_minutes}}` and `{{purpose}}`. With a `cm_test_` key nothing is delivered and the result includes `test_code`.
159
+
160
+ ### Domain Health
161
+
162
+ ```python
163
+ health = capimail.domains.check("client.co.id", dkim_selectors=["google"])
164
+ health.status # "pass" | "warn" | "fail"
165
+ health.issues # [DomainIssue(check="dmarc", code="dmarc_policy_none"), ...]
166
+ health.checks # raw {"mx": {...}, "spf": {...}, "dkim": {...}, "dmarc": {...}}
167
+
168
+ # Daily monitoring; domain.health_changed fires when the status changes.
169
+ monitor = capimail.domains.monitors.create("client.co.id")
170
+ capimail.domains.monitors.list()
171
+ capimail.domains.monitors.delete(monitor.id) # False if it no longer exists
172
+ ```
173
+
174
+ ## Webhooks
175
+
176
+ CapiMail signs every webhook with `X-CapiMail-Signature: t=<unix>,v1=<hex>`. Verify it against the **raw** request body (bytes or str, never a re-serialized dict). `construct_event` raises `capimail.WebhookSignatureError` (a `ValueError`) when the signature is missing, stale (default tolerance 300 s) or wrong; `verify_signature` returns a bool instead.
177
+
178
+ ### FastAPI
179
+
180
+ ```python
181
+ import os
182
+ from fastapi import FastAPI, Header, HTTPException, Request
183
+ from capimail.webhooks import construct_event
184
+
185
+ app = FastAPI()
186
+
187
+ @app.post("/webhooks/capimail")
188
+ async def capimail_webhook(request: Request, x_capimail_signature: str = Header(None)):
189
+ payload = await request.body()
190
+ try:
191
+ event = construct_event(
192
+ payload,
193
+ x_capimail_signature,
194
+ os.environ["CAPIMAIL_WEBHOOK_SECRET"], # or [current, previous] while rotating
195
+ )
196
+ except ValueError:
197
+ raise HTTPException(status_code=400, detail="invalid signature")
198
+
199
+ if event["type"] == "email.bounced":
200
+ ... # event["data"]["message_id"]
201
+ elif event["type"] == "validation.completed":
202
+ ... # event["data"]["job_id"], ["summary"], ["metadata"]
203
+ elif event["type"] == "domain.health_changed":
204
+ ... # event["data"]["domain"], ["status"], ["issues"]
205
+ return {"ok": True} # any 2xx; dedupe on the X-CapiMail-Event-Id header
206
+ ```
207
+
208
+ ### Flask
209
+
210
+ ```python
211
+ import os
212
+ from flask import Flask, abort, request
213
+ from capimail.webhooks import construct_event
214
+
215
+ app = Flask(__name__)
216
+
217
+ @app.post("/webhooks/capimail")
218
+ def capimail_webhook():
219
+ try:
220
+ event = construct_event(
221
+ request.get_data(), # raw bytes
222
+ request.headers.get("X-CapiMail-Signature"),
223
+ [os.environ["CAPIMAIL_WEBHOOK_SECRET"], os.environ.get("CAPIMAIL_WEBHOOK_SECRET_PREVIOUS", "")],
224
+ )
225
+ except ValueError:
226
+ abort(400)
227
+ handle(event)
228
+ return "", 204
229
+ ```
230
+
231
+ `secret` may be a `str`, `bytes`, or a list of them (current and previous while rotating); any other type raises `TypeError`. A malformed `X-CapiMail-Signature` header never raises from `verify_signature`; it returns `False` (and `construct_event` raises `WebhookSignatureError`, a `ValueError`). The replay window is `tolerance=300` seconds; a non-positive or non-finite value falls back to 300.
232
+
233
+ ## Errors
234
+
235
+ Non-2xx responses raise `CapiMailError` with a stable `code`:
236
+
237
+ ```python
238
+ from capimail import CapiMailConnectionError, CapiMailError
239
+
240
+ try:
241
+ capimail.emails.send(...)
242
+ except CapiMailError as err:
243
+ print(err.status, err.code, err.message, err.request_id) # 402 "insufficient_credits" …
244
+ # err.details: field-level problems for 422 validation_error.
245
+ # Add-on codes include live_key_required, otp_rate_limited, domain_required,
246
+ # invalid_domain and monitor_limit_reached; see https://capimail.com/docs#addons-errors
247
+ if err.is_retryable:
248
+ # 429 / 503 - back off and retry with the same idempotency key;
249
+ # err.retry_after_seconds is set when the response carried Retry-After.
250
+ ...
251
+ except CapiMailConnectionError as err:
252
+ # Network failure or timeout (err.cause holds the underlying exception).
253
+ ...
254
+ ```
255
+
256
+ ## Test mode
257
+
258
+ A `cm_test_` key simulates the full lifecycle (real `message_id`, signed webhooks) without sending. The simulated outcome is driven by the first recipient's local-part:
259
+
260
+ | Recipient | Simulated outcome |
261
+ | --- | --- |
262
+ | `bounce@…` | hard bounce |
263
+ | `complaint@…` | complaint |
264
+ | `fail@…` | failure |
265
+ | anything else | delivered |
266
+
267
+ ```python
268
+ capimail = CapiMail("cm_test_…")
269
+ capimail.is_test_mode # True
270
+ r = capimail.emails.send(from_="x@acme.com", to="bounce@example.com", subject="t", text="t", idempotency_key="test-bounce-1")
271
+ r.test # True
272
+ r.outcome # the simulated bounce outcome
273
+ ```
274
+
275
+ ## Options
276
+
277
+ ```python
278
+ CapiMail(
279
+ api_key,
280
+ base_url="https://capimail.com", # default
281
+ timeout=30, # seconds, default
282
+ transport=None, # optional callable(HttpRequest) -> HttpResponse
283
+ )
284
+ ```
285
+
286
+ `timeout` must be a positive, finite number of seconds; anything else raises `ValueError`.
287
+
288
+ `transport` lets you plug in another HTTP stack or a fake in your own tests; the default uses `urllib.request`.
289
+
290
+ The default transport never follows HTTP redirects, so your API key is only ever sent to `base_url`. A 3xx response raises `CapiMailError` with `code="unexpected_redirect"` (and `status` set to the 3xx); point `base_url` at the final API origin, for example `https://capimail.com` rather than a host that redirects to it. If you write your own transport, do not follow redirects either.
291
+
292
+ ## License
293
+
294
+ MIT