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 ADDED
@@ -0,0 +1,62 @@
1
+ """Official Sendly Python SDK.
2
+
3
+ Example:
4
+ >>> from sendly import Sendly
5
+ >>> sendly = Sendly() # reads SENDLY_API_KEY
6
+ >>> sendly.emails.send(
7
+ ... {"from": "a@b.com", "to": "c@d.com", "subject": "hi", "body": "<p>hi</p>"}
8
+ ... )
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from sendly.client import DEFAULT_BASE_URL, SDK_VERSION, Sendly
14
+ from sendly.errors import (
15
+ SendlyAuthenticationError,
16
+ SendlyConflictError,
17
+ SendlyConnectionError,
18
+ SendlyError,
19
+ SendlyNotFoundError,
20
+ SendlyPermissionError,
21
+ SendlyRateLimitError,
22
+ SendlyServerError,
23
+ SendlyValidationError,
24
+ )
25
+ from sendly.resources.contacts import ContactsResource
26
+ from sendly.resources.domains import DomainsResource
27
+ from sendly.resources.emails import EmailsResource
28
+ from sendly.resources.events import EventsResource
29
+ from sendly.resources.suppression import SuppressionResource
30
+ from sendly.resources.templates import TemplatesResource
31
+ from sendly.resources.verify import VerifyResource
32
+ from sendly.resources.webhooks import WebhooksResource
33
+ from sendly.webhook_utils import DEFAULT_TOLERANCE_MS, construct_event, verify_signature
34
+
35
+ __version__ = SDK_VERSION
36
+
37
+ __all__ = [
38
+ "DEFAULT_BASE_URL",
39
+ "DEFAULT_TOLERANCE_MS",
40
+ "SDK_VERSION",
41
+ "ContactsResource",
42
+ "DomainsResource",
43
+ "EmailsResource",
44
+ "EventsResource",
45
+ "Sendly",
46
+ "SendlyAuthenticationError",
47
+ "SendlyConflictError",
48
+ "SendlyConnectionError",
49
+ "SendlyError",
50
+ "SendlyNotFoundError",
51
+ "SendlyPermissionError",
52
+ "SendlyRateLimitError",
53
+ "SendlyServerError",
54
+ "SendlyValidationError",
55
+ "SuppressionResource",
56
+ "TemplatesResource",
57
+ "VerifyResource",
58
+ "WebhooksResource",
59
+ "__version__",
60
+ "construct_event",
61
+ "verify_signature",
62
+ ]
sendly/client.py ADDED
@@ -0,0 +1,252 @@
1
+ """Sendly SDK client — request core and resource wiring.
2
+
3
+ Ported from the reference TypeScript SDK's ``client.ts``. Behavioural parity:
4
+
5
+ * ``Authorization: Bearer <key>``, ``Accept`` and ``User-Agent`` on every request.
6
+ * ``{success, data}`` envelope unwrap (see :meth:`Sendly.unwrap`).
7
+ * Error envelope ``{error: {code, message}}`` mapped to typed exceptions.
8
+ * Query params skip ``None``/empty-string; list values append repeated keys.
9
+ * 204 / No-Content -> ``None``; non-JSON success body -> raw text.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from typing import TYPE_CHECKING, Any, NoReturn
17
+ from urllib.parse import urlencode
18
+
19
+ import httpx
20
+
21
+ from sendly.errors import SendlyConnectionError, SendlyError, error_from_response
22
+ from sendly.resources.contacts import ContactsResource
23
+ from sendly.resources.domains import DomainsResource
24
+ from sendly.resources.emails import EmailsResource
25
+ from sendly.resources.events import EventsResource
26
+ from sendly.resources.suppression import SuppressionResource
27
+ from sendly.resources.templates import TemplatesResource
28
+ from sendly.resources.verify import VerifyResource
29
+ from sendly.resources.webhooks import WebhooksResource
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Mapping
33
+ from types import TracebackType
34
+
35
+ from sendly.types import Body, Query
36
+
37
+ __all__ = ["DEFAULT_BASE_URL", "SDK_VERSION", "Sendly"]
38
+
39
+ #: Package version. Kept in sync with ``pyproject.toml``.
40
+ SDK_VERSION = "0.1.0"
41
+
42
+ #: Default production API base. Override via ``base_url`` for staging/self-hosted.
43
+ DEFAULT_BASE_URL = "https://api.sendly.now"
44
+
45
+ #: Sent as the ``User-Agent`` on every request.
46
+ USER_AGENT = f"sendly-python/{SDK_VERSION}"
47
+
48
+
49
+ def _stringify(value: Any) -> str:
50
+ """Render a query value the way the TS SDK's ``String(value)`` does."""
51
+ if isinstance(value, bool):
52
+ return "true" if value else "false"
53
+ return str(value)
54
+
55
+
56
+ class Sendly:
57
+ """Sendly SDK entry point.
58
+
59
+ Construct once with an API key and reuse the resource accessors
60
+ (``emails``, ``contacts``, ``events``, ``domains``, ``templates``,
61
+ ``verify``, ``webhooks``, ``suppression``) for all calls.
62
+
63
+ Args:
64
+ api_key: Project API key (``sk_*`` for full access, ``pk_*`` for
65
+ sending-only). If omitted, falls back to the ``SENDLY_API_KEY``
66
+ environment variable; if neither is set, a :class:`SendlyError` is
67
+ raised (fail-loud — there is no degraded mode).
68
+ base_url: Override API base URL. Trailing slashes are stripped.
69
+ timeout: Per-request timeout in seconds (default 30). ``0`` or ``None``
70
+ disables the timeout. Applied per request, even for an injected client.
71
+ client: Inject a custom :class:`httpx.Client` (for testing, custom
72
+ transports, proxies, etc.). If omitted, one is created and owned by
73
+ this instance.
74
+ default_headers: Extra headers merged into every request.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ api_key: str | None = None,
80
+ *,
81
+ base_url: str = DEFAULT_BASE_URL,
82
+ timeout: float | None = 30.0,
83
+ client: httpx.Client | None = None,
84
+ default_headers: Mapping[str, str] | None = None,
85
+ ) -> None:
86
+ resolved_key = api_key if api_key is not None else os.environ.get("SENDLY_API_KEY")
87
+ if not resolved_key:
88
+ raise SendlyError(
89
+ 0,
90
+ "invalid_options",
91
+ "Sendly: `api_key` is required. Pass it explicitly or set the "
92
+ "SENDLY_API_KEY environment variable.",
93
+ )
94
+
95
+ self._api_key = resolved_key
96
+ self._base_url = base_url.rstrip("/")
97
+ self._timeout: float | None = timeout if timeout and timeout > 0 else None
98
+ self._default_headers: dict[str, str] = dict(default_headers or {})
99
+
100
+ if client is not None:
101
+ self._client = client
102
+ self._owns_client = False
103
+ else:
104
+ self._client = httpx.Client()
105
+ self._owns_client = True
106
+
107
+ self.emails = EmailsResource(self)
108
+ self.contacts = ContactsResource(self)
109
+ self.events = EventsResource(self)
110
+ self.domains = DomainsResource(self)
111
+ self.templates = TemplatesResource(self)
112
+ self.verify = VerifyResource(self)
113
+ self.webhooks = WebhooksResource(self)
114
+ self.suppression = SuppressionResource(self)
115
+
116
+ def request(
117
+ self,
118
+ *,
119
+ method: str,
120
+ path: str,
121
+ body: Body | None = None,
122
+ query: Query | None = None,
123
+ headers: Mapping[str, str] | None = None,
124
+ no_content: bool = False,
125
+ ) -> Any:
126
+ """Low-level request helper.
127
+
128
+ Resources call this; consumers can call it directly for endpoints not
129
+ yet wrapped by a resource. Returns the parsed JSON body of a successful
130
+ response (the ``{success, data}`` envelope), raw text for a non-JSON
131
+ success body, or ``None`` for 204 / ``no_content``. Errors are raised as
132
+ :class:`SendlyError` subclasses based on status.
133
+ """
134
+ url = self._build_url(path, query)
135
+ request_headers: dict[str, str] = {
136
+ "Authorization": f"Bearer {self._api_key}",
137
+ "Accept": "application/json",
138
+ "User-Agent": USER_AGENT,
139
+ **self._default_headers,
140
+ **(dict(headers) if headers else {}),
141
+ }
142
+
143
+ content: str | None = None
144
+ if body is not None:
145
+ request_headers["Content-Type"] = "application/json"
146
+ content = json.dumps(body)
147
+
148
+ try:
149
+ response = self._client.request(
150
+ method,
151
+ url,
152
+ content=content,
153
+ headers=request_headers,
154
+ timeout=self._timeout,
155
+ )
156
+ except httpx.HTTPError as exc:
157
+ raise SendlyConnectionError(f"Sendly request failed: {exc}", exc) from exc
158
+
159
+ # 204 No Content or caller-forced no-content (DELETE endpoints, which
160
+ # now respond 200 with an id body the SDK intentionally discards).
161
+ if response.status_code == 204 or no_content:
162
+ if not response.is_success:
163
+ self._raise_for_error(response)
164
+ return None
165
+
166
+ parsed: Any = None
167
+ text = response.text
168
+ if len(text) > 0:
169
+ try:
170
+ parsed = json.loads(text)
171
+ except json.JSONDecodeError:
172
+ if not response.is_success:
173
+ raise error_from_response(
174
+ response.status_code,
175
+ "invalid_response",
176
+ f"Sendly returned non-JSON {response.status_code}: {text[:200]}",
177
+ text,
178
+ ) from None
179
+ # Non-JSON success response; caller expects the raw text.
180
+ return text
181
+
182
+ if not response.is_success:
183
+ self._raise_from_body(response.status_code, parsed)
184
+
185
+ return parsed
186
+
187
+ def unwrap(self, envelope: Any) -> Any:
188
+ """Return the ``data`` field of a ``{success, data}`` envelope, else the
189
+ value unchanged. Centralizing this keeps resource code clean.
190
+ """
191
+ if isinstance(envelope, dict) and "data" in envelope:
192
+ return envelope["data"]
193
+ return envelope
194
+
195
+ def close(self) -> None:
196
+ """Close the underlying HTTP client, if this instance owns it."""
197
+ if self._owns_client:
198
+ self._client.close()
199
+
200
+ def __enter__(self) -> Sendly:
201
+ return self
202
+
203
+ def __exit__(
204
+ self,
205
+ exc_type: type[BaseException] | None,
206
+ exc: BaseException | None,
207
+ traceback: TracebackType | None,
208
+ ) -> None:
209
+ self.close()
210
+
211
+ def _build_url(self, path: str, query: Query | None = None) -> str:
212
+ if not path.startswith("/"):
213
+ raise SendlyError(
214
+ 0, "invalid_path", f'Sendly: path must start with "/" (got "{path}").'
215
+ )
216
+ url = f"{self._base_url}{path}"
217
+ if query:
218
+ params: list[tuple[str, str]] = []
219
+ for key, value in query.items():
220
+ if value is None or value == "":
221
+ continue
222
+ if isinstance(value, list | tuple):
223
+ for item in value:
224
+ if item is None or item == "":
225
+ continue
226
+ params.append((key, _stringify(item)))
227
+ else:
228
+ params.append((key, _stringify(value)))
229
+ if params:
230
+ url = f"{url}?{urlencode(params)}"
231
+ return url
232
+
233
+ def _raise_for_error(self, response: httpx.Response) -> NoReturn:
234
+ body: Any = None
235
+ text = response.text
236
+ if text:
237
+ try:
238
+ body = json.loads(text)
239
+ except json.JSONDecodeError:
240
+ body = None
241
+ self._raise_from_body(response.status_code, body)
242
+
243
+ def _raise_from_body(self, status_code: int, body: Any) -> NoReturn:
244
+ error = body.get("error") if isinstance(body, dict) else None
245
+ error = error if isinstance(error, dict) else {}
246
+ raw_message = error.get("message")
247
+ message = (
248
+ str(raw_message) if raw_message else f"Sendly request failed with status {status_code}"
249
+ )
250
+ raw_code = error.get("code")
251
+ code = str(raw_code) if raw_code else f"http_{status_code}"
252
+ raise error_from_response(status_code, code, message, body)
sendly/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ """Error hierarchy raised by the Sendly SDK.
2
+
3
+ Mirrors the TypeScript SDK's ``errors.ts``: a single :class:`SendlyError` base with
4
+ one subclass per meaningful HTTP status so callers can ``except`` a narrow type
5
+ without inspecting the response body.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class SendlyError(Exception):
14
+ """Base error for any non-2xx HTTP response or transport failure.
15
+
16
+ Attributes:
17
+ status_code: HTTP status (``0`` for client-side/transport failures).
18
+ error_code: Machine-readable code from the API error envelope, or a
19
+ synthesized ``http_<status>`` / ``invalid_response`` / ``connection_error``.
20
+ message: Human-readable message.
21
+ body: The parsed (or raw) response body, when available.
22
+ """
23
+
24
+ def __init__(self, status_code: int, error_code: str, message: str, body: Any = None) -> None:
25
+ super().__init__(message)
26
+ self.status_code = status_code
27
+ self.error_code = error_code
28
+ self.message = message
29
+ self.body = body
30
+
31
+
32
+ class SendlyValidationError(SendlyError):
33
+ """400 / 422 — request body or query failed validation.
34
+
35
+ Migrated routes return ``422`` with a ``VALIDATION_ERROR`` code (and an
36
+ ``error.details.errors`` list); legacy/malformed-request paths still use
37
+ ``400``. Both map here so ``except SendlyValidationError`` catches either.
38
+ """
39
+
40
+
41
+ class SendlyAuthenticationError(SendlyError):
42
+ """401 — missing or invalid ``Authorization`` header."""
43
+
44
+
45
+ class SendlyPermissionError(SendlyError):
46
+ """403 — authenticated but lacks permission for the operation."""
47
+
48
+
49
+ class SendlyNotFoundError(SendlyError):
50
+ """404 — resource does not exist or is not visible to the caller."""
51
+
52
+
53
+ class SendlyConflictError(SendlyError):
54
+ """409 — conflict (already exists, immutable, etc.)."""
55
+
56
+
57
+ class SendlyRateLimitError(SendlyError):
58
+ """429 — rate limited. Honor ``Retry-After`` if present."""
59
+
60
+
61
+ class SendlyServerError(SendlyError):
62
+ """5xx — server-side failure. Generally retryable with backoff."""
63
+
64
+
65
+ class SendlyConnectionError(SendlyError):
66
+ """Transport-level failure (DNS, connect, timeout, parse)."""
67
+
68
+ def __init__(self, message: str, body: Any = None) -> None:
69
+ super().__init__(0, "connection_error", message, body)
70
+
71
+
72
+ def error_from_response(
73
+ status_code: int, error_code: str, message: str, body: Any = None
74
+ ) -> SendlyError:
75
+ """Map an HTTP status + error envelope to the appropriate error subclass."""
76
+ if status_code == 400:
77
+ return SendlyValidationError(status_code, error_code, message, body)
78
+ if status_code == 401:
79
+ return SendlyAuthenticationError(status_code, error_code, message, body)
80
+ if status_code == 403:
81
+ return SendlyPermissionError(status_code, error_code, message, body)
82
+ if status_code == 404:
83
+ return SendlyNotFoundError(status_code, error_code, message, body)
84
+ if status_code == 422:
85
+ return SendlyValidationError(status_code, error_code, message, body)
86
+ if status_code == 409:
87
+ return SendlyConflictError(status_code, error_code, message, body)
88
+ if status_code == 429:
89
+ return SendlyRateLimitError(status_code, error_code, message, body)
90
+ if status_code >= 500:
91
+ return SendlyServerError(status_code, error_code, message, body)
92
+ return SendlyError(status_code, error_code, message, body)
sendly/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Resource accessors for the Sendly SDK, one module per API surface."""
@@ -0,0 +1,17 @@
1
+ """Shared helpers for resource classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+
8
+ def encode_path_segment(segment: str) -> str:
9
+ """Percent-encode a single URL path segment (mirrors JS ``encodeURIComponent``)."""
10
+ return quote(str(segment), safe="")
11
+
12
+
13
+ def idempotency_headers(idempotency_key: str | None) -> dict[str, str] | None:
14
+ """Build the optional ``Idempotency-Key`` header dict, or ``None`` when unset."""
15
+ if not idempotency_key:
16
+ return None
17
+ return {"Idempotency-Key": idempotency_key}
@@ -0,0 +1,93 @@
1
+ """Contacts resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment, idempotency_headers
8
+
9
+ if TYPE_CHECKING:
10
+ from sendly.client import Sendly
11
+ from sendly.types import (
12
+ Body,
13
+ ContactListResponse,
14
+ ContactRecord,
15
+ JSONDict,
16
+ Query,
17
+ )
18
+
19
+
20
+ class ContactsResource:
21
+ """Create, query, and manage contacts."""
22
+
23
+ def __init__(self, client: Sendly) -> None:
24
+ self._client = client
25
+
26
+ def create(self, body: Body, *, idempotency_key: str | None = None) -> ContactRecord:
27
+ """Create a new contact (fails on duplicate)."""
28
+ envelope = self._client.request(
29
+ method="POST",
30
+ path="/api/contacts",
31
+ body=body,
32
+ headers=idempotency_headers(idempotency_key),
33
+ )
34
+ record: ContactRecord = self._client.unwrap(envelope)
35
+ return record
36
+
37
+ def upsert(self, body: Body, *, idempotency_key: str | None = None) -> ContactRecord:
38
+ """Insert or update a contact identified by email."""
39
+ envelope = self._client.request(
40
+ method="POST",
41
+ path="/api/contacts/upsert",
42
+ body=body,
43
+ headers=idempotency_headers(idempotency_key),
44
+ )
45
+ record: ContactRecord = self._client.unwrap(envelope)
46
+ return record
47
+
48
+ def bulk_create(self, body: Body, *, idempotency_key: str | None = None) -> JSONDict:
49
+ """Bulk-create contacts (up to API limit). Returns per-row results."""
50
+ response: JSONDict = self._client.request(
51
+ method="POST",
52
+ path="/api/contacts/bulk",
53
+ body=body,
54
+ headers=idempotency_headers(idempotency_key),
55
+ )
56
+ return response
57
+
58
+ def bulk_delete(self, body: Body) -> JSONDict:
59
+ """Bulk-delete contacts by id or email."""
60
+ response: JSONDict = self._client.request(
61
+ method="DELETE", path="/api/contacts/bulk", body=body
62
+ )
63
+ return response
64
+
65
+ def list(self, query: Query | None = None) -> ContactListResponse:
66
+ """List contacts with search + cursor pagination."""
67
+ response: ContactListResponse = self._client.request(
68
+ method="GET", path="/api/contacts", query=query
69
+ )
70
+ return response
71
+
72
+ def get(self, id: str) -> ContactRecord:
73
+ """Fetch a single contact by id."""
74
+ envelope = self._client.request(
75
+ method="GET", path=f"/api/contacts/{encode_path_segment(id)}"
76
+ )
77
+ record: ContactRecord = self._client.unwrap(envelope)
78
+ return record
79
+
80
+ def update(self, id: str, body: Body) -> ContactRecord:
81
+ """Patch a contact (partial update of ``data``, ``subscribed``, etc.)."""
82
+ envelope = self._client.request(
83
+ method="PATCH", path=f"/api/contacts/{encode_path_segment(id)}", body=body
84
+ )
85
+ record: ContactRecord = self._client.unwrap(envelope)
86
+ return record
87
+
88
+ def delete(self, id: str) -> None:
89
+ """Delete a contact. Returns ``None`` (the API responds 200 with the
90
+ deleted contact's id)."""
91
+ self._client.request(
92
+ method="DELETE", path=f"/api/contacts/{encode_path_segment(id)}", no_content=True
93
+ )
@@ -0,0 +1,67 @@
1
+ """Domains 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
+ DomainListResponse,
14
+ DomainRecord,
15
+ DomainVerificationStatus,
16
+ )
17
+
18
+
19
+ class DomainsResource:
20
+ """Register and verify sending domains."""
21
+
22
+ def __init__(self, client: Sendly) -> None:
23
+ self._client = client
24
+
25
+ def create(self, body: Body) -> DomainRecord:
26
+ """Register a new sending domain.
27
+
28
+ Pass ``region`` to pin this domain to a specific AWS SES region. On the
29
+ first domain for a project this also locks the project's region;
30
+ subsequent calls must match. The response includes DNS records to set.
31
+ """
32
+ envelope = self._client.request(method="POST", path="/api/domains", body=body)
33
+ record: DomainRecord = self._client.unwrap(envelope)
34
+ return record
35
+
36
+ def list(self) -> DomainListResponse:
37
+ """List all domains for the project."""
38
+ response: DomainListResponse = self._client.request(method="GET", path="/api/domains")
39
+ return response
40
+
41
+ def get(self, id: str) -> DomainRecord:
42
+ """Fetch a single domain."""
43
+ envelope = self._client.request(
44
+ method="GET", path=f"/api/domains/{encode_path_segment(id)}"
45
+ )
46
+ record: DomainRecord = self._client.unwrap(envelope)
47
+ return record
48
+
49
+ def verify(self, id: str) -> DomainVerificationStatus:
50
+ """Trigger SES verification for a domain."""
51
+ envelope = self._client.request(
52
+ method="POST", path=f"/api/domains/{encode_path_segment(id)}/verify"
53
+ )
54
+ status: DomainVerificationStatus = self._client.unwrap(envelope)
55
+ return status
56
+
57
+ def get_verification(self, id: str) -> DomainVerificationStatus:
58
+ """Read current SES verification status for a domain."""
59
+ envelope = self._client.request(
60
+ method="GET", path=f"/api/domains/{encode_path_segment(id)}/verify"
61
+ )
62
+ status: DomainVerificationStatus = self._client.unwrap(envelope)
63
+ return status
64
+
65
+ def delete(self, id: str) -> None:
66
+ """Delete a domain."""
67
+ self._client.request(method="DELETE", path=f"/api/domains/{encode_path_segment(id)}")
@@ -0,0 +1,73 @@
1
+ """Emails resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sendly.resources._helpers import encode_path_segment, idempotency_headers
8
+
9
+ if TYPE_CHECKING:
10
+ from sendly.client import Sendly
11
+ from sendly.types import (
12
+ BatchSendResponse,
13
+ Body,
14
+ EmailGetResponse,
15
+ EmailListResponse,
16
+ Query,
17
+ SendEmailData,
18
+ SuccessEmpty,
19
+ )
20
+
21
+
22
+ class EmailsResource:
23
+ """Send and manage transactional emails."""
24
+
25
+ def __init__(self, client: Sendly) -> None:
26
+ self._client = client
27
+
28
+ def send(
29
+ self, body: Body, *, idempotency_key: str | None = None
30
+ ) -> SendEmailData | list[SendEmailData]:
31
+ """Send a single transactional email.
32
+
33
+ Pass ``idempotency_key`` (1-255 chars) to dedupe replays for 24h.
34
+ """
35
+ envelope = self._client.request(
36
+ method="POST",
37
+ path="/api/emails",
38
+ body=body,
39
+ headers=idempotency_headers(idempotency_key),
40
+ )
41
+ data: SendEmailData | list[SendEmailData] = self._client.unwrap(envelope)
42
+ return data
43
+
44
+ def batch(self, body: Body, *, idempotency_key: str | None = None) -> BatchSendResponse:
45
+ """Send a batch (up to 100) of transactional emails in one call."""
46
+ response: BatchSendResponse = self._client.request(
47
+ method="POST",
48
+ path="/api/emails/batch",
49
+ body=body,
50
+ headers=idempotency_headers(idempotency_key),
51
+ )
52
+ return response
53
+
54
+ def list(self, query: Query | None = None) -> EmailListResponse:
55
+ """List emails with cursor-based pagination + filters."""
56
+ response: EmailListResponse = self._client.request(
57
+ method="GET", path="/api/emails", query=query
58
+ )
59
+ return response
60
+
61
+ def get(self, id: str) -> EmailGetResponse:
62
+ """Fetch a single email and its delivery events."""
63
+ response: EmailGetResponse = self._client.request(
64
+ method="GET", path=f"/api/emails/{encode_path_segment(id)}"
65
+ )
66
+ return response
67
+
68
+ def cancel_schedule(self, id: str) -> SuccessEmpty:
69
+ """Cancel a scheduled (PENDING) email before it fires."""
70
+ response: SuccessEmpty = self._client.request(
71
+ method="DELETE", path=f"/api/emails/{encode_path_segment(id)}/schedule"
72
+ )
73
+ return response
@@ -0,0 +1,26 @@
1
+ """Events 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, TrackEventData
10
+
11
+
12
+ class EventsResource:
13
+ """Record custom events for contacts."""
14
+
15
+ def __init__(self, client: Sendly) -> None:
16
+ self._client = client
17
+
18
+ def track(self, body: Body) -> TrackEventData:
19
+ """Record a custom event for a contact.
20
+
21
+ Both full (``sk_*``) and sending-only (``pk_*``) keys are accepted.
22
+ Reserved system event names (e.g. ``email.sent``) are rejected by the API.
23
+ """
24
+ envelope = self._client.request(method="POST", path="/api/track", body=body)
25
+ data: TrackEventData = self._client.unwrap(envelope)
26
+ return data