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.
- capimail-0.1.1/.gitignore +8 -0
- capimail-0.1.1/CHANGELOG.md +31 -0
- capimail-0.1.1/PKG-INFO +321 -0
- capimail-0.1.1/README.md +294 -0
- capimail-0.1.1/pyproject.toml +48 -0
- capimail-0.1.1/src/capimail/__init__.py +40 -0
- capimail-0.1.1/src/capimail/_client.py +668 -0
- capimail-0.1.1/src/capimail/_transport.py +92 -0
- capimail-0.1.1/src/capimail/_version.py +1 -0
- capimail-0.1.1/src/capimail/errors.py +53 -0
- capimail-0.1.1/src/capimail/py.typed +0 -0
- capimail-0.1.1/src/capimail/types.py +362 -0
- capimail-0.1.1/src/capimail/webhooks.py +129 -0
- capimail-0.1.1/tests/conftest.py +58 -0
- capimail-0.1.1/tests/test_client.py +524 -0
- capimail-0.1.1/tests/test_security.py +281 -0
- capimail-0.1.1/tests/test_webhooks.py +85 -0
|
@@ -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.
|
capimail-0.1.1/PKG-INFO
ADDED
|
@@ -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
|
capimail-0.1.1/README.md
ADDED
|
@@ -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
|