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/client.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""The public client: ``SenderKit`` (sync) and ``AsyncSenderKit`` (async).
|
|
2
|
+
|
|
3
|
+
Both expose the same surface as the TypeScript and PHP SDKs — ``send``,
|
|
4
|
+
``send_raw``, ``send_batch``, ``context``, plus the ``messages`` and
|
|
5
|
+
``templates`` resource namespaces — and both work as (async) context managers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import uuid
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from . import errors
|
|
18
|
+
from ._http import AsyncTransport, Transport
|
|
19
|
+
from ._serialize import build_send
|
|
20
|
+
from .models import (
|
|
21
|
+
BatchResult,
|
|
22
|
+
ChannelLike,
|
|
23
|
+
Content,
|
|
24
|
+
Context,
|
|
25
|
+
RawSend,
|
|
26
|
+
ScheduledAt,
|
|
27
|
+
SendResult,
|
|
28
|
+
TemplateSend,
|
|
29
|
+
)
|
|
30
|
+
from .resources import AsyncMessages, AsyncTemplates, Messages, Templates
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.senderkit.com"
|
|
33
|
+
DEFAULT_TIMEOUT = 30.0
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
|
|
36
|
+
SendItem = Union[TemplateSend, RawSend]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mode_for_key(api_key: str) -> str:
|
|
40
|
+
return "test" if api_key.startswith("sk_test_") else "live"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _batch_key(base: Optional[str], item: SendItem, index: int) -> str:
|
|
44
|
+
"""Per-item idempotency key: ``{base}-{index}`` if a base was given, else the
|
|
45
|
+
item's own key, else a fresh UUID."""
|
|
46
|
+
if base:
|
|
47
|
+
return f"{base}-{index}"
|
|
48
|
+
return item.idempotency_key or str(uuid.uuid4())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SenderKit:
|
|
52
|
+
"""Synchronous SenderKit client.
|
|
53
|
+
|
|
54
|
+
``timeout`` is in seconds (httpx convention; the TS/PHP SDKs use milliseconds).
|
|
55
|
+
Pass your own ``httpx.Client`` to control proxies, TLS, or connection pooling.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: str,
|
|
61
|
+
*,
|
|
62
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
63
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
64
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
65
|
+
http_client: Optional[httpx.Client] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not api_key:
|
|
68
|
+
raise ValueError("api_key is required")
|
|
69
|
+
self.mode = _mode_for_key(api_key)
|
|
70
|
+
self._transport = Transport(api_key, base_url, timeout, max_retries, http_client)
|
|
71
|
+
self.messages = Messages(self._transport)
|
|
72
|
+
self.templates = Templates(self._transport)
|
|
73
|
+
|
|
74
|
+
def send(
|
|
75
|
+
self,
|
|
76
|
+
template: str,
|
|
77
|
+
to: str,
|
|
78
|
+
*,
|
|
79
|
+
vars: Optional[Dict[str, Any]] = None,
|
|
80
|
+
version: Optional[int] = None,
|
|
81
|
+
channel: Optional[ChannelLike] = None,
|
|
82
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
83
|
+
scheduled_at: Optional[ScheduledAt] = None,
|
|
84
|
+
cc: Optional[List[str]] = None,
|
|
85
|
+
bcc: Optional[List[str]] = None,
|
|
86
|
+
reply_to: Optional[str] = None,
|
|
87
|
+
attachments: Optional[list] = None,
|
|
88
|
+
idempotency_key: Optional[str] = None,
|
|
89
|
+
) -> SendResult:
|
|
90
|
+
"""Send a stored template to one recipient."""
|
|
91
|
+
return self._send(
|
|
92
|
+
TemplateSend(
|
|
93
|
+
template=template,
|
|
94
|
+
to=to,
|
|
95
|
+
vars=vars,
|
|
96
|
+
version=version,
|
|
97
|
+
channel=channel,
|
|
98
|
+
metadata=metadata,
|
|
99
|
+
scheduled_at=scheduled_at,
|
|
100
|
+
cc=cc,
|
|
101
|
+
bcc=bcc,
|
|
102
|
+
reply_to=reply_to,
|
|
103
|
+
attachments=attachments,
|
|
104
|
+
idempotency_key=idempotency_key,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def send_raw(
|
|
109
|
+
self,
|
|
110
|
+
to: str,
|
|
111
|
+
content: Content,
|
|
112
|
+
*,
|
|
113
|
+
channel: Optional[ChannelLike] = None,
|
|
114
|
+
from_: Optional[str] = None,
|
|
115
|
+
interpolate: Optional[bool] = None,
|
|
116
|
+
vars: Optional[Dict[str, Any]] = None,
|
|
117
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
118
|
+
scheduled_at: Optional[ScheduledAt] = None,
|
|
119
|
+
idempotency_key: Optional[str] = None,
|
|
120
|
+
) -> SendResult:
|
|
121
|
+
"""Send inline content (no template). ``channel`` is inferred from ``content``."""
|
|
122
|
+
return self._send(
|
|
123
|
+
RawSend(
|
|
124
|
+
to=to,
|
|
125
|
+
content=content,
|
|
126
|
+
channel=channel,
|
|
127
|
+
from_=from_,
|
|
128
|
+
interpolate=interpolate,
|
|
129
|
+
vars=vars,
|
|
130
|
+
metadata=metadata,
|
|
131
|
+
scheduled_at=scheduled_at,
|
|
132
|
+
idempotency_key=idempotency_key,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _send(self, request: SendItem, idempotency_key: Optional[str] = None) -> SendResult:
|
|
137
|
+
body, own_key = build_send(request)
|
|
138
|
+
key = idempotency_key or own_key or str(uuid.uuid4())
|
|
139
|
+
data = self._transport.request_json("POST", "/v1/send", body=body, idempotency_key=key)
|
|
140
|
+
return SendResult.from_dict(data)
|
|
141
|
+
|
|
142
|
+
def send_batch(
|
|
143
|
+
self,
|
|
144
|
+
requests: Sequence[SendItem],
|
|
145
|
+
*,
|
|
146
|
+
concurrency: int = 5,
|
|
147
|
+
idempotency_key: Optional[str] = None,
|
|
148
|
+
) -> List[BatchResult]:
|
|
149
|
+
"""Send many messages concurrently. Each result is positionally aligned
|
|
150
|
+
with ``requests``; a per-item failure becomes a ``BatchResult(ok=False)``
|
|
151
|
+
rather than aborting the batch."""
|
|
152
|
+
results: List[Optional[BatchResult]] = [None] * len(requests)
|
|
153
|
+
|
|
154
|
+
def run(index: int, request: SendItem) -> BatchResult:
|
|
155
|
+
try:
|
|
156
|
+
key = _batch_key(idempotency_key, request, index)
|
|
157
|
+
result = self._send(request, idempotency_key=key)
|
|
158
|
+
return BatchResult(ok=True, index=index, result=result)
|
|
159
|
+
except errors.SenderKitError as exc:
|
|
160
|
+
return BatchResult(ok=False, index=index, error=exc)
|
|
161
|
+
|
|
162
|
+
if not requests:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
|
|
166
|
+
for outcome in pool.map(lambda pair: run(*pair), list(enumerate(requests))):
|
|
167
|
+
results[outcome.index] = outcome
|
|
168
|
+
|
|
169
|
+
return [r for r in results if r is not None]
|
|
170
|
+
|
|
171
|
+
def context(self) -> Context:
|
|
172
|
+
"""Return the workspace and mode this API key operates in."""
|
|
173
|
+
return Context.from_dict(self._transport.request_json("GET", "/v1/context"))
|
|
174
|
+
|
|
175
|
+
def close(self) -> None:
|
|
176
|
+
self._transport.close()
|
|
177
|
+
|
|
178
|
+
def __enter__(self) -> SenderKit:
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(self, *exc: object) -> None:
|
|
182
|
+
self.close()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AsyncSenderKit:
|
|
186
|
+
"""Asynchronous SenderKit client. Mirrors :class:`SenderKit` with ``await``."""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
api_key: str,
|
|
191
|
+
*,
|
|
192
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
193
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
194
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
195
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
if not api_key:
|
|
198
|
+
raise ValueError("api_key is required")
|
|
199
|
+
self.mode = _mode_for_key(api_key)
|
|
200
|
+
self._transport = AsyncTransport(api_key, base_url, timeout, max_retries, http_client)
|
|
201
|
+
self.messages = AsyncMessages(self._transport)
|
|
202
|
+
self.templates = AsyncTemplates(self._transport)
|
|
203
|
+
|
|
204
|
+
async def send(
|
|
205
|
+
self,
|
|
206
|
+
template: str,
|
|
207
|
+
to: str,
|
|
208
|
+
*,
|
|
209
|
+
vars: Optional[Dict[str, Any]] = None,
|
|
210
|
+
version: Optional[int] = None,
|
|
211
|
+
channel: Optional[ChannelLike] = None,
|
|
212
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
213
|
+
scheduled_at: Optional[ScheduledAt] = None,
|
|
214
|
+
cc: Optional[List[str]] = None,
|
|
215
|
+
bcc: Optional[List[str]] = None,
|
|
216
|
+
reply_to: Optional[str] = None,
|
|
217
|
+
attachments: Optional[list] = None,
|
|
218
|
+
idempotency_key: Optional[str] = None,
|
|
219
|
+
) -> SendResult:
|
|
220
|
+
return await self._send(
|
|
221
|
+
TemplateSend(
|
|
222
|
+
template=template,
|
|
223
|
+
to=to,
|
|
224
|
+
vars=vars,
|
|
225
|
+
version=version,
|
|
226
|
+
channel=channel,
|
|
227
|
+
metadata=metadata,
|
|
228
|
+
scheduled_at=scheduled_at,
|
|
229
|
+
cc=cc,
|
|
230
|
+
bcc=bcc,
|
|
231
|
+
reply_to=reply_to,
|
|
232
|
+
attachments=attachments,
|
|
233
|
+
idempotency_key=idempotency_key,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def send_raw(
|
|
238
|
+
self,
|
|
239
|
+
to: str,
|
|
240
|
+
content: Content,
|
|
241
|
+
*,
|
|
242
|
+
channel: Optional[ChannelLike] = None,
|
|
243
|
+
from_: Optional[str] = None,
|
|
244
|
+
interpolate: Optional[bool] = None,
|
|
245
|
+
vars: Optional[Dict[str, Any]] = None,
|
|
246
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
247
|
+
scheduled_at: Optional[ScheduledAt] = None,
|
|
248
|
+
idempotency_key: Optional[str] = None,
|
|
249
|
+
) -> SendResult:
|
|
250
|
+
return await self._send(
|
|
251
|
+
RawSend(
|
|
252
|
+
to=to,
|
|
253
|
+
content=content,
|
|
254
|
+
channel=channel,
|
|
255
|
+
from_=from_,
|
|
256
|
+
interpolate=interpolate,
|
|
257
|
+
vars=vars,
|
|
258
|
+
metadata=metadata,
|
|
259
|
+
scheduled_at=scheduled_at,
|
|
260
|
+
idempotency_key=idempotency_key,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _send(self, request: SendItem, idempotency_key: Optional[str] = None) -> SendResult:
|
|
265
|
+
body, own_key = build_send(request)
|
|
266
|
+
key = idempotency_key or own_key or str(uuid.uuid4())
|
|
267
|
+
data = await self._transport.request_json(
|
|
268
|
+
"POST", "/v1/send", body=body, idempotency_key=key
|
|
269
|
+
)
|
|
270
|
+
return SendResult.from_dict(data)
|
|
271
|
+
|
|
272
|
+
async def send_batch(
|
|
273
|
+
self,
|
|
274
|
+
requests: Sequence[SendItem],
|
|
275
|
+
*,
|
|
276
|
+
concurrency: int = 5,
|
|
277
|
+
idempotency_key: Optional[str] = None,
|
|
278
|
+
) -> List[BatchResult]:
|
|
279
|
+
if not requests:
|
|
280
|
+
return []
|
|
281
|
+
semaphore = asyncio.Semaphore(max(1, concurrency))
|
|
282
|
+
|
|
283
|
+
async def run(index: int, request: SendItem) -> BatchResult:
|
|
284
|
+
async with semaphore:
|
|
285
|
+
try:
|
|
286
|
+
key = _batch_key(idempotency_key, request, index)
|
|
287
|
+
result = await self._send(request, idempotency_key=key)
|
|
288
|
+
return BatchResult(ok=True, index=index, result=result)
|
|
289
|
+
except errors.SenderKitError as exc:
|
|
290
|
+
return BatchResult(ok=False, index=index, error=exc)
|
|
291
|
+
|
|
292
|
+
outcomes = await asyncio.gather(*(run(i, r) for i, r in enumerate(requests)))
|
|
293
|
+
return sorted(outcomes, key=lambda r: r.index)
|
|
294
|
+
|
|
295
|
+
async def context(self) -> Context:
|
|
296
|
+
return Context.from_dict(await self._transport.request_json("GET", "/v1/context"))
|
|
297
|
+
|
|
298
|
+
async def aclose(self) -> None:
|
|
299
|
+
await self._transport.aclose()
|
|
300
|
+
|
|
301
|
+
async def __aenter__(self) -> AsyncSenderKit:
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
305
|
+
await self.aclose()
|
senderkit/errors.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Exception hierarchy for the SenderKit SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript and PHP SDKs: a single ``SenderKitError`` base, an
|
|
4
|
+
``APIError`` for non-2xx responses (with status-specific subclasses), plus
|
|
5
|
+
transport-level ``TimeoutError`` / ``NetworkError`` and a webhook
|
|
6
|
+
``SignatureVerificationError``. Catch the base ``SenderKitError`` to handle
|
|
7
|
+
everything the SDK can raise, or a specific subclass for granular handling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SenderKitError(Exception):
|
|
16
|
+
"""Base class for every error raised by the SDK."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIError(SenderKitError):
|
|
20
|
+
"""The API returned a non-2xx response.
|
|
21
|
+
|
|
22
|
+
``status`` is the HTTP status code, ``code`` the machine-readable error code
|
|
23
|
+
from the response body (e.g. ``invalid_request``), ``issues`` any structured
|
|
24
|
+
validation detail, and ``request_id`` the ``x-request-id`` header for support.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
status: int,
|
|
32
|
+
code: Optional[str] = None,
|
|
33
|
+
issues: Any = None,
|
|
34
|
+
request_id: Optional[str] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.status = status
|
|
38
|
+
self.code = code
|
|
39
|
+
self.issues = issues
|
|
40
|
+
self.request_id = request_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthenticationError(APIError):
|
|
44
|
+
"""401/403 — the API key is missing, invalid, or revoked."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ValidationError(APIError):
|
|
48
|
+
"""400/422 — the request was malformed or failed validation."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PaymentRequiredError(APIError):
|
|
52
|
+
"""402 — a plan limit was reached (e.g. ``message_limit_reached``)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConflictError(APIError):
|
|
56
|
+
"""409 — the resource is in a state that disallows the action.
|
|
57
|
+
|
|
58
|
+
Raised by ``messages.cancel`` when a message has already been dispatched
|
|
59
|
+
(``not_cancelable``); the message's freshly observed status is in ``code``/message.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RateLimitError(APIError):
|
|
64
|
+
"""429 — too many requests. ``retry_after`` is the server's hint, in seconds."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
*,
|
|
70
|
+
status: int = 429,
|
|
71
|
+
code: Optional[str] = None,
|
|
72
|
+
issues: Any = None,
|
|
73
|
+
request_id: Optional[str] = None,
|
|
74
|
+
retry_after: Optional[float] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message, status=status, code=code, issues=issues, request_id=request_id)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TimeoutError(SenderKitError):
|
|
81
|
+
"""The request exceeded the configured timeout (shadows builtins.TimeoutError)."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NetworkError(SenderKitError):
|
|
85
|
+
"""A transport-level failure occurred before a response was received."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SignatureVerificationError(SenderKitError):
|
|
89
|
+
"""A webhook payload failed signature verification."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Framework integrations.
|
|
2
|
+
|
|
3
|
+
Each submodule imports its framework lazily, so installing the core SDK never
|
|
4
|
+
pulls in Django/FastAPI/Flask/Celery. Import the one you need:
|
|
5
|
+
|
|
6
|
+
from senderkit.integrations.django import EmailBackend, get_client, senderkit_webhook
|
|
7
|
+
from senderkit.integrations.fastapi import get_senderkit, webhook_verifier
|
|
8
|
+
from senderkit.integrations.flask import SenderKitFlask
|
|
9
|
+
from senderkit.integrations.celery import make_send_task
|
|
10
|
+
"""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Celery integration: a retryable background send task.
|
|
2
|
+
|
|
3
|
+
from celery import Celery
|
|
4
|
+
from senderkit import SenderKit
|
|
5
|
+
from senderkit.integrations.celery import make_send_task
|
|
6
|
+
|
|
7
|
+
celery_app = Celery("app", broker="redis://localhost:6379/0")
|
|
8
|
+
send_email = make_send_task(celery_app, lambda: SenderKit(api_key="sk_..."))
|
|
9
|
+
|
|
10
|
+
send_email.delay("welcome", "user@example.com", vars={"name": "Ada"})
|
|
11
|
+
|
|
12
|
+
The task calls ``client.send(template, to, **kwargs)`` and returns the result as a
|
|
13
|
+
dict (JSON-serializable for result backends). Transient failures — rate limits,
|
|
14
|
+
network errors, timeouts — are retried by Celery with exponential backoff, on top
|
|
15
|
+
of the SDK's own in-request retries for 5xx responses.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Callable, Dict, Tuple, Type
|
|
21
|
+
|
|
22
|
+
from ..client import SenderKit
|
|
23
|
+
from ..errors import NetworkError, RateLimitError, SenderKitError, TimeoutError
|
|
24
|
+
|
|
25
|
+
# Transient errors worth retrying at the task level. (5xx is already retried inside
|
|
26
|
+
# the SDK, so a surfaced APIError is treated as terminal here.)
|
|
27
|
+
TRANSIENT_ERRORS: Tuple[Type[SenderKitError], ...] = (
|
|
28
|
+
RateLimitError,
|
|
29
|
+
NetworkError,
|
|
30
|
+
TimeoutError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_send_task(
|
|
35
|
+
celery_app: Any,
|
|
36
|
+
client_factory: Callable[[], SenderKit],
|
|
37
|
+
*,
|
|
38
|
+
name: str = "senderkit.send",
|
|
39
|
+
max_retries: int = 3,
|
|
40
|
+
) -> Any:
|
|
41
|
+
"""Register and return a ``send(template, to, **kwargs)`` Celery task."""
|
|
42
|
+
|
|
43
|
+
@celery_app.task(
|
|
44
|
+
bind=True,
|
|
45
|
+
name=name,
|
|
46
|
+
max_retries=max_retries,
|
|
47
|
+
autoretry_for=TRANSIENT_ERRORS,
|
|
48
|
+
retry_backoff=True,
|
|
49
|
+
retry_jitter=True,
|
|
50
|
+
)
|
|
51
|
+
def send_task(self: Any, template: str, to: str, **kwargs: Any) -> Dict[str, Any]:
|
|
52
|
+
client = client_factory()
|
|
53
|
+
result = client.send(template, to, **kwargs)
|
|
54
|
+
return {"id": result.id, "status": result.status, "livemode": result.livemode}
|
|
55
|
+
|
|
56
|
+
return send_task
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Django integration for SenderKit.
|
|
2
|
+
|
|
3
|
+
Configure via a ``SENDERKIT`` dict in settings::
|
|
4
|
+
|
|
5
|
+
SENDERKIT = {
|
|
6
|
+
"API_KEY": env("SENDERKIT_API_KEY"),
|
|
7
|
+
"BASE_URL": "https://api.senderkit.com", # optional
|
|
8
|
+
"TIMEOUT": 30.0, # optional, seconds
|
|
9
|
+
"MAX_RETRIES": 2, # optional
|
|
10
|
+
"WEBHOOK_SECRET": env("SENDERKIT_WEBHOOK_SECRET"), # for webhooks
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Use it three ways:
|
|
14
|
+
|
|
15
|
+
- ``EMAIL_BACKEND = "senderkit.integrations.django.EmailBackend"`` to route
|
|
16
|
+
``django.core.mail`` through SenderKit.
|
|
17
|
+
- ``get_client()`` for a configured :class:`~senderkit.SenderKit` instance.
|
|
18
|
+
- ``@senderkit_webhook`` to verify and dispatch inbound webhooks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .backends import EmailBackend
|
|
22
|
+
from .client import get_client, reset_client
|
|
23
|
+
from .views import senderkit_webhook
|
|
24
|
+
|
|
25
|
+
__all__ = ["EmailBackend", "get_client", "reset_client", "senderkit_webhook"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""A Django ``EMAIL_BACKEND`` that routes mail through SenderKit's raw-send API.
|
|
2
|
+
|
|
3
|
+
Because Django renders messages locally, sends through this backend bypass
|
|
4
|
+
SenderKit templates (you lose versioning, preview, and per-template analytics).
|
|
5
|
+
Use it to migrate an existing ``django.core.mail`` app without code changes; for
|
|
6
|
+
new code, prefer ``get_client().send(...)`` with a template.
|
|
7
|
+
|
|
8
|
+
Messages with multiple ``to`` recipients are fanned out as one API call each,
|
|
9
|
+
since the API addresses a single recipient per send (mirrors the PHP Laravel
|
|
10
|
+
``SenderKitTransport``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
from email.mime.base import MIMEBase
|
|
17
|
+
from typing import Any, List, Optional
|
|
18
|
+
|
|
19
|
+
from django.core.mail.backends.base import BaseEmailBackend
|
|
20
|
+
|
|
21
|
+
from ...errors import SenderKitError
|
|
22
|
+
from ...models import Attachment, EmailContent
|
|
23
|
+
from .client import get_client
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _to_text(value: Any) -> Optional[str]:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(value, bytes):
|
|
30
|
+
return value.decode("utf-8", "replace")
|
|
31
|
+
return str(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _html_body(message: Any) -> str:
|
|
35
|
+
"""The API requires HTML. Prefer an explicit text/html alternative, then an
|
|
36
|
+
html ``content_subtype`` body, falling back to an escaped plain-text body."""
|
|
37
|
+
for content, mimetype in getattr(message, "alternatives", None) or []:
|
|
38
|
+
if mimetype == "text/html":
|
|
39
|
+
return _to_text(content) or ""
|
|
40
|
+
if getattr(message, "content_subtype", "plain") == "html":
|
|
41
|
+
return _to_text(message.body) or ""
|
|
42
|
+
from django.utils.html import escape
|
|
43
|
+
|
|
44
|
+
return escape(_to_text(message.body) or "").replace("\n", "<br>")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _text_body(message: Any) -> Optional[str]:
|
|
48
|
+
if getattr(message, "content_subtype", "plain") == "html":
|
|
49
|
+
return None
|
|
50
|
+
return _to_text(message.body)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _addresses(values: Any) -> Optional[List[str]]:
|
|
54
|
+
items = [v for v in (values or []) if v]
|
|
55
|
+
return items or None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _attachments(message: Any) -> Optional[List[Attachment]]:
|
|
59
|
+
out: List[Attachment] = []
|
|
60
|
+
for attachment in getattr(message, "attachments", None) or []:
|
|
61
|
+
if isinstance(attachment, MIMEBase):
|
|
62
|
+
filename = attachment.get_filename() or "attachment"
|
|
63
|
+
content_type = attachment.get_content_type()
|
|
64
|
+
decoded = attachment.get_payload(decode=True)
|
|
65
|
+
payload = decoded if isinstance(decoded, (bytes, bytearray)) else b""
|
|
66
|
+
content = base64.b64encode(payload).decode()
|
|
67
|
+
else:
|
|
68
|
+
filename, raw, content_type = attachment
|
|
69
|
+
data = raw.encode() if isinstance(raw, str) else raw
|
|
70
|
+
content = base64.b64encode(data).decode()
|
|
71
|
+
out.append(
|
|
72
|
+
Attachment(
|
|
73
|
+
filename=filename or "attachment",
|
|
74
|
+
content_type=content_type or "application/octet-stream",
|
|
75
|
+
content=content,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return out or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EmailBackend(BaseEmailBackend):
|
|
82
|
+
"""Sends Django email messages via SenderKit raw email sends."""
|
|
83
|
+
|
|
84
|
+
def send_messages(self, email_messages: Any) -> int:
|
|
85
|
+
if not email_messages:
|
|
86
|
+
return 0
|
|
87
|
+
client = get_client()
|
|
88
|
+
sent = 0
|
|
89
|
+
for message in email_messages:
|
|
90
|
+
content = EmailContent(
|
|
91
|
+
subject=str(message.subject or ""),
|
|
92
|
+
html=_html_body(message),
|
|
93
|
+
text=_text_body(message),
|
|
94
|
+
cc=_addresses(getattr(message, "cc", None)),
|
|
95
|
+
bcc=_addresses(getattr(message, "bcc", None)),
|
|
96
|
+
reply_to=(getattr(message, "reply_to", None) or [None])[0],
|
|
97
|
+
attachments=_attachments(message),
|
|
98
|
+
)
|
|
99
|
+
from_email = message.from_email or None
|
|
100
|
+
recipients = getattr(message, "to", None) or []
|
|
101
|
+
try:
|
|
102
|
+
for recipient in recipients:
|
|
103
|
+
client.send_raw(recipient, content, from_=from_email)
|
|
104
|
+
sent += 1
|
|
105
|
+
except SenderKitError:
|
|
106
|
+
if not self.fail_silently:
|
|
107
|
+
raise
|
|
108
|
+
return sent
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Build and cache a :class:`~senderkit.SenderKit` from Django settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from ...client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, SenderKit
|
|
8
|
+
|
|
9
|
+
_client: Optional[SenderKit] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_config() -> Dict[str, Any]:
|
|
13
|
+
"""Return the ``SENDERKIT`` settings dict (empty if unset)."""
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
|
|
16
|
+
return dict(getattr(settings, "SENDERKIT", {}) or {})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_client() -> SenderKit:
|
|
20
|
+
"""Return a process-wide :class:`~senderkit.SenderKit` built from settings."""
|
|
21
|
+
global _client
|
|
22
|
+
if _client is None:
|
|
23
|
+
config = get_config()
|
|
24
|
+
api_key = config.get("API_KEY")
|
|
25
|
+
if not api_key:
|
|
26
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
27
|
+
|
|
28
|
+
raise ImproperlyConfigured("SENDERKIT['API_KEY'] is not set.")
|
|
29
|
+
_client = SenderKit(
|
|
30
|
+
api_key=api_key,
|
|
31
|
+
base_url=config.get("BASE_URL", DEFAULT_BASE_URL),
|
|
32
|
+
timeout=config.get("TIMEOUT", DEFAULT_TIMEOUT),
|
|
33
|
+
max_retries=config.get("MAX_RETRIES", DEFAULT_MAX_RETRIES),
|
|
34
|
+
)
|
|
35
|
+
return _client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def reset_client() -> None:
|
|
39
|
+
"""Drop the cached client (useful in tests and after settings changes)."""
|
|
40
|
+
global _client
|
|
41
|
+
if _client is not None:
|
|
42
|
+
_client.close()
|
|
43
|
+
_client = None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""A decorator that verifies a SenderKit webhook before calling your view.
|
|
2
|
+
|
|
3
|
+
from senderkit.integrations.django import senderkit_webhook
|
|
4
|
+
|
|
5
|
+
@senderkit_webhook
|
|
6
|
+
def handle(request, event):
|
|
7
|
+
if event.type == "message.delivered":
|
|
8
|
+
...
|
|
9
|
+
return HttpResponse(status=204)
|
|
10
|
+
|
|
11
|
+
The secret comes from ``SENDERKIT['WEBHOOK_SECRET']`` unless passed explicitly.
|
|
12
|
+
A missing/invalid/stale signature yields an HTTP 400 before your view runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from functools import wraps
|
|
18
|
+
from typing import Any, Callable, Optional
|
|
19
|
+
|
|
20
|
+
from ...errors import SignatureVerificationError
|
|
21
|
+
from ...webhooks import DEFAULT_TOLERANCE_SECONDS, WebhookVerifier
|
|
22
|
+
from .client import get_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def senderkit_webhook(
|
|
26
|
+
view: Optional[Callable[..., Any]] = None,
|
|
27
|
+
*,
|
|
28
|
+
secret: Optional[str] = None,
|
|
29
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
30
|
+
) -> Callable[..., Any]:
|
|
31
|
+
"""Wrap a view ``(request, event, *args, **kwargs)``; usable bare or with args."""
|
|
32
|
+
|
|
33
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
34
|
+
from django.http import HttpResponseBadRequest
|
|
35
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
36
|
+
|
|
37
|
+
@csrf_exempt
|
|
38
|
+
@wraps(fn)
|
|
39
|
+
def wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
signing_secret = secret or get_config().get("WEBHOOK_SECRET")
|
|
41
|
+
try:
|
|
42
|
+
event = WebhookVerifier(signing_secret).verify(
|
|
43
|
+
request.body,
|
|
44
|
+
request.headers.get("X-SenderKit-Signature", ""),
|
|
45
|
+
tolerance=tolerance,
|
|
46
|
+
event_type=request.headers.get("X-SenderKit-Event"),
|
|
47
|
+
delivery_id=request.headers.get("X-SenderKit-Delivery"),
|
|
48
|
+
)
|
|
49
|
+
except SignatureVerificationError as exc:
|
|
50
|
+
return HttpResponseBadRequest(str(exc))
|
|
51
|
+
return fn(request, event, *args, **kwargs)
|
|
52
|
+
|
|
53
|
+
return wrapper
|
|
54
|
+
|
|
55
|
+
return decorator(view) if view is not None else decorator
|