broadcast-python 0.1.0__py3-none-any.whl

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,72 @@
1
+ from typing import Any, Dict, List
2
+
3
+ from .base import BaseResource
4
+
5
+
6
+ class Subscribers(BaseResource):
7
+ def list(self, **params: Any) -> Any:
8
+ """List subscribers, 250 per page with ``pagination`` metadata.
9
+
10
+ Filters, all optional and combinable: ``is_active``, ``source``,
11
+ ``created_after``, ``created_before``, ``tags`` (AND logic), ``email``
12
+ (partial, case-insensitive), ``confirmation_status``, ``custom_data``
13
+ (JSONB containment).
14
+
15
+ An unparseable ``created_after``/``created_before`` is *ignored* by the
16
+ server rather than rejected, and comes back as a ``parameter_ignored``
17
+ warning — so a bad timestamp silently widens the result set unless you
18
+ check ``result.warnings``.
19
+ """
20
+ return self._get("/api/v1/subscribers.json", params)
21
+
22
+ def find(self, email: str) -> Any:
23
+ return self._get("/api/v1/subscribers/find.json", {"email": email})
24
+
25
+ def create(self, **attrs: Any) -> Any:
26
+ """Create or upsert a subscriber.
27
+
28
+ Attributes are wrapped under ``subscriber:`` on the wire, except
29
+ ``double_opt_in`` and ``confirmation_template_id``, which the API
30
+ expects at the top level.
31
+
32
+ ``confirmed_at`` is admin-token only — it backdates the confirmation
33
+ timestamp when migrating an already-confirmed list off another provider.
34
+ It is ignored (with a warning) on update.
35
+
36
+ ``unsubscribed_at`` is never settable here; use :meth:`unsubscribe`.
37
+ """
38
+ double_opt_in = attrs.pop("double_opt_in", None)
39
+ confirmation_template_id = attrs.pop("confirmation_template_id", None)
40
+
41
+ payload: Dict[str, Any] = {"subscriber": attrs}
42
+ if double_opt_in is not None:
43
+ payload["double_opt_in"] = double_opt_in
44
+ if confirmation_template_id is not None:
45
+ payload["confirmation_template_id"] = confirmation_template_id
46
+
47
+ return self._post("/api/v1/subscribers.json", payload)
48
+
49
+ def update(self, email: str, **attrs: Any) -> Any:
50
+ return self._patch("/api/v1/subscribers.json", {"email": email, "subscriber": attrs})
51
+
52
+ def add_tags(self, email: str, tags: List[str]) -> Any:
53
+ return self._post("/api/v1/subscribers/add_tag.json", {"email": email, "tags": tags})
54
+
55
+ def remove_tags(self, email: str, tags: List[str]) -> Any:
56
+ return self._delete("/api/v1/subscribers/remove_tag.json", {"email": email, "tags": tags})
57
+
58
+ def activate(self, email: str) -> Any:
59
+ return self._post("/api/v1/subscribers/activate.json", {"email": email})
60
+
61
+ def deactivate(self, email: str) -> Any:
62
+ return self._post("/api/v1/subscribers/deactivate.json", {"email": email})
63
+
64
+ def unsubscribe(self, email: str) -> Any:
65
+ return self._post("/api/v1/subscribers/unsubscribe.json", {"email": email})
66
+
67
+ def resubscribe(self, email: str) -> Any:
68
+ return self._post("/api/v1/subscribers/resubscribe.json", {"email": email})
69
+
70
+ def redact(self, email: str) -> Any:
71
+ """Irreversible: scrubs personal data while keeping aggregate counts."""
72
+ return self._post("/api/v1/subscribers/redact.json", {"email": email})
@@ -0,0 +1,34 @@
1
+ from typing import Any, Union
2
+
3
+ from .base import BaseResource
4
+
5
+ Id = Union[str, int]
6
+
7
+
8
+ class Templates(BaseResource):
9
+ def list(self, **params: Any) -> Any:
10
+ return self._get("/api/v1/templates", params)
11
+
12
+ def get(self, id: Id) -> Any: # noqa: A002
13
+ return self._get("/api/v1/templates/{}".format(id))
14
+
15
+ def create(self, **attrs: Any) -> Any:
16
+ """Create a template. Attributes are wrapped under ``template:``.
17
+
18
+ Content: ``label``, ``subject``, ``preheader``, ``body``, ``html_body``.
19
+
20
+ Confirmation templates (double opt-in): ``template_purpose``,
21
+ ``confirmation_text``, ``default_confirmation``, and
22
+ ``confirmation_page_settings`` — per-state page copy keyed by state,
23
+ each taking ``{"heading": ..., "body": ...}``.
24
+
25
+ Anything the server does not recognise comes back as an
26
+ ``unrecognized_parameter`` warning rather than an error.
27
+ """
28
+ return self._post("/api/v1/templates", {"template": attrs})
29
+
30
+ def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
31
+ return self._patch("/api/v1/templates/{}".format(id), {"template": attrs})
32
+
33
+ def delete(self, id: Id) -> Any: # noqa: A002
34
+ return self._delete("/api/v1/templates/{}".format(id))
@@ -0,0 +1,85 @@
1
+ from typing import Any, Dict, Optional, Union
2
+
3
+ from .base import BaseResource
4
+
5
+ Id = Union[str, int]
6
+
7
+ MAX_IDEMPOTENCY_KEY_LENGTH = 255
8
+
9
+
10
+ class Transactionals(BaseResource):
11
+ def create(
12
+ self,
13
+ to: str,
14
+ subject: Optional[str] = None,
15
+ body: Optional[str] = None,
16
+ reply_to: Optional[str] = None,
17
+ preheader: Optional[str] = None,
18
+ template_id: Optional[Id] = None,
19
+ include_unsubscribe_link: Optional[bool] = None,
20
+ double_opt_in: Optional[Any] = None,
21
+ confirmation_template_id: Optional[Id] = None,
22
+ subscriber: Optional[Dict[str, Any]] = None,
23
+ idempotency_key: Optional[str] = None,
24
+ **extra: Any
25
+ ) -> Any:
26
+ """Send a transactional email.
27
+
28
+ One of ``subject``/``body`` or ``template_id`` is required;
29
+ ``template_id`` resolves subject and body server-side, and
30
+ ``subject``/``body`` override the template.
31
+
32
+ Idempotency
33
+ -----------
34
+ Pass ``idempotency_key`` to make a retry safe. The server stores the
35
+ response for 24 hours keyed on (token, key) and replays it rather than
36
+ sending a second email. Check ``result.idempotent_replay`` to tell a
37
+ replay from a fresh send.
38
+
39
+ The key is part of a fingerprint over method + full path + body:
40
+
41
+ - same key, same payload, still running -> :class:`ConflictError` (409)
42
+ - same key, *different* payload -> :class:`ValidationError` (422)
43
+
44
+ That 422 means "this key was already used for something else", not that
45
+ the email was invalid — do not retry it with the same key.
46
+ """
47
+ payload: Dict[str, Any] = {"to": to}
48
+ for key, value in (
49
+ ("subject", subject),
50
+ ("body", body),
51
+ ("preheader", preheader),
52
+ ("reply_to", reply_to),
53
+ ("template_id", template_id),
54
+ ("include_unsubscribe_link", include_unsubscribe_link),
55
+ ("double_opt_in", double_opt_in),
56
+ ("confirmation_template_id", confirmation_template_id),
57
+ ("subscriber", subscriber),
58
+ ):
59
+ if value is not None:
60
+ payload[key] = value
61
+ payload.update(extra)
62
+
63
+ headers = _idempotency_headers(idempotency_key)
64
+ return self._post("/api/v1/transactionals.json", payload, headers=headers)
65
+
66
+ def get(self, id: Id) -> Any: # noqa: A002
67
+ return self._get("/api/v1/transactionals/{}.json".format(id))
68
+
69
+
70
+ def _idempotency_headers(key: Optional[str]) -> Dict[str, str]:
71
+ if key is None:
72
+ return {}
73
+
74
+ trimmed = str(key).strip()
75
+ if trimmed == "":
76
+ return {}
77
+
78
+ if len(trimmed) > MAX_IDEMPOTENCY_KEY_LENGTH:
79
+ raise ValueError(
80
+ "idempotency_key must be {} characters or fewer (got {})".format(
81
+ MAX_IDEMPOTENCY_KEY_LENGTH, len(trimmed)
82
+ )
83
+ )
84
+
85
+ return {"Idempotency-Key": trimmed}
@@ -0,0 +1,29 @@
1
+ from typing import Any, Union
2
+
3
+ from .base import BaseResource
4
+
5
+ Id = Union[str, int]
6
+
7
+
8
+ class WebhookEndpoints(BaseResource):
9
+ def list(self, **params: Any) -> Any:
10
+ return self._get("/api/v1/webhook_endpoints", params)
11
+
12
+ def get(self, id: Id) -> Any: # noqa: A002
13
+ return self._get("/api/v1/webhook_endpoints/{}".format(id))
14
+
15
+ def create(self, **attrs: Any) -> Any:
16
+ """The ``secret`` is returned once, on create, and never again."""
17
+ return self._post("/api/v1/webhook_endpoints", {"webhook_endpoint": attrs})
18
+
19
+ def update(self, id: Id, **attrs: Any) -> Any: # noqa: A002
20
+ return self._patch("/api/v1/webhook_endpoints/{}".format(id), {"webhook_endpoint": attrs})
21
+
22
+ def delete(self, id: Id) -> Any: # noqa: A002
23
+ return self._delete("/api/v1/webhook_endpoints/{}".format(id))
24
+
25
+ def test(self, id: Id, event_type: str = "test.webhook") -> Any: # noqa: A002
26
+ return self._post("/api/v1/webhook_endpoints/{}/test".format(id), {"event_type": event_type})
27
+
28
+ def deliveries(self, id: Id, **params: Any) -> Any: # noqa: A002
29
+ return self._get("/api/v1/webhook_endpoints/{}/deliveries".format(id), params)
@@ -0,0 +1,160 @@
1
+ """The value returned by every JSON API call.
2
+
3
+ ``Response`` subclasses ``dict`` rather than wrapping it, so everything that
4
+ works against a parsed body still works — ``result["id"]``, ``isinstance(...,
5
+ dict)``, ``**result``, equality against a plain dict. Transport metadata the API
6
+ sends alongside the body is exposed as attributes.
7
+
8
+ Python keeps item access and attribute access separate, so this reproduces the
9
+ Ruby gem's design exactly: ``result["status"]`` is the body's field and
10
+ ``result.status`` is the HTTP status. (The Node client cannot do this — see its
11
+ README.)
12
+
13
+ result = client.subscribers.create(email="a@b.com", foo="bar")
14
+ result["id"] # 42
15
+ result.warnings # [Warning_(code='unrecognized_parameter', ...)]
16
+ result.rate_limit.remaining # 118
17
+ result.status # 201
18
+ """
19
+
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Dict, List, NamedTuple, Optional
22
+
23
+
24
+ class Warning_(NamedTuple):
25
+ """A single entry from the API's ``warnings`` array.
26
+
27
+ The API raises these on successful 2xx responses when it accepted the
28
+ request but ignored part of it — an unrecognised parameter, a parameter that
29
+ only applies in another mode, a value the server overrode.
30
+
31
+ ``param`` is a dot-path to the offending parameter (e.g. ``subscriber.foo``).
32
+ The API never includes submitted values, so a warning is safe to log.
33
+
34
+ Named with a trailing underscore to avoid shadowing the builtin ``Warning``.
35
+ """
36
+
37
+ code: Optional[str]
38
+ param: Optional[str]
39
+ message: Optional[str]
40
+
41
+ def __str__(self) -> str:
42
+ if self.param:
43
+ return "[{}] {}: {}".format(self.code, self.param, self.message)
44
+ return "[{}] {}".format(self.code, self.message)
45
+
46
+
47
+ class RateLimit(NamedTuple):
48
+ """Parsed ``X-RateLimit-*`` headers.
49
+
50
+ ``reset`` is the time the current window rolls over, not a duration.
51
+ """
52
+
53
+ limit: int
54
+ remaining: Optional[int]
55
+ reset: Optional[datetime]
56
+
57
+
58
+ class Response(dict):
59
+ """A parsed JSON object body plus its transport metadata."""
60
+
61
+ __slots__ = ("_rate_limit", "_warnings", "headers", "status")
62
+
63
+ def __init__(self, *args, **kwargs):
64
+ super().__init__(*args, **kwargs)
65
+ self.status: Optional[int] = None
66
+ self.headers: Dict[str, str] = {}
67
+ self._warnings: Optional[List[Warning_]] = None
68
+ self._rate_limit: Any = _UNSET
69
+
70
+ def _attach(self, status: int, headers: Dict[str, str]) -> "Response":
71
+ self.status = status
72
+ # Lower-cased once here so every lookup below is case-insensitive.
73
+ self.headers = {str(k).lower(): v for k, v in (headers or {}).items()}
74
+ return self
75
+
76
+ @property
77
+ def warnings(self) -> List[Warning_]:
78
+ if self._warnings is None:
79
+ entries = self.get("warnings")
80
+ entries = entries if isinstance(entries, list) else []
81
+ self._warnings = [
82
+ Warning_(code=e.get("code"), param=e.get("param"), message=e.get("message"))
83
+ for e in entries
84
+ if isinstance(e, dict)
85
+ ]
86
+ return self._warnings
87
+
88
+ @property
89
+ def has_warnings(self) -> bool:
90
+ return len(self.warnings) > 0
91
+
92
+ @property
93
+ def rate_limit(self) -> Optional[RateLimit]:
94
+ if self._rate_limit is _UNSET:
95
+ # A present-but-unparseable limit means the whole block is
96
+ # untrustworthy, so report no rate limit rather than a RateLimit
97
+ # whose `limit` is None despite being typed int.
98
+ limit = _int_or_none(self.headers.get("x-ratelimit-limit"))
99
+ if limit is None:
100
+ self._rate_limit = None
101
+ else:
102
+ self._rate_limit = RateLimit(
103
+ limit=limit,
104
+ remaining=_int_or_none(self.headers.get("x-ratelimit-remaining")),
105
+ reset=_parse_time(self.headers.get("x-ratelimit-reset")),
106
+ )
107
+ return self._rate_limit
108
+
109
+ @property
110
+ def idempotent_replay(self) -> bool:
111
+ """True when the API replayed a stored response for a repeated
112
+ Idempotency-Key rather than performing the write again."""
113
+ return self.headers.get("idempotency-replayed") == "true"
114
+
115
+
116
+ class _Unset:
117
+ pass
118
+
119
+
120
+ _UNSET = _Unset()
121
+
122
+
123
+ def build_response(parsed: Any, status: int, headers: Dict[str, str]) -> Any:
124
+ """Wrap a parsed JSON body when it is an object; pass anything else through.
125
+
126
+ A bare array body is returned as a plain list and carries no metadata,
127
+ matching the Ruby gem.
128
+ """
129
+ if not isinstance(parsed, dict):
130
+ return parsed
131
+
132
+ return Response(parsed)._attach(status, headers)
133
+
134
+
135
+ def _int_or_none(value: Optional[str]) -> Optional[int]:
136
+ if value is None:
137
+ return None
138
+ try:
139
+ return int(value)
140
+ except (TypeError, ValueError):
141
+ return None
142
+
143
+
144
+ def _parse_time(value: Optional[str]) -> Optional[datetime]:
145
+ if not value:
146
+ return None
147
+ try:
148
+ # fromisoformat did not accept a trailing Z until 3.11.
149
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
150
+ except (TypeError, ValueError):
151
+ pass
152
+ try:
153
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z")
154
+ except (TypeError, ValueError):
155
+ pass
156
+ # Some proxies send a unix timestamp instead of ISO-8601.
157
+ try:
158
+ return datetime.fromtimestamp(int(value), tz=timezone.utc)
159
+ except (TypeError, ValueError, OSError, OverflowError):
160
+ return None
@@ -0,0 +1,3 @@
1
+ # Kept in sync with pyproject.toml by tests/test_package.py — a User-Agent that
2
+ # lies about its version misattributes server-side client analytics.
3
+ VERSION = "0.1.0"
@@ -0,0 +1,121 @@
1
+ """Inbound webhook verification."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import time
7
+ from typing import Optional
8
+
9
+ TIMESTAMP_TOLERANCE = 300 # 5 minutes
10
+
11
+ #: Every event type a webhook endpoint can subscribe to, mirroring
12
+ #: WebhookEndpoint::AVAILABLE_EVENT_TYPES server-side. Use these when creating
13
+ #: an endpoint — an unknown event type is dropped silently.
14
+ EMAIL_EVENTS = (
15
+ "email.sent",
16
+ "email.delivered",
17
+ "email.delivery_delayed",
18
+ "email.complained",
19
+ "email.bounced",
20
+ "email.opened",
21
+ "email.clicked",
22
+ "email.failed",
23
+ )
24
+
25
+ SUBSCRIBER_EVENTS = (
26
+ "subscriber.created",
27
+ "subscriber.updated",
28
+ "subscriber.deleted",
29
+ "subscriber.subscribed",
30
+ "subscriber.unsubscribed",
31
+ "subscriber.bounced",
32
+ "subscriber.complained",
33
+ )
34
+
35
+ BROADCAST_EVENTS = (
36
+ "broadcast.scheduled",
37
+ "broadcast.queueing",
38
+ "broadcast.sending",
39
+ "broadcast.sent",
40
+ "broadcast.failed",
41
+ "broadcast.partial_failure",
42
+ "broadcast.aborted",
43
+ "broadcast.paused",
44
+ )
45
+
46
+ SEQUENCE_EVENTS = (
47
+ "sequence.subscriber_added",
48
+ "sequence.subscriber_completed",
49
+ "sequence.subscriber_moved",
50
+ "sequence.subscriber_removed",
51
+ "sequence.subscriber_paused",
52
+ "sequence.subscriber_resumed",
53
+ "sequence.subscriber_error",
54
+ )
55
+
56
+ #: Delivery-machinery events, not content events.
57
+ SYSTEM_EVENTS = ("message.attempt.exhausted", "test.webhook")
58
+
59
+ EVENT_TYPES = EMAIL_EVENTS + SUBSCRIBER_EVENTS + BROADCAST_EVENTS + SEQUENCE_EVENTS + SYSTEM_EVENTS
60
+
61
+
62
+ def verify(
63
+ payload: str,
64
+ signature_header: Optional[str],
65
+ timestamp_header: Optional[str],
66
+ secret: Optional[str],
67
+ now: Optional[int] = None,
68
+ ) -> bool:
69
+ """Verify an inbound webhook.
70
+
71
+ Returns ``False`` rather than raising for every rejection — a missing
72
+ header, a stale timestamp, a bad signature. A handler should answer 401 for
73
+ all of them identically, and distinguishing them invites leaking which check
74
+ failed.
75
+
76
+ ``payload`` must be the raw request body, exactly as received.
77
+ Re-serialising a parsed object changes the bytes and verification fails.
78
+ """
79
+ if payload is None or signature_header is None or timestamp_header is None or secret is None:
80
+ return False
81
+
82
+ try:
83
+ timestamp = int(timestamp_header)
84
+ except (TypeError, ValueError):
85
+ return False
86
+
87
+ current_time = int(time.time()) if now is None else int(now)
88
+ if not timestamp_valid(timestamp, current_time):
89
+ return False
90
+
91
+ actual = extract_signature(signature_header)
92
+ if actual is None:
93
+ return False
94
+
95
+ return secure_compare(compute_signature(payload, timestamp, secret), actual)
96
+
97
+
98
+ def compute_signature(payload: str, timestamp: int, secret: str) -> str:
99
+ signed_content = "{}.{}".format(timestamp, payload)
100
+ digest = hmac.new(
101
+ secret.encode("utf-8") if isinstance(secret, str) else secret,
102
+ signed_content.encode("utf-8") if isinstance(signed_content, str) else signed_content,
103
+ hashlib.sha256,
104
+ ).digest()
105
+ return base64.b64encode(digest).decode("ascii")
106
+
107
+
108
+ def timestamp_valid(timestamp: int, current_time: Optional[int] = None) -> bool:
109
+ current_time = int(time.time()) if current_time is None else current_time
110
+ return abs(current_time - timestamp) <= TIMESTAMP_TOLERANCE
111
+
112
+
113
+ def extract_signature(header: str) -> Optional[str]:
114
+ if not header.startswith("v1,"):
115
+ return None
116
+ signature = header[len("v1,"):]
117
+ return signature or None
118
+
119
+
120
+ def secure_compare(a: str, b: str) -> bool:
121
+ return hmac.compare_digest(a, b)