sendly-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.
- sendly/__init__.py +62 -0
- sendly/client.py +252 -0
- sendly/errors.py +92 -0
- sendly/py.typed +0 -0
- sendly/resources/__init__.py +1 -0
- sendly/resources/_helpers.py +17 -0
- sendly/resources/contacts.py +93 -0
- sendly/resources/domains.py +67 -0
- sendly/resources/emails.py +73 -0
- sendly/resources/events.py +26 -0
- sendly/resources/suppression.py +52 -0
- sendly/resources/templates.py +61 -0
- sendly/resources/verify.py +26 -0
- sendly/resources/webhooks.py +76 -0
- sendly/types.py +84 -0
- sendly/webhook_utils.py +127 -0
- sendly_python-0.1.0.dist-info/METADATA +311 -0
- sendly_python-0.1.0.dist-info/RECORD +20 -0
- sendly_python-0.1.0.dist-info/WHEEL +4 -0
- sendly_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Suppression resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sendly.client import Sendly
|
|
11
|
+
from sendly.types import (
|
|
12
|
+
Body,
|
|
13
|
+
Query,
|
|
14
|
+
SuppressionCheckResponse,
|
|
15
|
+
SuppressionListResponse,
|
|
16
|
+
SuppressionRecord,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SuppressionResource:
|
|
21
|
+
"""Manage the project suppression list."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, client: Sendly) -> None:
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
def add(self, body: Body) -> SuppressionRecord:
|
|
27
|
+
"""Add an email to the project suppression list."""
|
|
28
|
+
envelope = self._client.request(method="POST", path="/api/suppression", body=body)
|
|
29
|
+
record: SuppressionRecord = self._client.unwrap(envelope)
|
|
30
|
+
return record
|
|
31
|
+
|
|
32
|
+
def list(self, query: Query | None = None) -> SuppressionListResponse:
|
|
33
|
+
"""List suppressions with optional reason filter + cursor pagination."""
|
|
34
|
+
response: SuppressionListResponse = self._client.request(
|
|
35
|
+
method="GET", path="/api/suppression", query=query
|
|
36
|
+
)
|
|
37
|
+
return response
|
|
38
|
+
|
|
39
|
+
def get(self, email: str) -> SuppressionCheckResponse:
|
|
40
|
+
"""Check whether a given email is suppressed."""
|
|
41
|
+
response: SuppressionCheckResponse = self._client.request(
|
|
42
|
+
method="GET", path=f"/api/suppression/{encode_path_segment(email)}"
|
|
43
|
+
)
|
|
44
|
+
return response
|
|
45
|
+
|
|
46
|
+
def remove(self, email: str) -> None:
|
|
47
|
+
"""Remove an email from the suppression list. Returns 204."""
|
|
48
|
+
self._client.request(
|
|
49
|
+
method="DELETE",
|
|
50
|
+
path=f"/api/suppression/{encode_path_segment(email)}",
|
|
51
|
+
no_content=True,
|
|
52
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Templates resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sendly.client import Sendly
|
|
11
|
+
from sendly.types import (
|
|
12
|
+
Body,
|
|
13
|
+
Query,
|
|
14
|
+
TemplateListResponse,
|
|
15
|
+
TemplateRecord,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TemplatesResource:
|
|
20
|
+
"""Create and manage reusable email templates."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: Sendly) -> None:
|
|
23
|
+
self._client = client
|
|
24
|
+
|
|
25
|
+
def create(self, body: Body) -> TemplateRecord:
|
|
26
|
+
"""Create a reusable email template."""
|
|
27
|
+
envelope = self._client.request(method="POST", path="/api/templates", body=body)
|
|
28
|
+
record: TemplateRecord = self._client.unwrap(envelope)
|
|
29
|
+
return record
|
|
30
|
+
|
|
31
|
+
def list(self, query: Query | None = None) -> TemplateListResponse:
|
|
32
|
+
"""List templates with cursor pagination (``limit``/``cursor``) + optional
|
|
33
|
+
type filter."""
|
|
34
|
+
response: TemplateListResponse = self._client.request(
|
|
35
|
+
method="GET", path="/api/templates", query=query
|
|
36
|
+
)
|
|
37
|
+
return response
|
|
38
|
+
|
|
39
|
+
def get(self, id: str) -> TemplateRecord:
|
|
40
|
+
"""Fetch a single template by id."""
|
|
41
|
+
envelope = self._client.request(
|
|
42
|
+
method="GET", path=f"/api/templates/{encode_path_segment(id)}"
|
|
43
|
+
)
|
|
44
|
+
record: TemplateRecord = self._client.unwrap(envelope)
|
|
45
|
+
return record
|
|
46
|
+
|
|
47
|
+
def update(self, id: str, body: Body) -> TemplateRecord:
|
|
48
|
+
"""Patch an existing template."""
|
|
49
|
+
envelope = self._client.request(
|
|
50
|
+
method="PATCH", path=f"/api/templates/{encode_path_segment(id)}", body=body
|
|
51
|
+
)
|
|
52
|
+
record: TemplateRecord = self._client.unwrap(envelope)
|
|
53
|
+
return record
|
|
54
|
+
|
|
55
|
+
def delete(self, id: str) -> None:
|
|
56
|
+
"""Delete a template. Returns ``None`` (the API responds 200 with the
|
|
57
|
+
deleted template's id); raises :class:`SendlyConflictError` if the
|
|
58
|
+
template is still referenced."""
|
|
59
|
+
self._client.request(
|
|
60
|
+
method="DELETE", path=f"/api/templates/{encode_path_segment(id)}", no_content=True
|
|
61
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Verify resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from sendly.client import Sendly
|
|
9
|
+
from sendly.types import Body, VerifyEmailData
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VerifyResource:
|
|
13
|
+
"""Validate email addresses (syntax, MX, disposable, plus-addressing)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: Sendly) -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
def email(self, body: Body) -> VerifyEmailData:
|
|
19
|
+
"""Validate a single email address.
|
|
20
|
+
|
|
21
|
+
The endpoint is open (no auth required server-side); the SDK still sends
|
|
22
|
+
its usual ``Authorization`` header, which the API harmlessly ignores.
|
|
23
|
+
"""
|
|
24
|
+
envelope = self._client.request(method="POST", path="/api/verify", body=body)
|
|
25
|
+
data: VerifyEmailData = self._client.unwrap(envelope)
|
|
26
|
+
return data
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Webhooks resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sendly.resources._helpers import encode_path_segment
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sendly.client import Sendly
|
|
11
|
+
from sendly.types import (
|
|
12
|
+
Body,
|
|
13
|
+
Query,
|
|
14
|
+
WebhookCallsListResponse,
|
|
15
|
+
WebhookCreateResponse,
|
|
16
|
+
WebhookGetResponse,
|
|
17
|
+
WebhookListResponse,
|
|
18
|
+
WebhookRecord,
|
|
19
|
+
WebhookRotateSecretResponse,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WebhooksResource:
|
|
24
|
+
"""Manage outbound webhook subscriptions and inspect deliveries."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: Sendly) -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def create(self, body: Body) -> WebhookCreateResponse:
|
|
30
|
+
"""Create a new outbound webhook subscription.
|
|
31
|
+
|
|
32
|
+
The response includes the signing secret — store it now, it is only
|
|
33
|
+
returned in full at creation and rotation time.
|
|
34
|
+
"""
|
|
35
|
+
response: WebhookCreateResponse = self._client.request(
|
|
36
|
+
method="POST", path="/api/webhooks", body=body
|
|
37
|
+
)
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
def list(self) -> WebhookListResponse:
|
|
41
|
+
"""List all webhooks for the project."""
|
|
42
|
+
response: WebhookListResponse = self._client.request(method="GET", path="/api/webhooks")
|
|
43
|
+
return response
|
|
44
|
+
|
|
45
|
+
def get(self, id: str) -> WebhookGetResponse:
|
|
46
|
+
"""Fetch a single webhook (without its signing secret)."""
|
|
47
|
+
response: WebhookGetResponse = self._client.request(
|
|
48
|
+
method="GET", path=f"/api/webhooks/{encode_path_segment(id)}"
|
|
49
|
+
)
|
|
50
|
+
return response
|
|
51
|
+
|
|
52
|
+
def update(self, id: str, body: Body) -> WebhookRecord:
|
|
53
|
+
"""Patch a webhook (URL, event types, active flag)."""
|
|
54
|
+
envelope = self._client.request(
|
|
55
|
+
method="PATCH", path=f"/api/webhooks/{encode_path_segment(id)}", body=body
|
|
56
|
+
)
|
|
57
|
+
record: WebhookRecord = self._client.unwrap(envelope)
|
|
58
|
+
return record
|
|
59
|
+
|
|
60
|
+
def delete(self, id: str) -> None:
|
|
61
|
+
"""Delete a webhook."""
|
|
62
|
+
self._client.request(method="DELETE", path=f"/api/webhooks/{encode_path_segment(id)}")
|
|
63
|
+
|
|
64
|
+
def rotate_secret(self, id: str) -> WebhookRotateSecretResponse:
|
|
65
|
+
"""Rotate the webhook signing secret. The response contains the new secret."""
|
|
66
|
+
response: WebhookRotateSecretResponse = self._client.request(
|
|
67
|
+
method="POST", path=f"/api/webhooks/{encode_path_segment(id)}/rotate-secret"
|
|
68
|
+
)
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
def list_calls(self, id: str, query: Query | None = None) -> WebhookCallsListResponse:
|
|
72
|
+
"""List recent delivery attempts for a webhook."""
|
|
73
|
+
response: WebhookCallsListResponse = self._client.request(
|
|
74
|
+
method="GET", path=f"/api/webhooks/{encode_path_segment(id)}/calls", query=query
|
|
75
|
+
)
|
|
76
|
+
return response
|
sendly/types.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Type aliases for the Sendly SDK.
|
|
2
|
+
|
|
3
|
+
The Sendly API speaks JSON. The reference TypeScript SDK layers precise
|
|
4
|
+
OpenAPI-generated types over that JSON but performs no runtime validation — the
|
|
5
|
+
request core simply serializes the body and returns the parsed response. This
|
|
6
|
+
Python port keeps the same thin-client contract: request inputs are accepted as
|
|
7
|
+
loose mappings (so any valid API field flows through without the SDK rejecting
|
|
8
|
+
it) and responses are returned as parsed ``dict`` objects.
|
|
9
|
+
|
|
10
|
+
The response aliases below are intentionally ``dict[str, Any]`` but are named to
|
|
11
|
+
mirror the TypeScript SDK's ``types.ts`` exports, so the public surface reads the
|
|
12
|
+
same across both SDKs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# ---------- Generic JSON ----------
|
|
21
|
+
|
|
22
|
+
JSONValue = Any
|
|
23
|
+
JSONDict = dict[str, Any]
|
|
24
|
+
|
|
25
|
+
# ---------- Request inputs (typed loosely, like the TS request core) ----------
|
|
26
|
+
|
|
27
|
+
Body = Mapping[str, Any]
|
|
28
|
+
Query = Mapping[str, Any]
|
|
29
|
+
Headers = Mapping[str, str]
|
|
30
|
+
|
|
31
|
+
# ---------- Generic envelopes ----------
|
|
32
|
+
|
|
33
|
+
SuccessEmpty = JSONDict
|
|
34
|
+
Pagination = JSONDict
|
|
35
|
+
|
|
36
|
+
# ---------- Emails ----------
|
|
37
|
+
|
|
38
|
+
SendEmailData = JSONDict
|
|
39
|
+
SendEmailResponse = JSONDict
|
|
40
|
+
BatchSendResponse = JSONDict
|
|
41
|
+
EmailRecord = JSONDict
|
|
42
|
+
EmailListResponse = JSONDict
|
|
43
|
+
EmailGetResponse = JSONDict
|
|
44
|
+
|
|
45
|
+
# ---------- Contacts ----------
|
|
46
|
+
|
|
47
|
+
ContactRecord = JSONDict
|
|
48
|
+
ContactListResponse = JSONDict
|
|
49
|
+
|
|
50
|
+
# ---------- Domains ----------
|
|
51
|
+
|
|
52
|
+
DomainRecord = JSONDict
|
|
53
|
+
DomainListResponse = JSONDict
|
|
54
|
+
DomainVerificationStatus = JSONDict
|
|
55
|
+
|
|
56
|
+
# ---------- Templates ----------
|
|
57
|
+
|
|
58
|
+
TemplateRecord = JSONDict
|
|
59
|
+
TemplateListResponse = JSONDict
|
|
60
|
+
|
|
61
|
+
# ---------- Webhooks ----------
|
|
62
|
+
|
|
63
|
+
WebhookRecord = JSONDict
|
|
64
|
+
WebhookCreateResponse = JSONDict
|
|
65
|
+
WebhookGetResponse = JSONDict
|
|
66
|
+
WebhookListResponse = JSONDict
|
|
67
|
+
WebhookRotateSecretResponse = JSONDict
|
|
68
|
+
WebhookCallsListResponse = JSONDict
|
|
69
|
+
|
|
70
|
+
# ---------- Suppression ----------
|
|
71
|
+
|
|
72
|
+
SuppressionRecord = JSONDict
|
|
73
|
+
SuppressionListResponse = JSONDict
|
|
74
|
+
SuppressionCheckResponse = JSONDict
|
|
75
|
+
|
|
76
|
+
# ---------- Events ----------
|
|
77
|
+
|
|
78
|
+
TrackEventData = JSONDict
|
|
79
|
+
TrackEventResponse = JSONDict
|
|
80
|
+
|
|
81
|
+
# ---------- Verify ----------
|
|
82
|
+
|
|
83
|
+
VerifyEmailData = JSONDict
|
|
84
|
+
VerifyEmailResponse = JSONDict
|
sendly/webhook_utils.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Sendly webhook signature verification.
|
|
2
|
+
|
|
3
|
+
Ported 1:1 from the reference implementation (``webhook-utils.ts``). Every
|
|
4
|
+
Sendly webhook delivery is signed and carries two headers:
|
|
5
|
+
|
|
6
|
+
* ``X-Sendly-Signature`` — the bare (no ``sha256=`` prefix) lowercase hex
|
|
7
|
+
HMAC-SHA256 of ``f"{timestamp}.{body}"`` using your signing secret.
|
|
8
|
+
* ``X-Sendly-Timestamp`` — the signing time as a **millisecond** Unix epoch
|
|
9
|
+
(decimal string).
|
|
10
|
+
|
|
11
|
+
Verification recomputes the HMAC and, by default, rejects deliveries whose
|
|
12
|
+
timestamp is more than :data:`DEFAULT_TOLERANCE_MS` away from now (replay
|
|
13
|
+
protection). Always pass the RAW request body — do not parse JSON first.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
from sendly import verify_signature, construct_event
|
|
18
|
+
|
|
19
|
+
@app.route("/webhook", methods=["POST"])
|
|
20
|
+
def webhook():
|
|
21
|
+
payload = request.get_data() # raw bytes
|
|
22
|
+
signature = request.headers.get("X-Sendly-Signature", "")
|
|
23
|
+
timestamp = request.headers.get("X-Sendly-Timestamp", "")
|
|
24
|
+
secret = os.environ["SENDLY_WEBHOOK_SECRET"]
|
|
25
|
+
try:
|
|
26
|
+
event = construct_event(payload, signature, timestamp, secret)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return "Invalid signature", 400
|
|
29
|
+
if event["event"] == "email.sent":
|
|
30
|
+
...
|
|
31
|
+
return "", 200
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import hmac
|
|
38
|
+
import json
|
|
39
|
+
import re
|
|
40
|
+
import time
|
|
41
|
+
from typing import Any, Final
|
|
42
|
+
|
|
43
|
+
#: Default replay-protection window: a delivery whose ``X-Sendly-Timestamp`` is
|
|
44
|
+
#: more than this many milliseconds from now is rejected. Pass ``math.inf`` for
|
|
45
|
+
#: ``tolerance_ms`` to disable the freshness check.
|
|
46
|
+
DEFAULT_TOLERANCE_MS: Final = 5 * 60 * 1000
|
|
47
|
+
|
|
48
|
+
_TIMESTAMP_PATTERN: Final = re.compile(r"[0-9]+")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_signature(
|
|
52
|
+
payload: bytes | str,
|
|
53
|
+
signature: str,
|
|
54
|
+
timestamp: str,
|
|
55
|
+
secret: str,
|
|
56
|
+
*,
|
|
57
|
+
tolerance_ms: float = DEFAULT_TOLERANCE_MS,
|
|
58
|
+
) -> bool:
|
|
59
|
+
"""Return ``True`` if *signature* is valid and *timestamp* is fresh.
|
|
60
|
+
|
|
61
|
+
Verification order: reject a non-numeric timestamp, then reject a timestamp
|
|
62
|
+
outside *tolerance_ms* of now, then constant-time compare the recomputed
|
|
63
|
+
HMAC. A length mismatch (e.g. a legacy ``sha256=``-prefixed value) compares
|
|
64
|
+
as ``False``, as does any non-ASCII value — this function returns ``bool``
|
|
65
|
+
for every str input and never raises on malformed headers.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
payload: Raw request body (``bytes`` or ``str``). Do NOT parse JSON
|
|
69
|
+
first — pass the body exactly as received.
|
|
70
|
+
signature: Value of the ``X-Sendly-Signature`` header (bare hex).
|
|
71
|
+
timestamp: Value of the ``X-Sendly-Timestamp`` header (ms Unix epoch).
|
|
72
|
+
secret: Webhook signing secret from your Sendly dashboard.
|
|
73
|
+
tolerance_ms: Maximum allowed difference, in milliseconds, between the
|
|
74
|
+
timestamp and now. Defaults to :data:`DEFAULT_TOLERANCE_MS`. Pass
|
|
75
|
+
``math.inf`` to disable the freshness check.
|
|
76
|
+
"""
|
|
77
|
+
if _TIMESTAMP_PATTERN.fullmatch(timestamp) is None:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
now_ms = int(time.time() * 1000)
|
|
81
|
+
if abs(now_ms - int(timestamp)) > tolerance_ms:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
85
|
+
expected = hmac.new(
|
|
86
|
+
secret.encode(),
|
|
87
|
+
f"{timestamp}.{body}".encode(),
|
|
88
|
+
hashlib.sha256,
|
|
89
|
+
).hexdigest()
|
|
90
|
+
# Compare as bytes: `hmac.compare_digest` on *str* raises TypeError for any
|
|
91
|
+
# non-ASCII character, and the signature is attacker-controlled header data.
|
|
92
|
+
expected_bytes = expected.encode()
|
|
93
|
+
try:
|
|
94
|
+
signature_bytes = signature.encode()
|
|
95
|
+
except UnicodeEncodeError: # lone surrogates can never be a valid hex digest
|
|
96
|
+
return False
|
|
97
|
+
return hmac.compare_digest(signature_bytes, expected_bytes)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def construct_event(
|
|
101
|
+
payload: bytes | str,
|
|
102
|
+
signature: str,
|
|
103
|
+
timestamp: str,
|
|
104
|
+
secret: str,
|
|
105
|
+
*,
|
|
106
|
+
tolerance_ms: float = DEFAULT_TOLERANCE_MS,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Verify the signature + timestamp, then parse *payload* as JSON.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
payload: Raw request body (``bytes`` or ``str``). Do NOT parse JSON first.
|
|
112
|
+
signature: Value of the ``X-Sendly-Signature`` header.
|
|
113
|
+
timestamp: Value of the ``X-Sendly-Timestamp`` header (ms Unix epoch).
|
|
114
|
+
secret: Webhook signing secret from your Sendly dashboard.
|
|
115
|
+
tolerance_ms: See :func:`verify_signature`.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The decoded event object.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If the signature or timestamp does not verify.
|
|
122
|
+
"""
|
|
123
|
+
if not verify_signature(payload, signature, timestamp, secret, tolerance_ms=tolerance_ms):
|
|
124
|
+
raise ValueError("Invalid webhook signature")
|
|
125
|
+
text = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
126
|
+
event: dict[str, Any] = json.loads(text)
|
|
127
|
+
return event
|