announcer-sdk 0.1.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,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ['3.9', '3.10', '3.11', '3.12', '3.13']
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: actions/setup-python@v7
17
+ with:
18
+ python-version: ${{ matrix.python }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: pytest -q
21
+ - run: mypy
22
+ if: matrix.python == '3.12'
23
+ - run: ruff check .
24
+ if: matrix.python == '3.12'
@@ -0,0 +1,45 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v7
15
+ - uses: actions/setup-python@v7
16
+ with:
17
+ python-version: '3.12'
18
+ - name: Check tag matches pyproject.toml
19
+ run: |
20
+ version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
21
+ test "${GITHUB_REF_NAME#v}" = "$version"
22
+ - run: pip install -e ".[dev]" build
23
+ - run: pytest -q
24
+ - run: mypy
25
+ - run: ruff check .
26
+ - run: python -m build
27
+ - uses: actions/upload-artifact@v7
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ publish:
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: pypi
37
+ url: https://pypi.org/p/announcer-sdk
38
+ permissions:
39
+ id-token: write
40
+ steps:
41
+ - uses: actions/download-artifact@v8
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trond Bordewich
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,356 @@
1
+ Metadata-Version: 2.5
2
+ Name: announcer-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Announcer — send transactional email from your own domain, DKIM-signed.
5
+ Project-URL: Homepage, https://misralo.com
6
+ Project-URL: Repository, https://github.com/ZeldaIV/announcer-python
7
+ Author: Trond Bordewich
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: announcer,dkim,email,smtp,transactional-email,webhooks
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications :: Email
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx<1,>=0.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.8; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
27
+ Requires-Dist: pytest>=7; extra == 'dev'
28
+ Requires-Dist: ruff==0.16.8; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # announcer-sdk
32
+
33
+ Python SDK for [Announcer](https://misralo.com) — send transactional email from
34
+ your own domain, DKIM-signed.
35
+
36
+ Sync and async clients, full type hints, one dependency (`httpx`).
37
+
38
+ ```bash
39
+ pip install announcer-sdk
40
+ ```
41
+
42
+ ## Send an email
43
+
44
+ ```python
45
+ from announcer import Announcer
46
+
47
+ announcer = Announcer() # reads ANNOUNCER_API_KEY
48
+
49
+ announcer.send(
50
+ from_="Acme <billing@acme.com>",
51
+ to="customer@example.com",
52
+ subject="Your receipt",
53
+ text="Thanks for your order.",
54
+ html="<p>Thanks for your order.</p>",
55
+ )
56
+ ```
57
+
58
+ That's the whole integration. `from_` has a trailing underscore because `from`
59
+ is a Python keyword; if you already have a dict, splat it and use the plain
60
+ spelling:
61
+
62
+ ```python
63
+ announcer.send(**{"from": "billing@acme.com", "to": "customer@example.com", "text": "Hi"})
64
+ ```
65
+
66
+ `to`, `cc` and `bcc` each take one address or a list, and `reply_to` sets the
67
+ reply address — see [Several recipients](#several-recipients).
68
+
69
+ ### Async
70
+
71
+ Same surface, awaited:
72
+
73
+ ```python
74
+ from announcer import AsyncAnnouncer
75
+
76
+ async with AsyncAnnouncer() as announcer:
77
+ await announcer.send(
78
+ from_="billing@acme.com",
79
+ to="customer@example.com",
80
+ subject="Your receipt",
81
+ text="Thanks!",
82
+ )
83
+ ```
84
+
85
+ ## Before your first send
86
+
87
+ You need a registered, verified sending domain — Announcer will not let you send
88
+ `From:` a domain you have not proved you control.
89
+
90
+ ```python
91
+ domain = announcer.domains.create("acme.com")
92
+
93
+ for record in domain.dns:
94
+ print(record.type, record.name, record.value)
95
+ # TXT mail._domainkey.acme.com v=DKIM1; k=rsa; p=MIIBIjANBg...
96
+
97
+ # Publish that record, wait for DNS, then:
98
+ announcer.domains.verify(domain.id)
99
+ ```
100
+
101
+ One TXT record is the entire ask. SPF and MX stay on Announcer's own bounce
102
+ domain, so your root domain's DNS is untouched.
103
+
104
+ ## What the SDK does for you
105
+
106
+ **Retries are safe.** Every send carries an `Idempotency-Key`, generated per call
107
+ when you do not supply one. A timeout, a 500, or a 429 gets retried with
108
+ exponential backoff and jitter — and because the key travels with the retry, the
109
+ API recognises it as the same operation instead of sending twice.
110
+
111
+ Supply your own key to extend that guarantee across process restarts:
112
+
113
+ ```python
114
+ announcer.send(
115
+ from_="billing@acme.com",
116
+ to="customer@example.com",
117
+ subject="Your receipt",
118
+ text="Thanks!",
119
+ idempotency_key=f"receipt-{order.id}", # this order mails exactly once, ever
120
+ )
121
+ ```
122
+
123
+ A replay tells you so rather than pretending it sent again:
124
+
125
+ ```python
126
+ result = announcer.send(..., idempotency_key="receipt-4711")
127
+ if result.idempotent_replay:
128
+ ... # already sent earlier; nothing went out a second time
129
+ ```
130
+
131
+ **Errors are typed.** Catch the case you can actually handle:
132
+
133
+ ```python
134
+ from announcer import SuppressedRecipientError, RateLimitError, PermissionDeniedError
135
+
136
+ try:
137
+ announcer.send(from_=sender, to=recipient, subject=subject, text=body)
138
+ except SuppressedRecipientError:
139
+ # They hard-bounced or complained before. Don't retry; mark them inactive.
140
+ deactivate(recipient)
141
+ except RateLimitError as exc:
142
+ print(f"Slow down for {exc.retry_after}s")
143
+ except PermissionDeniedError:
144
+ # Domain not registered, not verified, or this key is send-scoped.
145
+ ...
146
+ ```
147
+
148
+ The full set: `ValidationError` (with a per-field `.errors` dict),
149
+ `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`,
150
+ `ConflictError`, `UnprocessableEntityError`, `SuppressedRecipientError`,
151
+ `RateLimitError`, `InternalServerError`, `APIConnectionError`,
152
+ `APITimeoutError`. All subclass `AnnouncerError`.
153
+
154
+ **Results are dataclasses, not dicts.** Timestamps arrive as `datetime`
155
+ objects, `date` fields as `date`. The API mixes `camelCase` and `snake_case`
156
+ depending on the endpoint; the SDK normalises everything to Python's
157
+ convention and derives the booleans you actually want (`domain.verified`,
158
+ `key.revoked`, `endpoint.disabled`). Free-form `payload` and `detail` dicts
159
+ pass through untouched — those keys are your data.
160
+
161
+ ## Several recipients
162
+
163
+ `to`, `cc` and `bcc` each take one address or a list. Everything in `to` and
164
+ `cc` is **one email** whose recipients see each other; `bcc` recipients see
165
+ nobody, not even each other:
166
+
167
+ ```python
168
+ announcer.send(
169
+ from_="billing@acme.com",
170
+ to=["customer@example.com", "partner@example.com"],
171
+ cc="accounting@acme.com",
172
+ bcc="archive@acme.com",
173
+ reply_to="support@acme.com",
174
+ subject="Your receipt",
175
+ text="Thanks!",
176
+ )
177
+ ```
178
+
179
+ At most 50 addresses across the three. `reply_to` is a header only — it costs
180
+ nothing and cannot bounce.
181
+
182
+ **Recipients are the billable unit.** That call counts four against your quota,
183
+ not one. It is also what keeps `monthly_hard_cap` meaningful: otherwise a leaked
184
+ key could send fifty times your ceiling by padding the list.
185
+
186
+ ### One email, or many?
187
+
188
+ For anything list-shaped — a newsletter, a digest, a fan-out — you want
189
+ `send_many`, not a list:
190
+
191
+ ```python
192
+ results = announcer.emails.send_many(
193
+ ["a@example.com", "b@example.com", "c@example.com"],
194
+ from_="news@acme.com",
195
+ subject="September update",
196
+ html=body,
197
+ )
198
+
199
+ for r in results:
200
+ if not r.ok:
201
+ print(f"{r.to} failed: {r.error}")
202
+ ```
203
+
204
+ | | `send(to=[a, b])` | `send_many([a, b], …)` |
205
+ |---|---|---|
206
+ | Emails sent | one | two |
207
+ | Do they see each other? | yes, in `To:` | no |
208
+ | API requests | one | two |
209
+ | Idempotency key | one | one each, derived |
210
+ | One address fails | the send reports it | the others are unaffected |
211
+
212
+ ### Suppressed recipients
213
+
214
+ A recipient on your suppression list is dropped and the rest still goes out:
215
+
216
+ ```python
217
+ result = announcer.send(
218
+ from_="billing@acme.com",
219
+ to=["good@example.com", "bounced-before@example.com"],
220
+ subject="Your receipt",
221
+ text="Thanks!",
222
+ )
223
+
224
+ result.recipients # 1 — what actually went out and what you were billed
225
+ result.suppressed # ['bounced-before@example.com']
226
+ ```
227
+
228
+ `SuppressedRecipientError` is raised only when *every* recipient is suppressed
229
+ (or every `to` recipient — a message with no visible primary recipient is
230
+ refused rather than sent). Its `.suppressed` list names them all.
231
+
232
+ ## Webhooks
233
+
234
+ Register an endpoint, store the secret, verify every delivery:
235
+
236
+ ```python
237
+ endpoint = announcer.webhooks.create("https://acme.com/hooks/announcer")
238
+ print(endpoint.secret) # whsec_... — shown once, store it now
239
+ ```
240
+
241
+ Flask:
242
+
243
+ ```python
244
+ import os
245
+ from flask import Flask, request
246
+ from announcer import verify_webhook, SignatureVerificationError
247
+
248
+ app = Flask(__name__)
249
+
250
+ @app.post("/hooks/announcer")
251
+ def announcer_webhook():
252
+ try:
253
+ event = verify_webhook(
254
+ request.get_data(), # raw bytes, NOT request.get_json()
255
+ request.headers.get("X-Announcer-Signature"),
256
+ os.environ["ANNOUNCER_WEBHOOK_SECRET"],
257
+ )
258
+ except SignatureVerificationError:
259
+ return "", 400
260
+
261
+ if event.event == "delivered":
262
+ mark_delivered(event.message.id)
263
+ elif event.event in ("bounced", "complained"):
264
+ deactivate(event.message.to)
265
+
266
+ return "", 200
267
+ ```
268
+
269
+ Django is the same with `request.body`.
270
+
271
+ Verification checks the HMAC **and** the timestamp, rejecting anything more than
272
+ five minutes old so a captured delivery cannot be replayed at you. Tune it with
273
+ `tolerance=`.
274
+
275
+ Events: `sent`, `delivered`, `bounced`, `complained`, `suppressed`.
276
+
277
+ ## API reference
278
+
279
+ ### Client
280
+
281
+ ```python
282
+ Announcer(api_key=None, *, base_url=None, timeout=30.0, max_retries=2,
283
+ user_agent=None, headers=None, http_client=None)
284
+ AsyncAnnouncer(...) # same arguments
285
+ ```
286
+
287
+ | Argument | Default |
288
+ |---------------|------------------------------------------------------------------|
289
+ | `api_key` | `ANNOUNCER_API_KEY` |
290
+ | `base_url` | `ANNOUNCER_BASE_URL`, then `https://mail.misralo.com` |
291
+ | `timeout` | `30.0` seconds, per attempt |
292
+ | `max_retries` | `2` extra attempts after a failure |
293
+ | `user_agent` | appended to the SDK's own — name your app |
294
+ | `headers` | added to every request |
295
+ | `http_client` | bring your own `httpx.Client` / `httpx.AsyncClient` |
296
+
297
+ ### Methods
298
+
299
+ | Call | Does |
300
+ |------|------|
301
+ | `announcer.send(**msg)` | Shorthand for `emails.send`. |
302
+ | `announcer.usage()` | Quota consumption plus a 14-day sending series. |
303
+ | `emails.send(**msg)` | Sends one email. `to`/`cc`/`bcc` take one address or many. |
304
+ | `emails.send_many(recipients, **msg)` | Separate emails, one per recipient. |
305
+ | `emails.list(limit=, status=, search=)` | Send history. |
306
+ | `emails.events(message_id)` | A message's audit trail. |
307
+ | `domains.create(domain)` | Registers a domain, returns the DNS record. |
308
+ | `domains.list()` | Every domain on the account. |
309
+ | `domains.dns(id)` | The records again, for a domain you already registered. |
310
+ | `domains.verify(id)` | Resolves DNS and checks the published key. |
311
+ | `domains.delete(id)` | Removes the domain and its signing key. |
312
+ | `api_keys.create(name, scope="full")` | Issues a key. Secret shown once. |
313
+ | `api_keys.list()` | Every key, without secrets. |
314
+ | `api_keys.revoke(id)` | Revokes a key; history survives. |
315
+ | `webhooks.create(url)` | Registers an endpoint. Max 2 active. |
316
+ | `webhooks.list()` | Every endpoint. |
317
+ | `webhooks.delete(id)` | Disables an endpoint. |
318
+ | `webhooks.verify(body, header, secret)` | Verifies a delivery. |
319
+ | `suppressions.list(limit=)` | Addresses that bounced or complained. |
320
+
321
+ `domains.*`, `api_keys.*` and `webhooks.*` need a `full`-scoped key. Everything
322
+ else works with a `send` key too — give integrations `send`.
323
+
324
+ ## Scopes
325
+
326
+ Mint a `send`-scoped key for anything that only sends mail:
327
+
328
+ ```python
329
+ key = announcer.api_keys.create("production-worker", "send")
330
+ ```
331
+
332
+ A leaked send key cannot register domains, mint successor keys, or touch
333
+ billing. It is the difference between an incident and a catastrophe.
334
+
335
+ ## Local development
336
+
337
+ Point the SDK at a local Announcer stack:
338
+
339
+ ```python
340
+ announcer = Announcer(
341
+ "ann_dev_0000000000000000000000000000",
342
+ base_url="http://localhost:8080",
343
+ )
344
+ ```
345
+
346
+ ## Contributing
347
+
348
+ ```bash
349
+ pip install -e ".[dev]"
350
+ pytest # no network; everything runs against httpx.MockTransport
351
+ mypy
352
+ ```
353
+
354
+ ## License
355
+
356
+ MIT