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.
@@ -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
@@ -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