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.
- broadcast_python/__init__.py +66 -0
- broadcast_python/client.py +130 -0
- broadcast_python/configuration.py +117 -0
- broadcast_python/connection.py +384 -0
- broadcast_python/errors.py +76 -0
- broadcast_python/py.typed +0 -0
- broadcast_python/resources/__init__.py +0 -0
- broadcast_python/resources/autopilots.py +83 -0
- broadcast_python/resources/base.py +44 -0
- broadcast_python/resources/broadcasts.py +44 -0
- broadcast_python/resources/discovery.py +35 -0
- broadcast_python/resources/email_servers.py +68 -0
- broadcast_python/resources/migration.py +113 -0
- broadcast_python/resources/opt_in_forms.py +62 -0
- broadcast_python/resources/segments.py +23 -0
- broadcast_python/resources/sequences.py +58 -0
- broadcast_python/resources/subscribers.py +72 -0
- broadcast_python/resources/templates.py +34 -0
- broadcast_python/resources/transactionals.py +85 -0
- broadcast_python/resources/webhook_endpoints.py +29 -0
- broadcast_python/response.py +160 -0
- broadcast_python/version.py +3 -0
- broadcast_python/webhook.py +121 -0
- broadcast_python-0.1.0.dist-info/METADATA +488 -0
- broadcast_python-0.1.0.dist-info/RECORD +27 -0
- broadcast_python-0.1.0.dist-info/WHEEL +4 -0
- broadcast_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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)
|