broadcast-python 0.1.0__tar.gz → 0.2.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.
Files changed (33) hide show
  1. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/CHANGELOG.md +29 -1
  2. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/PKG-INFO +54 -13
  3. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/README.md +52 -11
  4. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/pyproject.toml +1 -1
  5. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/client.py +18 -0
  6. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/base.py +3 -0
  7. broadcast_python-0.2.0/src/broadcast_python/resources/channel_design.py +24 -0
  8. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/discovery.py +14 -0
  9. broadcast_python-0.2.0/src/broadcast_python/resources/global_suppressions.py +40 -0
  10. broadcast_python-0.2.0/src/broadcast_python/resources/suppressions.py +52 -0
  11. broadcast_python-0.2.0/src/broadcast_python/resources/users.py +115 -0
  12. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/version.py +1 -1
  13. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/webhook.py +3 -0
  14. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/.gitignore +0 -0
  15. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/LICENSE +0 -0
  16. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/__init__.py +0 -0
  17. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/configuration.py +0 -0
  18. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/connection.py +0 -0
  19. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/errors.py +0 -0
  20. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/py.typed +0 -0
  21. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/__init__.py +0 -0
  22. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/autopilots.py +0 -0
  23. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/broadcasts.py +0 -0
  24. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/email_servers.py +0 -0
  25. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/migration.py +0 -0
  26. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/opt_in_forms.py +0 -0
  27. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/segments.py +0 -0
  28. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/sequences.py +0 -0
  29. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/subscribers.py +0 -0
  30. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/templates.py +0 -0
  31. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/transactionals.py +0 -0
  32. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/resources/webhook_endpoints.py +0 -0
  33. {broadcast_python-0.1.0 → broadcast_python-0.2.0}/src/broadcast_python/response.py +0 -0
@@ -2,7 +2,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [0.1.0] - 2026-07-27
5
+ ## [0.2.0] - 2026-09-25
6
+
7
+ ### Added
8
+ - `subscribers.purged` and `subscribers.purge_failed` webhook event types, in
9
+ `SUBSCRIBER_EVENTS` and `EVENT_TYPES` (now 34). A purge of the whole list
10
+ sends one of these instead of a `subscriber.deleted` per subscriber.
11
+ - `client.channel_design.get()` for `GET /api/v1/channel/design`: the token
12
+ channel's brand kit (colours, font and font stack, layout, logo URL, website
13
+ and social links), fully resolved and read-only. Needs `templates_read`.
14
+ - `client.users` resource for the admin-only Users API: `list`, `get`,
15
+ `create`, `update`, `deactivate`, `activate`, `delete`,
16
+ `channel_permissions`, `set_channel_permissions` (PUT, replaces the whole
17
+ channel record; requires exactly one of `permissions`/`role`/`preset_id`),
18
+ `remove_channel_permissions`, `bulk_channel_permissions`,
19
+ `system_permissions`, `update_system_permissions`. Requires an admin API
20
+ token; sudo users are read-only and sudo access can never be granted
21
+ through the API. Adds `BaseResource._put`.
22
+
23
+ ## [0.1.0] - 2026-07-28
24
+
25
+ Published to PyPI as `broadcast-python`; the import module is `broadcast_python`,
26
+ because a package named `broadcast` already occupies that name on PyPI.
27
+
28
+ Released through PyPI trusted publishing (OIDC) rather than an API token — no
29
+ credential for this package exists anywhere. Verified from the registry: the
30
+ installed artifact imports, ships `py.typed`, exposes all 18 migration
31
+ collections and 32 event types, and computes a webhook signature identical to
32
+ the Ruby, PHP and Node SDKs.
33
+
6
34
 
7
35
  First release. Feature parity with `broadcast-ruby` v0.3.0 — the reference
8
36
  implementation — verified at **104/104 API operations** by the coverage report
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: broadcast-python
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python client for the Broadcast email platform. Subscribers, sequences, broadcasts, segments, templates, autopilot, webhooks, and transactional email.
5
5
  Project-URL: Homepage, https://sendbroadcast.net
6
6
  Project-URL: Repository, https://github.com/send-broadcast/broadcast-python
@@ -26,20 +26,12 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  Official Python client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
28
28
 
29
- Works with any Broadcast instance — self-hosted or SaaS. Covers **104/104 API operations**, verified against the API's generated OpenAPI document.
29
+ Works with any Broadcast instance — self-hosted or SaaS. Covers **117/117 API operations**, verified against the API's generated OpenAPI document.
30
30
 
