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