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.
- announcer_sdk-0.1.0/.github/workflows/ci.yml +24 -0
- announcer_sdk-0.1.0/.github/workflows/release.yml +45 -0
- announcer_sdk-0.1.0/.gitignore +11 -0
- announcer_sdk-0.1.0/LICENSE +21 -0
- announcer_sdk-0.1.0/PKG-INFO +356 -0
- announcer_sdk-0.1.0/README.md +326 -0
- announcer_sdk-0.1.0/pyproject.toml +52 -0
- announcer_sdk-0.1.0/src/announcer/__init__.py +129 -0
- announcer_sdk-0.1.0/src/announcer/_client.py +187 -0
- announcer_sdk-0.1.0/src/announcer/_http.py +281 -0
- announcer_sdk-0.1.0/src/announcer/_resources.py +549 -0
- announcer_sdk-0.1.0/src/announcer/errors.py +257 -0
- announcer_sdk-0.1.0/src/announcer/py.typed +0 -0
- announcer_sdk-0.1.0/src/announcer/types.py +517 -0
- announcer_sdk-0.1.0/src/announcer/webhooks.py +100 -0
- announcer_sdk-0.1.0/tests/conftest.py +87 -0
- announcer_sdk-0.1.0/tests/test_async.py +124 -0
- announcer_sdk-0.1.0/tests/test_emails.py +356 -0
- announcer_sdk-0.1.0/tests/test_errors_and_retries.py +305 -0
- announcer_sdk-0.1.0/tests/test_webhooks_and_resources.py +315 -0
|
@@ -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,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
|