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