31
31
  📖 **[Python SDK documentation](https://sendbroadcast.net/docs/python-sdk)** · [API reference](https://sendbroadcast.net/docs/api-authentication) · [All docs](https://sendbroadcast.net/docs)
32
32
 
33
33
  Also available: [Ruby](https://github.com/send-broadcast/broadcast-ruby) · [PHP](https://github.com/send-broadcast/broadcast-php) · [Node/TypeScript](https://github.com/send-broadcast/broadcast-node)
34
34
 
35
- > **Not yet on PyPI.** The package is complete and tested but unpublished, so
36
- > `pip install broadcast-python` will not resolve. Install from the repository
37
- > until it lands:
38
- >
39
- > ```bash
40
- > pip install git+https://github.com/send-broadcast/broadcast-python
41
- > ```
42
-
43
35
  ## Installation
44
36
 
45
37
  ```bash
@@ -254,6 +246,19 @@ client.opt_in_forms.duplicate(id, label="Copy")
254
246
 
255
247
  Reading a segment recounts its members server-side, so `segments.get` is not free.
256
248
 
249
+ ### Channel design (brand kit)
250
+
251
+ ```python
252
+ kit = client.channel_design.get()
253
+ kit["colors"]["accent"] # "#2563eb"
254
+ kit["typography"]["font_stack"] # email-safe CSS font stack
255
+ kit["brand"]["logo_url"] # public URL, or None
256
+ ```
257
+
258
+ Read-only, for the token's own channel, fully resolved (defaults filled in).
259
+ Needs `templates_read`. Block emails built with the drag-and-drop editor inherit
260
+ these values.
261
+
257
262
  ### Email servers
258
263
 
259
264
  **Credential redaction guard.** The API returns credentials bullet-masked
@@ -268,6 +273,39 @@ client.email_servers.update(id, name="Renamed", smtp_password=server["smtp_passw
268
273
  # -> sends only {"name": "Renamed"}, warns about the dropped field
269
274
  ```
270
275
 
276
+ ### Users
277
+
278
+ Requires an **admin API token**. Sudo users are read-only through this API —
279
+ update, deactivate, activate, delete, and permission writes on one raise
280
+ `AuthorizationError`. Sudo access itself can never be granted through the API.
281
+
282
+ ```python
283
+ client.users.list(status="active", q="ada")
284
+ user = client.users.create(email="ada@example.com", first_name="Ada", last_name="L", password="x")
285
+ client.users.update(user["id"], first_name="Grace")
286
+ client.users.deactivate(user["id"])
287
+ client.users.activate(user["id"])
288
+ client.users.delete(user["id"])
289
+ ```
290
+
291
+ Channel and system permissions:
292
+
293
+ ```python
294
+ client.users.channel_permissions(user["id"])
295
+
296
+ # PUT replaces the whole channel record -- pass exactly one of
297
+ # permissions, role, or preset_id
298
+ client.users.set_channel_permissions(user["id"], channel_id, role="Editor")
299
+ client.users.set_channel_permissions(user["id"], channel_id, permissions={"subscribers_read": True})
300
+ client.users.remove_channel_permissions(user["id"], channel_id)
301
+
302
+ # Same rule, applied to several channels at once
303
+ client.users.bulk_channel_permissions(user["id"], [channel_id, other_channel_id], role="Viewer")
304
+
305
+ client.users.system_permissions(user["id"])
306
+ client.users.update_system_permissions(user["id"], {"user_management": True})
307
+ ```
308
+
271
309
  ### Autopilot
272
310
 
273
311
  ```python
@@ -370,7 +408,9 @@ every rejection rather than distinguishing them.
370
408
  Pass the **raw** request body. Re-serialising a parsed dict changes the bytes
371
409
  and verification will fail.
372
410
 
373
- `broadcast_python.EVENT_TYPES` lists all 32 event names.
411
+ `broadcast_python.EVENT_TYPES` lists all 34 event names. `SUBSCRIBER_EVENTS`
412
+ includes `subscribers.purged` and `subscribers.purge_failed`: a purge of the
413
+ whole list sends one of these instead of a `subscriber.deleted` per subscriber.
374
414
 
375
415
  ---
376
416
 
@@ -386,11 +426,12 @@ integration requires.
386
426
  | Sequences | `sequences_read` -- list, get, list steps | `sequences_write` -- create, update, delete, manage steps, enroll subscribers |
387
427
  | Broadcasts | `broadcasts_read` -- list, get, statistics | `broadcasts_write` -- create, update, delete, send, schedule |
388
428
  | Segments | `segments_read` -- list, get | `segments_write` -- create, update, delete |
389
- | Templates | `templates_read` -- list, get | `templates_write` -- create, update, delete |
429
+ | Templates | `templates_read` -- list, get, channel_design.get | `templates_write` -- create, update, delete |
390
430
  | Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
391
431
  | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
392
432
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
393
433
  | Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
434
+ | Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
394
435
 
395
436
  ---
396
437
 
@@ -2,20 +2,12 @@
2
2
 
3
3
  Official Python client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
4
4
 
5
- Works with any Broadcast instance — self-hosted or SaaS. Covers **104/104 API operations**, verified against the API's generated OpenAPI document.
5
+ Works with any Broadcast instance — self-hosted or SaaS. Covers **117/117 API operations**, verified against the API's generated OpenAPI document.
6
6
 
7
7
  📖 **[Python SDK documentation](https://sendbroadcast.net/docs/python-sdk)** · [API reference](https://sendbroadcast.net/docs/api-authentication) · [All docs](https://sendbroadcast.net/docs)
8
8
 
9
9
  Also available: [Ruby](https://github.com/send-broadcast/broadcast-ruby) · [PHP](https://github.com/send-broadcast/broadcast-php) · [Node/TypeScript](https://github.com/send-broadcast/broadcast-node)
10
10
 
11
- > **Not yet on PyPI.** The package is complete and tested but unpublished, so
12
- > `pip install broadcast-python` will not resolve. Install from the repository
13
- > until it lands:
14
- >
15
- > ```bash
16
- > pip install git+https://github.com/send-broadcast/broadcast-python
17
- > ```
18
-
19
11
  ## Installation
20
12
 
21
13
  ```bash
@@ -230,6 +222,19 @@ client.opt_in_forms.duplicate(id, label="Copy")
230
222
 
231
223
  Reading a segment recounts its members server-side, so `segments.get` is not free.
232
224
 
225
+ ### Channel design (brand kit)
226
+
227
+ ```python
228
+ kit = client.channel_design.get()
229
+ kit["colors"]["accent"] # "#2563eb"
230
+ kit["typography"]["font_stack"] # email-safe CSS font stack
231
+ kit["brand"]["logo_url"] # public URL, or None
232
+ ```
233
+
234
+ Read-only, for the token's own channel, fully resolved (defaults filled in).
235
+ Needs `templates_read`. Block emails built with the drag-and-drop editor inherit
236
+ these values.
237
+
233
238
  ### Email servers
234
239
 
235
240
  **Credential redaction guard.** The API returns credentials bullet-masked
@@ -244,6 +249,39 @@ client.email_servers.update(id, name="Renamed", smtp_password=server["smtp_passw
244
249
  # -> sends only {"name": "Renamed"}, warns about the dropped field
245
250
  ```
246
251
 
252
+ ### Users
253
+
254
+ Requires an **admin API token**. Sudo users are read-only through this API —
255
+ update, deactivate, activate, delete, and permission writes on one raise
256
+ `AuthorizationError`. Sudo access itself can never be granted through the API.
257
+
258
+ ```python
259
+ client.users.list(status="active", q="ada")
260
+ user = client.users.create(email="ada@example.com", first_name="Ada", last_name="L", password="x")
261
+ client.users.update(user["id"], first_name="Grace")
262
+ client.users.deactivate(user["id"])
263
+ client.users.activate(user["id"])
264
+ client.users.delete(user["id"])
265
+ ```
266
+
267
+ Channel and system permissions:
268
+
269
+ ```python
270
+ client.users.channel_permissions(user["id"])
271
+
272
+ # PUT replaces the whole channel record -- pass exactly one of
273
+ # permissions, role, or preset_id
274
+ client.users.set_channel_permissions(user["id"], channel_id, role="Editor")
275
+ client.users.set_channel_permissions(user["id"], channel_id, permissions={"subscribers_read": True})
276
+ client.users.remove_channel_permissions(user["id"], channel_id)
277
+
278
+ # Same rule, applied to several channels at once
279
+ client.users.bulk_channel_permissions(user["id"], [channel_id, other_channel_id], role="Viewer")
280
+
281
+ client.users.system_permissions(user["id"])
282
+ client.users.update_system_permissions(user["id"], {"user_management": True})
283
+ ```
284
+
247
285
  ### Autopilot
248
286
 
249
287
  ```python
@@ -346,7 +384,9 @@ every rejection rather than distinguishing them.
346
384
  Pass the **raw** request body. Re-serialising a parsed dict changes the bytes
347
385
  and verification will fail.
348
386
 
349
- `broadcast_python.EVENT_TYPES` lists all 32 event names.
387
+ `broadcast_python.EVENT_TYPES` lists all 34 event names. `SUBSCRIBER_EVENTS`
388
+ includes `subscribers.purged` and `subscribers.purge_failed`: a purge of the
389
+ whole list sends one of these instead of a `subscriber.deleted` per subscriber.
350
390
 
351
391
  ---
352
392
 
@@ -362,11 +402,12 @@ integration requires.
362
402
  | Sequences | `sequences_read` -- list, get, list steps | `sequences_write` -- create, update, delete, manage steps, enroll subscribers |
363
403
  | Broadcasts | `broadcasts_read` -- list, get, statistics | `broadcasts_write` -- create, update, delete, send, schedule |
364
404
  | Segments | `segments_read` -- list, get | `segments_write` -- create, update, delete |
365
- | Templates | `templates_read` -- list, get | `templates_write` -- create, update, delete |
405
+ | Templates | `templates_read` -- list, get, channel_design.get | `templates_write` -- create, update, delete |
366
406
  | Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
367
407
  | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
368
408
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
369
409
  | Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
410
+ | Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
370
411
 
371
412
  ---
372
413
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "broadcast-python"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Python client for the Broadcast email platform. Subscribers, sequences, broadcasts, segments, templates, autopilot, webhooks, and transactional email."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -5,15 +5,19 @@ from .configuration import Configuration
5
5
  from .connection import Connection
6
6
  from .resources.autopilots import Autopilots
7
7
  from .resources.broadcasts import Broadcasts
8
+ from .resources.channel_design import ChannelDesign
8
9
  from .resources.discovery import Discovery
9
10
  from .resources.email_servers import EmailServers
11
+ from .resources.global_suppressions import GlobalSuppressions
10
12
  from .resources.migration import Migration
11
13
  from .resources.opt_in_forms import OptInForms
12
14
  from .resources.segments import Segments
13
15
  from .resources.sequences import Sequences
14
16
  from .resources.subscribers import Subscribers
17
+ from .resources.suppressions import Suppressions
15
18
  from .resources.templates import Templates
16
19
  from .resources.transactionals import Transactionals
20
+ from .resources.users import Users
17
21
  from .resources.webhook_endpoints import WebhookEndpoints
18
22
 
19
23
  Id = Union[str, int]
@@ -37,14 +41,25 @@ class Broadcast:
37
41
  self.broadcasts = Broadcasts(self)
38
42
  self.segments = Segments(self)
39
43
  self.templates = Templates(self)
44
+ #: The channel's brand kit (Settings -> Design). Read-only.
45
+ self.channel_design = ChannelDesign(self)
40
46
  self.webhook_endpoints = WebhookEndpoints(self)
41
47
  self.transactionals = Transactionals(self)
42
48
  self.opt_in_forms = OptInForms(self)
43
49
  self.email_servers = EmailServers(self)
44
50
  self.autopilots = Autopilots(self)
45
51
  self.discovery = Discovery(self)
52
+ #: The current channel's suppression list (plus ``check``, which
53
+ #: reads the global list too).
54
+ self.suppressions = Suppressions(self)
55
+ #: The installation-wide suppression list. Requires an admin (system)
56
+ #: API token.
57
+ self.global_suppressions = GlobalSuppressions(self)
46
58
  #: Read-only export endpoints. Requires an admin (system) API token.
47
59
  self.migration = Migration(self)
60
+ #: Installation users and their permissions. Requires an admin
61
+ #: (system) API token; sudo users are read-only through this API.
62
+ self.users = Users(self)
48
63
 
49
64
  # --- Channel scoping (admin/system tokens) ---
50
65
 
@@ -97,6 +112,9 @@ class Broadcast:
97
112
  def skill(self) -> str:
98
113
  return self.discovery.skill()
99
114
 
115
+ def openapi(self) -> str:
116
+ return self.discovery.openapi()
117
+
100
118
  # --- Internal ---
101
119
 
102
120
  def request(
@@ -25,6 +25,9 @@ class BaseResource:
25
25
  def _patch(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
26
26
  return self._client.request("PATCH", path, body or {})
27
27
 
28
+ def _put(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
29
+ return self._client.request("PUT", path, body or {})
30
+
28
31
  def _delete(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
29
32
  return self._client.request("DELETE", path, body)
30
33
 
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+ from .base import BaseResource
4
+
5
+
6
+ class ChannelDesign(BaseResource):
7
+ """The brand kit of the token's channel (Settings -> Design). Read-only.
8
+
9
+ Requires the ``templates_read`` permission: the kit is design metadata for
10
+ templates. There is no channel argument; the server reads the channel the
11
+ token resolves to (for an admin token, the one set with ``with_channel`` or
12
+ ``broadcast_channel_id``).
13
+ """
14
+
15
+ def get(self) -> Any:
16
+ """The channel's brand kit, fully resolved (defaults filled in).
17
+
18
+ Returns ``colors``, ``typography`` (``font`` key and the email-safe
19
+ ``font_stack``), ``layout`` (``width``, ``radius``), and ``brand``
20
+ (``logo_url`` as a public URL or None, ``logo_width``, ``website_url``,
21
+ ``social_links``, ``social_icon_style``). Block emails built with the
22
+ drag-and-drop editor inherit these values.
23
+ """
24
+ return self._get("/api/v1/channel/design")
@@ -33,3 +33,17 @@ class Discovery(BaseResource):
33
33
  Returns a ``str``, not a dict — this endpoint serves ``text/plain``.
34
34
  """
35
35
  return self._get("/api/v1/skill", raw=True)
36
+
37
+ def openapi(self) -> str:
38
+ """This installation's own OpenAPI document, as YAML.
39
+
40
+ Returns a ``str``, not a dict — this endpoint serves
41
+ ``application/yaml``.
42
+
43
+ The server URL inside the document is rewritten by the installation to
44
+ the host that served it, so the result feeds a client generator or an
45
+ API explorer without hand-editing. Preferable to a spec copied from
46
+ elsewhere: a 2.28 install serves the 2.28 surface, so the document
47
+ cannot drift from the routes it describes.
48
+ """
49
+ return self._get("/api/v1/openapi", raw=True)
@@ -0,0 +1,40 @@
1
+ """The installation-wide suppression list.
2
+
3
+ Addresses on it never receive mail from any channel. All operations require an
4
+ admin (system) API token — a channel token gets a 401.
5
+
6
+ There is deliberately no ``check`` here: checking is a per-channel question
7
+ (it reads the channel list too), so it lives on ``suppressions``.
8
+ """
9
+
10
+ from typing import Any, List
11
+
12
+ from .base import BaseResource
13
+
14
+
15
+ class GlobalSuppressions(BaseResource):
16
+ def list(self, **params: Any) -> Any:
17
+ """List global suppressions (250 per page, with ``pagination``
18
+ metadata; pass ``page``). Optional ``email`` filters by partial
19
+ match."""
20
+ return self._get("/api/v1/global_suppressions.json", params)
21
+
22
+ def add(self, email: str) -> Any:
23
+ """Add an address to the global list. Already-suppressed is a success
24
+ (200 instead of 201)."""
25
+ return self._post("/api/v1/global_suppressions.json", {"email": email})
26
+
27
+ def remove(self, email: str) -> Any:
28
+ """Remove an address from the global list only. Channels that
29
+ suppressed the same address on their own account keep their block."""
30
+ return self._delete("/api/v1/global_suppressions.json", {"email": email})
31
+
32
+ def bulk_add(self, emails: List[str]) -> Any:
33
+ """Add up to 10,000 addresses at once. Idempotent. Returns ``added``,
34
+ ``already_suppressed``, and ``invalid`` counts."""
35
+ return self._post("/api/v1/global_suppressions/bulk.json", {"emails": emails})
36
+
37
+ def bulk_remove(self, emails: List[str]) -> Any:
38
+ """Remove up to 10,000 addresses at once. Returns ``removed`` and
39
+ ``not_found`` counts."""
40
+ return self._delete("/api/v1/global_suppressions/bulk.json", {"emails": emails})
@@ -0,0 +1,52 @@
1
+ """The current channel's suppression list.
2
+
3
+ Addresses on it never receive broadcasts, sequences, or transactionals from
4
+ this channel. The installation-wide list is a separate resource — see
5
+ ``global_suppressions`` — but ``check`` reads across both on purpose: it
6
+ answers the question an integration actually asks, "will this address receive
7
+ mail?".
8
+ """
9
+
10
+ from typing import Any, List
11
+
12
+ from .base import BaseResource
13
+
14
+
15
+ class Suppressions(BaseResource):
16
+ def list(self, **params: Any) -> Any:
17
+ """List the channel's suppressions (250 per page, with ``pagination``
18
+ metadata; pass ``page``). Optional ``email`` filters by partial,
19
+ case-insensitive match."""
20
+ return self._get("/api/v1/suppressions.json", params)
21
+
22
+ def add(self, email: str) -> Any:
23
+ """Add an address to the channel's suppression list.
24
+
25
+ Adding an address that is already suppressed is a success (the server
26
+ answers 200 instead of 201), so callers do not have to check first.
27
+ """
28
+ return self._post("/api/v1/suppressions.json", {"email": email})
29
+
30
+ def remove(self, email: str) -> Any:
31
+ """Remove an address from the channel's suppression list. Returns
32
+ ``removed: False`` (not an error) when the address was not on it.
33
+ Does not touch the global list."""
34
+ return self._delete("/api/v1/suppressions.json", {"email": email})
35
+
36
+ def bulk_add(self, emails: List[str]) -> Any:
37
+ """Add up to 10,000 addresses at once. Idempotent: a retried batch
38
+ cannot duplicate. Returns ``added``, ``already_suppressed``, and
39
+ ``invalid`` counts."""
40
+ return self._post("/api/v1/suppressions/bulk.json", {"emails": emails})
41
+
42
+ def bulk_remove(self, emails: List[str]) -> Any:
43
+ """Remove up to 10,000 addresses at once. Returns ``removed`` and
44
+ ``not_found`` counts."""
45
+ return self._delete("/api/v1/suppressions/bulk.json", {"emails": emails})
46
+
47
+ def check(self, email: str) -> Any:
48
+ """Will this address receive mail? Reads across both the global and
49
+ the channel list — a globally blocked address reports
50
+ ``suppressed: True`` here even though it is absent from the channel's
51
+ own list. The response's ``scope`` says which list matched."""
52
+ return self._get("/api/v1/suppressions/check.json", {"email": email})
@@ -0,0 +1,115 @@
1
+ from typing import Any, Dict, List, Optional, Union
2
+
3
+ from .base import BaseResource, compact
4
+
5
+ Id = Union[str, int]
6
+
7
+
8
+ class Users(BaseResource):
9
+ """Manage installation users and their permissions.
10
+
11
+ Every operation here requires an **admin API token** — a channel token
12
+ gets ``403 {"error": "Admin API token required for user management"}``.
13
+ Reads need the ``users_read`` scope, writes need ``users_write``.
14
+
15
+ Sudo users are **read-only** through this API: update, deactivate,
16
+ activate, delete, and permission writes on a sudo user all raise
17
+ ``AuthorizationError`` (403). Sudo access itself can never be granted
18
+ through the API, whether creating or updating a user or writing system
19
+ permissions.
20
+ """
21
+
22
+ def list(
23
+ self,
24
+ limit: Optional[int] = None,
25
+ offset: Optional[int] = None,
26
+ q: Optional[str] = None,
27
+ status: Optional[str] = None,
28
+ ) -> Any:
29
+ """List users. ``q`` searches email/name, ``status`` is ``"active"``
30
+ or ``"inactive"``."""
31
+ params = compact({"limit": limit, "offset": offset, "q": q, "status": status})
32
+ return self._get("/api/v1/users", params)
33
+
34
+ def get(self, id: Id) -> Any: # noqa: A002
35
+ return self._get("/api/v1/users/{}".format(id))
36
+
37
+ def create(self, **attrs: Any) -> Any:
38
+ """Create a user. Requires either ``password`` or
39
+ ``send_password_reset=True``."""
40
+ return self._post("/api/v1/users", {"user": attrs})
41
+
42
+ def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
43
+ """Update a user. Raises ``AuthorizationError`` if the user is sudo."""
44
+ return self._patch("/api/v1/users/{}".format(id), {"user": attrs})
45
+
46
+ def deactivate(self, id: Id) -> Any: # noqa: A002
47
+ return self._post("/api/v1/users/{}/deactivate".format(id))
48
+
49
+ def activate(self, id: Id) -> Any: # noqa: A002
50
+ """Reactivate a user. Also clears any account lockout."""
51
+ return self._post("/api/v1/users/{}/activate".format(id))
52
+
53
+ def delete(self, id: Id) -> Any: # noqa: A002
54
+ return self._delete("/api/v1/users/{}".format(id))
55
+
56
+ def channel_permissions(self, id: Id) -> Any: # noqa: A002
57
+ return self._get("/api/v1/users/{}/channel_permissions".format(id))
58
+
59
+ def set_channel_permissions(
60
+ self,
61
+ id: Id, # noqa: A002
62
+ broadcast_channel_id: Id,
63
+ permissions: Optional[Dict[str, bool]] = None,
64
+ role: Optional[str] = None,
65
+ preset_id: Optional[Id] = None,
66
+ ) -> Any:
67
+ """Replace the user's whole permission record for one channel.
68
+
69
+ This is a PUT: it replaces the record rather than merging into it, so
70
+ passing ``permissions`` sets any flag not named to ``False``. Pass
71
+ exactly one of ``permissions``, ``role``, or ``preset_id``.
72
+ """
73
+ body = _one_of(permissions=permissions, role=role, preset_id=preset_id)
74
+ return self._put("/api/v1/users/{}/channel_permissions/{}".format(id, broadcast_channel_id), body)
75
+
76
+ def remove_channel_permissions(self, id: Id, broadcast_channel_id: Id) -> Any: # noqa: A002
77
+ return self._delete("/api/v1/users/{}/channel_permissions/{}".format(id, broadcast_channel_id))
78
+
79
+ def bulk_channel_permissions(
80
+ self,
81
+ id: Id, # noqa: A002
82
+ broadcast_channel_ids: List[Id],
83
+ permissions: Optional[Dict[str, bool]] = None,
84
+ role: Optional[str] = None,
85
+ preset_id: Optional[Id] = None,
86
+ ) -> Any:
87
+ """Apply the same permission record to several channels at once.
88
+
89
+ Pass exactly one of ``permissions``, ``role``, or ``preset_id``.
90
+ Returns ``{"applied": [...], "failed": [...]}`` — a failure on one
91
+ channel does not roll back the others.
92
+ """
93
+ body = _one_of(permissions=permissions, role=role, preset_id=preset_id)
94
+ body["broadcast_channel_ids"] = broadcast_channel_ids
95
+ return self._post("/api/v1/users/{}/channel_permissions/bulk".format(id), body)
96
+
97
+ def system_permissions(self, id: Id) -> Any: # noqa: A002
98
+ return self._get("/api/v1/users/{}/system_permissions".format(id))
99
+
100
+ def update_system_permissions(self, id: Id, permissions: Dict[str, bool]) -> Any: # noqa: A002
101
+ """Update only the named system permission flags.
102
+
103
+ ``sudo_access`` is never present and sending it raises a 422 —
104
+ sudo can never be granted through the API.
105
+ """
106
+ return self._patch("/api/v1/users/{}/system_permissions".format(id), {"permissions": permissions})
107
+
108
+
109
+ def _one_of(**kwargs: Any) -> Dict[str, Any]:
110
+ given = {k: v for k, v in kwargs.items() if v is not None}
111
+ if len(given) != 1:
112
+ raise ValueError(
113
+ "Pass exactly one of {} (got {})".format(", ".join(kwargs.keys()), len(given))
114
+ )
115
+ return given
@@ -1,3 +1,3 @@
1
1
  # Kept in sync with pyproject.toml by tests/test_package.py — a User-Agent that
2
2
  # lies about its version misattributes server-side client analytics.
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
@@ -30,6 +30,9 @@ SUBSCRIBER_EVENTS = (
30
30
  "subscriber.unsubscribed",
31
31
  "subscriber.bounced",
32
32
  "subscriber.complained",
33
+ # One event for a whole-list purge, in place of a subscriber.deleted per row
34
+ "subscribers.purged",
35
+ "subscribers.purge_failed",
33
36
  )
34
37
 
35
38
  BROADCAST_EVENTS = (