senderkit 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.
- senderkit/__init__.py +95 -0
- senderkit/_http.py +280 -0
- senderkit/_serialize.py +184 -0
- senderkit/_version.py +3 -0
- senderkit/client.py +305 -0
- senderkit/errors.py +89 -0
- senderkit/integrations/__init__.py +10 -0
- senderkit/integrations/celery.py +56 -0
- senderkit/integrations/django/__init__.py +25 -0
- senderkit/integrations/django/backends.py +108 -0
- senderkit/integrations/django/client.py +43 -0
- senderkit/integrations/django/views.py +55 -0
- senderkit/integrations/fastapi.py +86 -0
- senderkit/integrations/flask.py +107 -0
- senderkit/models.py +335 -0
- senderkit/py.typed +0 -0
- senderkit/resources/__init__.py +6 -0
- senderkit/resources/messages.py +132 -0
- senderkit/resources/templates.py +53 -0
- senderkit/webhooks.py +103 -0
- senderkit-0.1.0.dist-info/METADATA +412 -0
- senderkit-0.1.0.dist-info/RECORD +24 -0
- senderkit-0.1.0.dist-info/WHEEL +4 -0
- senderkit-0.1.0.dist-info/licenses/LICENSE +21 -0
senderkit/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""SenderKit Python SDK.
|
|
2
|
+
|
|
3
|
+
from senderkit import SenderKit
|
|
4
|
+
|
|
5
|
+
with SenderKit(api_key="sk_test_...") as sk:
|
|
6
|
+
sk.send("welcome", "user@example.com", vars={"name": "Ada"})
|
|
7
|
+
|
|
8
|
+
See :class:`SenderKit` / :class:`AsyncSenderKit` for the client, ``senderkit.errors``
|
|
9
|
+
for the exception hierarchy, and :class:`WebhookVerifier` for inbound webhooks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from . import errors
|
|
15
|
+
from ._version import VERSION
|
|
16
|
+
from .client import AsyncSenderKit, SenderKit
|
|
17
|
+
from .errors import (
|
|
18
|
+
APIError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
NetworkError,
|
|
22
|
+
PaymentRequiredError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
SenderKitError,
|
|
25
|
+
SignatureVerificationError,
|
|
26
|
+
TimeoutError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from .models import (
|
|
30
|
+
Attachment,
|
|
31
|
+
BatchResult,
|
|
32
|
+
Channel,
|
|
33
|
+
Context,
|
|
34
|
+
EmailContent,
|
|
35
|
+
Message,
|
|
36
|
+
MessageList,
|
|
37
|
+
PushContent,
|
|
38
|
+
RawSend,
|
|
39
|
+
RenderResult,
|
|
40
|
+
SendResult,
|
|
41
|
+
SmsContent,
|
|
42
|
+
TemplateDetail,
|
|
43
|
+
TemplateSend,
|
|
44
|
+
TemplateSummary,
|
|
45
|
+
TemplateVariable,
|
|
46
|
+
TemplateVersion,
|
|
47
|
+
WebPushContent,
|
|
48
|
+
Workspace,
|
|
49
|
+
)
|
|
50
|
+
from .webhooks import WebhookEvent, WebhookVerifier
|
|
51
|
+
|
|
52
|
+
__version__ = VERSION
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"VERSION",
|
|
56
|
+
"__version__",
|
|
57
|
+
"SenderKit",
|
|
58
|
+
"AsyncSenderKit",
|
|
59
|
+
"errors",
|
|
60
|
+
# Errors
|
|
61
|
+
"SenderKitError",
|
|
62
|
+
"APIError",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"ValidationError",
|
|
65
|
+
"PaymentRequiredError",
|
|
66
|
+
"ConflictError",
|
|
67
|
+
"RateLimitError",
|
|
68
|
+
"TimeoutError",
|
|
69
|
+
"NetworkError",
|
|
70
|
+
"SignatureVerificationError",
|
|
71
|
+
# Requests
|
|
72
|
+
"TemplateSend",
|
|
73
|
+
"RawSend",
|
|
74
|
+
"EmailContent",
|
|
75
|
+
"SmsContent",
|
|
76
|
+
"PushContent",
|
|
77
|
+
"WebPushContent",
|
|
78
|
+
"Attachment",
|
|
79
|
+
"Channel",
|
|
80
|
+
# Responses
|
|
81
|
+
"SendResult",
|
|
82
|
+
"BatchResult",
|
|
83
|
+
"Message",
|
|
84
|
+
"MessageList",
|
|
85
|
+
"TemplateSummary",
|
|
86
|
+
"TemplateDetail",
|
|
87
|
+
"TemplateVersion",
|
|
88
|
+
"TemplateVariable",
|
|
89
|
+
"RenderResult",
|
|
90
|
+
"Context",
|
|
91
|
+
"Workspace",
|
|
92
|
+
# Webhooks
|
|
93
|
+
"WebhookVerifier",
|
|
94
|
+
"WebhookEvent",
|
|
95
|
+
]
|
senderkit/_http.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Internal HTTP transport: header construction, retries with backoff,
|
|
2
|
+
idempotency, timeout/network mapping, and error-to-exception translation.
|
|
3
|
+
|
|
4
|
+
Two thin transports — ``Transport`` (sync, ``httpx.Client``) and
|
|
5
|
+
``AsyncTransport`` (``httpx.AsyncClient``) — share all policy via the module
|
|
6
|
+
helpers below. Not part of the public API; the client and resources use it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from . import errors
|
|
20
|
+
from ._version import VERSION
|
|
21
|
+
|
|
22
|
+
RETRY_BASE_MS = 250
|
|
23
|
+
RETRY_CAP_MS = 5_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_retryable(status: int) -> bool:
|
|
27
|
+
"""429 and 5xx (except 501 Not Implemented) are worth retrying."""
|
|
28
|
+
return status == 429 or (500 <= status < 600 and status != 501)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_headers(
|
|
32
|
+
api_key: str,
|
|
33
|
+
idempotency_key: Optional[str],
|
|
34
|
+
extra: Optional[Dict[str, str]],
|
|
35
|
+
) -> Dict[str, str]:
|
|
36
|
+
headers = {
|
|
37
|
+
"Authorization": f"Bearer {api_key}",
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
"User-Agent": f"senderkit-python/{VERSION}",
|
|
40
|
+
}
|
|
41
|
+
if idempotency_key:
|
|
42
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
43
|
+
if extra:
|
|
44
|
+
headers.update(extra)
|
|
45
|
+
return headers
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_retry_after(response: httpx.Response) -> Optional[float]:
|
|
49
|
+
value = response.headers.get("retry-after")
|
|
50
|
+
if not value:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return float(value)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _backoff_seconds(attempt: int, retry_after: Optional[float]) -> float:
|
|
59
|
+
"""Full-jitter exponential backoff, capped at ``RETRY_CAP_MS``.
|
|
60
|
+
|
|
61
|
+
When the server sent ``Retry-After`` we wait at least that long (also capped),
|
|
62
|
+
so we never hammer ahead of an explicit instruction.
|
|
63
|
+
"""
|
|
64
|
+
cap = RETRY_CAP_MS / 1000
|
|
65
|
+
ceiling = min(RETRY_CAP_MS, RETRY_BASE_MS * (2**attempt)) / 1000
|
|
66
|
+
jitter = random.uniform(0, ceiling)
|
|
67
|
+
if retry_after is not None:
|
|
68
|
+
return min(max(retry_after, jitter), cap)
|
|
69
|
+
return jitter
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _clean_query(query: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
|
|
73
|
+
if not query:
|
|
74
|
+
return None
|
|
75
|
+
return {k: str(v) for k, v in query.items() if v is not None}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
79
|
+
status = response.status_code
|
|
80
|
+
if 200 <= status < 300:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
code: Optional[str] = None
|
|
84
|
+
message: Optional[str] = None
|
|
85
|
+
issues: Any = None
|
|
86
|
+
try:
|
|
87
|
+
body = response.json()
|
|
88
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
89
|
+
if isinstance(err, dict):
|
|
90
|
+
code = err.get("code")
|
|
91
|
+
message = err.get("message")
|
|
92
|
+
issues = err.get("issues")
|
|
93
|
+
except (ValueError, json.JSONDecodeError):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
message = message or f"HTTP {status}"
|
|
97
|
+
request_id = response.headers.get("x-request-id")
|
|
98
|
+
|
|
99
|
+
if status in (401, 403):
|
|
100
|
+
raise errors.AuthenticationError(
|
|
101
|
+
message, status=status, code=code, issues=issues, request_id=request_id
|
|
102
|
+
)
|
|
103
|
+
if status in (400, 422):
|
|
104
|
+
raise errors.ValidationError(
|
|
105
|
+
message, status=status, code=code, issues=issues, request_id=request_id
|
|
106
|
+
)
|
|
107
|
+
if status == 402:
|
|
108
|
+
raise errors.PaymentRequiredError(
|
|
109
|
+
message, status=status, code=code, issues=issues, request_id=request_id
|
|
110
|
+
)
|
|
111
|
+
if status == 409:
|
|
112
|
+
raise errors.ConflictError(
|
|
113
|
+
message, status=status, code=code, issues=issues, request_id=request_id
|
|
114
|
+
)
|
|
115
|
+
if status == 429:
|
|
116
|
+
raise errors.RateLimitError(
|
|
117
|
+
message,
|
|
118
|
+
status=status,
|
|
119
|
+
code=code,
|
|
120
|
+
issues=issues,
|
|
121
|
+
request_id=request_id,
|
|
122
|
+
retry_after=_parse_retry_after(response),
|
|
123
|
+
)
|
|
124
|
+
raise errors.APIError(message, status=status, code=code, issues=issues, request_id=request_id)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _BaseTransport:
|
|
128
|
+
def __init__(self, api_key: str, base_url: str, max_retries: int) -> None:
|
|
129
|
+
self._api_key = api_key
|
|
130
|
+
self._base_url = base_url.rstrip("/")
|
|
131
|
+
self._max_retries = max_retries
|
|
132
|
+
|
|
133
|
+
def _url(self, path: str) -> str:
|
|
134
|
+
return self._base_url + (path if path.startswith("/") else f"/{path}")
|
|
135
|
+
|
|
136
|
+
def _prepare(
|
|
137
|
+
self,
|
|
138
|
+
body: Optional[Dict[str, Any]],
|
|
139
|
+
idempotency_key: Optional[str],
|
|
140
|
+
headers: Optional[Dict[str, str]],
|
|
141
|
+
accept: Optional[str],
|
|
142
|
+
) -> tuple[Dict[str, str], Optional[bytes]]:
|
|
143
|
+
h = _build_headers(self._api_key, idempotency_key, headers)
|
|
144
|
+
if accept:
|
|
145
|
+
h["Accept"] = accept
|
|
146
|
+
content: Optional[bytes] = None
|
|
147
|
+
if body is not None:
|
|
148
|
+
content = json.dumps(body).encode()
|
|
149
|
+
h["Content-Type"] = "application/json"
|
|
150
|
+
return h, content
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Transport(_BaseTransport):
|
|
154
|
+
"""Synchronous transport over ``httpx.Client``."""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
api_key: str,
|
|
159
|
+
base_url: str,
|
|
160
|
+
timeout: float,
|
|
161
|
+
max_retries: int,
|
|
162
|
+
http_client: Optional[httpx.Client] = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
super().__init__(api_key, base_url, max_retries)
|
|
165
|
+
self._owns_client = http_client is None
|
|
166
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
167
|
+
|
|
168
|
+
def request(
|
|
169
|
+
self,
|
|
170
|
+
method: str,
|
|
171
|
+
path: str,
|
|
172
|
+
*,
|
|
173
|
+
query: Optional[Dict[str, Any]] = None,
|
|
174
|
+
body: Optional[Dict[str, Any]] = None,
|
|
175
|
+
idempotency_key: Optional[str] = None,
|
|
176
|
+
headers: Optional[Dict[str, str]] = None,
|
|
177
|
+
accept: Optional[str] = None,
|
|
178
|
+
) -> httpx.Response:
|
|
179
|
+
url = self._url(path)
|
|
180
|
+
h, content = self._prepare(body, idempotency_key, headers, accept)
|
|
181
|
+
params = _clean_query(query)
|
|
182
|
+
attempts = 1 + self._max_retries
|
|
183
|
+
|
|
184
|
+
for attempt in range(attempts):
|
|
185
|
+
try:
|
|
186
|
+
response = self._client.request(
|
|
187
|
+
method, url, params=params, content=content, headers=h
|
|
188
|
+
)
|
|
189
|
+
except httpx.TimeoutException as exc:
|
|
190
|
+
raise errors.TimeoutError(f"Request timed out: {exc}") from exc
|
|
191
|
+
except httpx.RequestError as exc:
|
|
192
|
+
if attempt < attempts - 1:
|
|
193
|
+
time.sleep(_backoff_seconds(attempt, None))
|
|
194
|
+
continue
|
|
195
|
+
raise errors.NetworkError(f"Network error: {exc}") from exc
|
|
196
|
+
|
|
197
|
+
if _is_retryable(response.status_code) and attempt < attempts - 1:
|
|
198
|
+
time.sleep(_backoff_seconds(attempt, _parse_retry_after(response)))
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
_raise_for_status(response)
|
|
202
|
+
return response
|
|
203
|
+
|
|
204
|
+
raise errors.NetworkError("Request failed after retries") # pragma: no cover
|
|
205
|
+
|
|
206
|
+
def request_json(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
|
207
|
+
response = self.request(method, path, **kwargs)
|
|
208
|
+
if not response.content:
|
|
209
|
+
return {}
|
|
210
|
+
data = response.json()
|
|
211
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
212
|
+
|
|
213
|
+
def close(self) -> None:
|
|
214
|
+
if self._owns_client:
|
|
215
|
+
self._client.close()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class AsyncTransport(_BaseTransport):
|
|
219
|
+
"""Asynchronous transport over ``httpx.AsyncClient``."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
api_key: str,
|
|
224
|
+
base_url: str,
|
|
225
|
+
timeout: float,
|
|
226
|
+
max_retries: int,
|
|
227
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
super().__init__(api_key, base_url, max_retries)
|
|
230
|
+
self._owns_client = http_client is None
|
|
231
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
232
|
+
|
|
233
|
+
async def request(
|
|
234
|
+
self,
|
|
235
|
+
method: str,
|
|
236
|
+
path: str,
|
|
237
|
+
*,
|
|
238
|
+
query: Optional[Dict[str, Any]] = None,
|
|
239
|
+
body: Optional[Dict[str, Any]] = None,
|
|
240
|
+
idempotency_key: Optional[str] = None,
|
|
241
|
+
headers: Optional[Dict[str, str]] = None,
|
|
242
|
+
accept: Optional[str] = None,
|
|
243
|
+
) -> httpx.Response:
|
|
244
|
+
url = self._url(path)
|
|
245
|
+
h, content = self._prepare(body, idempotency_key, headers, accept)
|
|
246
|
+
params = _clean_query(query)
|
|
247
|
+
attempts = 1 + self._max_retries
|
|
248
|
+
|
|
249
|
+
for attempt in range(attempts):
|
|
250
|
+
try:
|
|
251
|
+
response = await self._client.request(
|
|
252
|
+
method, url, params=params, content=content, headers=h
|
|
253
|
+
)
|
|
254
|
+
except httpx.TimeoutException as exc:
|
|
255
|
+
raise errors.TimeoutError(f"Request timed out: {exc}") from exc
|
|
256
|
+
except httpx.RequestError as exc:
|
|
257
|
+
if attempt < attempts - 1:
|
|
258
|
+
await asyncio.sleep(_backoff_seconds(attempt, None))
|
|
259
|
+
continue
|
|
260
|
+
raise errors.NetworkError(f"Network error: {exc}") from exc
|
|
261
|
+
|
|
262
|
+
if _is_retryable(response.status_code) and attempt < attempts - 1:
|
|
263
|
+
await asyncio.sleep(_backoff_seconds(attempt, _parse_retry_after(response)))
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
_raise_for_status(response)
|
|
267
|
+
return response
|
|
268
|
+
|
|
269
|
+
raise errors.NetworkError("Request failed after retries") # pragma: no cover
|
|
270
|
+
|
|
271
|
+
async def request_json(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
|
272
|
+
response = await self.request(method, path, **kwargs)
|
|
273
|
+
if not response.content:
|
|
274
|
+
return {}
|
|
275
|
+
data = response.json()
|
|
276
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
277
|
+
|
|
278
|
+
async def aclose(self) -> None:
|
|
279
|
+
if self._owns_client:
|
|
280
|
+
await self._client.aclose()
|
senderkit/_serialize.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Turn request DTOs into the JSON shapes the API expects.
|
|
2
|
+
|
|
3
|
+
Centralizes the snake_case → camelCase mapping, ``None`` pruning, datetime →
|
|
4
|
+
ISO-8601 conversion, and enum unwrapping so the client and resources never
|
|
5
|
+
hand-build wire dicts. Kept explicit (rather than reflection-based) so the
|
|
6
|
+
mapping is auditable against ``openapi.yaml``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Dict, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .models import (
|
|
15
|
+
Attachment,
|
|
16
|
+
Channel,
|
|
17
|
+
ChannelLike,
|
|
18
|
+
Content,
|
|
19
|
+
EmailContent,
|
|
20
|
+
PushContent,
|
|
21
|
+
RawSend,
|
|
22
|
+
SmsContent,
|
|
23
|
+
TemplateSend,
|
|
24
|
+
WebPushContent,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _iso(value: Any) -> Any:
|
|
29
|
+
"""ISO-8601 string for a datetime; pass strings (and None) through."""
|
|
30
|
+
if isinstance(value, datetime):
|
|
31
|
+
return value.isoformat()
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _channel_value(channel: Optional[ChannelLike]) -> Optional[str]:
|
|
36
|
+
if channel is None:
|
|
37
|
+
return None
|
|
38
|
+
return channel.value if isinstance(channel, Channel) else str(channel)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _prune(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
42
|
+
"""Drop keys whose value is ``None`` (omit-if-absent semantics)."""
|
|
43
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _attachment_to_wire(a: Attachment) -> Dict[str, Any]:
|
|
47
|
+
return _prune(
|
|
48
|
+
{
|
|
49
|
+
"filename": a.filename,
|
|
50
|
+
"contentType": a.content_type,
|
|
51
|
+
"content": a.content,
|
|
52
|
+
"inline": a.inline,
|
|
53
|
+
"contentId": a.content_id,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def channel_for_content(content: Content) -> str:
|
|
59
|
+
"""Infer the channel string from a content instance."""
|
|
60
|
+
if isinstance(content, EmailContent):
|
|
61
|
+
return "email"
|
|
62
|
+
if isinstance(content, SmsContent):
|
|
63
|
+
return "sms"
|
|
64
|
+
if isinstance(content, PushContent):
|
|
65
|
+
return "push"
|
|
66
|
+
if isinstance(content, WebPushContent):
|
|
67
|
+
return "web-push"
|
|
68
|
+
raise TypeError(f"Unsupported content type: {type(content)!r}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def content_to_wire(content: Content) -> Dict[str, Any]:
|
|
72
|
+
if isinstance(content, EmailContent):
|
|
73
|
+
return _prune(
|
|
74
|
+
{
|
|
75
|
+
"subject": content.subject,
|
|
76
|
+
"preheader": content.preheader,
|
|
77
|
+
"html": content.html,
|
|
78
|
+
"text": content.text,
|
|
79
|
+
"cc": content.cc,
|
|
80
|
+
"bcc": content.bcc,
|
|
81
|
+
"replyTo": content.reply_to,
|
|
82
|
+
"attachments": (
|
|
83
|
+
[_attachment_to_wire(a) for a in content.attachments]
|
|
84
|
+
if content.attachments
|
|
85
|
+
else None
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
if isinstance(content, SmsContent):
|
|
90
|
+
return {"body": content.body}
|
|
91
|
+
if isinstance(content, PushContent):
|
|
92
|
+
return _prune(
|
|
93
|
+
{
|
|
94
|
+
"title": content.title,
|
|
95
|
+
"body": content.body,
|
|
96
|
+
"data": content.data,
|
|
97
|
+
"badge": content.badge,
|
|
98
|
+
"sound": content.sound,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
if isinstance(content, WebPushContent):
|
|
102
|
+
return _prune(
|
|
103
|
+
{
|
|
104
|
+
"title": content.title,
|
|
105
|
+
"body": content.body,
|
|
106
|
+
"icon": content.icon,
|
|
107
|
+
"clickUrl": content.click_url,
|
|
108
|
+
"data": content.data,
|
|
109
|
+
"badge": content.badge,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
raise TypeError(f"Unsupported content type: {type(content)!r}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def template_send_to_body(req: TemplateSend) -> Dict[str, Any]:
|
|
116
|
+
return _prune(
|
|
117
|
+
{
|
|
118
|
+
"template": req.template,
|
|
119
|
+
"to": req.to,
|
|
120
|
+
"vars": req.vars,
|
|
121
|
+
"version": req.version,
|
|
122
|
+
"channel": _channel_value(req.channel),
|
|
123
|
+
"metadata": req.metadata,
|
|
124
|
+
"scheduledAt": _iso(req.scheduled_at),
|
|
125
|
+
"cc": req.cc,
|
|
126
|
+
"bcc": req.bcc,
|
|
127
|
+
"replyTo": req.reply_to,
|
|
128
|
+
"attachments": (
|
|
129
|
+
[_attachment_to_wire(a) for a in req.attachments] if req.attachments else None
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def raw_send_to_body(req: RawSend) -> Dict[str, Any]:
|
|
136
|
+
channel = _channel_value(req.channel) or channel_for_content(req.content)
|
|
137
|
+
return _prune(
|
|
138
|
+
{
|
|
139
|
+
"channel": channel,
|
|
140
|
+
"to": req.to,
|
|
141
|
+
"from": req.from_,
|
|
142
|
+
"interpolate": req.interpolate,
|
|
143
|
+
"content": content_to_wire(req.content),
|
|
144
|
+
"vars": req.vars,
|
|
145
|
+
"metadata": req.metadata,
|
|
146
|
+
"scheduledAt": _iso(req.scheduled_at),
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_send(req: Any) -> Tuple[Dict[str, Any], Optional[str]]:
|
|
152
|
+
"""Serialize a send request, returning ``(body, idempotency_key)``."""
|
|
153
|
+
if isinstance(req, TemplateSend):
|
|
154
|
+
return template_send_to_body(req), req.idempotency_key
|
|
155
|
+
if isinstance(req, RawSend):
|
|
156
|
+
return raw_send_to_body(req), req.idempotency_key
|
|
157
|
+
raise TypeError(f"Expected TemplateSend or RawSend, got {type(req)!r}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def list_messages_query(
|
|
161
|
+
*,
|
|
162
|
+
limit: Optional[int],
|
|
163
|
+
cursor: Optional[str],
|
|
164
|
+
status: Optional[str],
|
|
165
|
+
channel: Optional[ChannelLike],
|
|
166
|
+
template: Optional[str],
|
|
167
|
+
metadata: Optional[Dict[str, Any]],
|
|
168
|
+
tail: Optional[str],
|
|
169
|
+
) -> Dict[str, Any]:
|
|
170
|
+
"""Build the query dict for ``GET /v1/messages``, including ``metadata[key]``."""
|
|
171
|
+
query: Dict[str, Any] = _prune(
|
|
172
|
+
{
|
|
173
|
+
"limit": limit,
|
|
174
|
+
"cursor": cursor,
|
|
175
|
+
"status": status.value if isinstance(status, Channel) else status,
|
|
176
|
+
"channel": _channel_value(channel),
|
|
177
|
+
"template": template,
|
|
178
|
+
"tail": tail,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
if metadata:
|
|
182
|
+
for key, value in metadata.items():
|
|
183
|
+
query[f"metadata[{key}]"] = value
|
|
184
|
+
return query
|
senderkit/_version.py
ADDED