sendbyte 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {sendbyte-0.1.0/sendbyte.egg-info → sendbyte-0.2.0}/PKG-INFO +1 -1
- {sendbyte-0.1.0 → sendbyte-0.2.0}/pyproject.toml +8 -1
- {sendbyte-0.1.0 → sendbyte-0.2.0}/sendbyte/__init__.py +200 -25
- {sendbyte-0.1.0 → sendbyte-0.2.0/sendbyte.egg-info}/PKG-INFO +1 -1
- sendbyte-0.2.0/tests/test_sdk.py +257 -0
- sendbyte-0.1.0/tests/test_sdk.py +0 -138
- {sendbyte-0.1.0 → sendbyte-0.2.0}/LICENSE +0 -0
- {sendbyte-0.1.0 → sendbyte-0.2.0}/README.md +0 -0
- {sendbyte-0.1.0 → sendbyte-0.2.0}/sendbyte/py.typed +0 -0
- {sendbyte-0.1.0 → sendbyte-0.2.0}/sendbyte.egg-info/SOURCES.txt +0 -0
- {sendbyte-0.1.0 → sendbyte-0.2.0}/sendbyte.egg-info/dependency_links.txt +0 -0
- {sendbyte-0.1.0 → sendbyte-0.2.0}/sendbyte.egg-info/top_level.txt +0 -0
- {sendbyte-0.1.0 → sendbyte-0.2.0}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sendbyte"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "SendByte Python SDK: the email API for Africa."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -21,6 +21,9 @@ classifiers = [
|
|
|
21
21
|
# Zero runtime dependencies: stdlib urllib for transport.
|
|
22
22
|
dependencies = []
|
|
23
23
|
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
include-package-data = true
|
|
26
|
+
|
|
24
27
|
[project.urls]
|
|
25
28
|
Homepage = "https://sendbyte.africa"
|
|
26
29
|
Documentation = "https://docs.sendbyte.africa"
|
|
@@ -29,3 +32,7 @@ Issues = "https://github.com/Sendbyte-Africa/sendbyte-python/issues"
|
|
|
29
32
|
|
|
30
33
|
[tool.setuptools.packages.find]
|
|
31
34
|
include = ["sendbyte*"]
|
|
35
|
+
|
|
36
|
+
# Ship the PEP 561 marker so type checkers treat the installed package as typed.
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
sendbyte = ["py.typed"]
|
|
@@ -17,14 +17,18 @@ same surface; errors carry the API's error shape (code, message, docs_url).
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
|
+
import email.utils as email_utils
|
|
20
21
|
import hashlib
|
|
21
22
|
import hmac
|
|
22
23
|
import json
|
|
24
|
+
import random
|
|
23
25
|
import time
|
|
24
26
|
import urllib.error
|
|
25
27
|
import urllib.parse
|
|
26
28
|
import urllib.request
|
|
29
|
+
import uuid
|
|
27
30
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
31
|
+
from urllib.parse import urlsplit
|
|
28
32
|
|
|
29
33
|
__all__ = [
|
|
30
34
|
"SendByte",
|
|
@@ -36,10 +40,16 @@ __all__ = [
|
|
|
36
40
|
|
|
37
41
|
DEFAULT_BASE_URL = "https://api.sendbyte.africa"
|
|
38
42
|
SIGNATURE_HEADER = "sendbyte-signature"
|
|
39
|
-
_VERSION = "0.
|
|
43
|
+
_VERSION = "0.2.0"
|
|
44
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
45
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
46
|
+
_MAX_BACKOFF_SECONDS = 20.0
|
|
40
47
|
|
|
41
|
-
# transport(method, url, headers, body) -> (status, response_text)
|
|
42
|
-
|
|
48
|
+
# transport(method, url, headers, body) -> (status, response_text). The default
|
|
49
|
+
# transport also exposes the response headers as a third tuple element so the
|
|
50
|
+
# retry policy can read Retry-After; a custom transport may return only
|
|
51
|
+
# (status, text), in which case headers are treated as absent.
|
|
52
|
+
Transport = Callable[..., Tuple]
|
|
43
53
|
|
|
44
54
|
|
|
45
55
|
class SendByteError(Exception):
|
|
@@ -52,19 +62,76 @@ class SendByteError(Exception):
|
|
|
52
62
|
self.docs_url = docs_url
|
|
53
63
|
|
|
54
64
|
|
|
65
|
+
def _is_secure_base_url(base_url: str) -> bool:
|
|
66
|
+
"""https is required; http is tolerated only for localhost/loopback dev hosts."""
|
|
67
|
+
parts = urlsplit(base_url)
|
|
68
|
+
if parts.scheme == "https":
|
|
69
|
+
return True
|
|
70
|
+
host = parts.hostname or ""
|
|
71
|
+
return host in ("localhost", "127.0.0.1", "::1")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_retryable_status(status: int) -> bool:
|
|
75
|
+
return status == 429 or 500 <= status < 600
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
79
|
+
if not value:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return max(0.0, float(value))
|
|
83
|
+
except ValueError:
|
|
84
|
+
pass
|
|
85
|
+
try:
|
|
86
|
+
parsed = email_utils.parsedate_to_datetime(value)
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
if parsed is None:
|
|
90
|
+
return None
|
|
91
|
+
return max(0.0, parsed.timestamp() - time.time())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _backoff_delay(attempt: int, retry_after: Optional[str]) -> float:
|
|
95
|
+
"""Exponential backoff with full jitter, capped, honoring Retry-After.
|
|
96
|
+
|
|
97
|
+
attempt is 1-based.
|
|
98
|
+
"""
|
|
99
|
+
after = _parse_retry_after(retry_after)
|
|
100
|
+
if after is not None:
|
|
101
|
+
return min(after, _MAX_BACKOFF_SECONDS)
|
|
102
|
+
base = min(_MAX_BACKOFF_SECONDS, 0.25 * (2 ** (attempt - 1)))
|
|
103
|
+
return random.random() * base
|
|
104
|
+
|
|
105
|
+
|
|
55
106
|
def _default_transport(
|
|
56
|
-
method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
|
|
57
|
-
) -> Tuple[int, str]:
|
|
107
|
+
method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float
|
|
108
|
+
) -> Tuple[int, str, Dict[str, str]]:
|
|
58
109
|
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
59
110
|
try:
|
|
60
|
-
with urllib.request.urlopen(request, timeout=
|
|
61
|
-
return response.status, response.read().decode("utf-8")
|
|
111
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
112
|
+
return response.status, response.read().decode("utf-8"), dict(response.headers)
|
|
62
113
|
except urllib.error.HTTPError as err: # non-2xx still carries a JSON body
|
|
63
|
-
return err.code, err.read().decode("utf-8")
|
|
114
|
+
return err.code, err.read().decode("utf-8"), dict(err.headers or {})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_transport_result(result: Tuple) -> Tuple[int, str, Dict[str, str]]:
|
|
118
|
+
"""Accept (status, text) or (status, text, headers) from any transport."""
|
|
119
|
+
if len(result) >= 3:
|
|
120
|
+
status, text, raw = result[0], result[1], result[2]
|
|
121
|
+
headers = {str(k).lower(): str(v) for k, v in (raw or {}).items()}
|
|
122
|
+
return status, text, headers
|
|
123
|
+
return result[0], result[1], {}
|
|
64
124
|
|
|
65
125
|
|
|
66
126
|
class _Http:
|
|
67
|
-
def __init__(
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
api_key: str,
|
|
130
|
+
base_url: str,
|
|
131
|
+
transport: Optional[Transport],
|
|
132
|
+
max_attempts: int,
|
|
133
|
+
timeout: float,
|
|
134
|
+
):
|
|
68
135
|
if not api_key or not (api_key.startswith("sk_live_") or api_key.startswith("sk_test_")):
|
|
69
136
|
raise SendByteError(
|
|
70
137
|
"invalid_api_key",
|
|
@@ -74,30 +141,111 @@ class _Http:
|
|
|
74
141
|
)
|
|
75
142
|
self.api_key = api_key
|
|
76
143
|
self.base_url = base_url.rstrip("/")
|
|
77
|
-
self.transport = transport
|
|
144
|
+
self.transport = transport
|
|
145
|
+
self.max_attempts = max(1, max_attempts)
|
|
146
|
+
self.timeout = max(0.001, timeout)
|
|
147
|
+
|
|
148
|
+
# A live key over a non-https base URL would send the secret in cleartext.
|
|
149
|
+
if api_key.startswith("sk_live_") and not _is_secure_base_url(self.base_url):
|
|
150
|
+
raise SendByteError(
|
|
151
|
+
"insecure_base_url",
|
|
152
|
+
"Refusing to send a live API key (sk_live_) over an insecure base URL. "
|
|
153
|
+
"Use https, or a test key for local http.",
|
|
154
|
+
0,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _call_transport(
|
|
158
|
+
self, method: str, url: str, headers: Dict[str, str], payload: Optional[bytes]
|
|
159
|
+
) -> Tuple[int, str, Dict[str, str]]:
|
|
160
|
+
if self.transport is not None:
|
|
161
|
+
# A custom transport keeps the historical (method, url, headers, body)
|
|
162
|
+
# signature; only the default transport receives the timeout.
|
|
163
|
+
return _normalize_transport_result(self.transport(method, url, headers, payload))
|
|
164
|
+
return _default_transport(method, url, headers, payload, self.timeout)
|
|
78
165
|
|
|
79
166
|
def request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
167
|
+
# Auto-generate an idempotency key for sends and reuse it across retries
|
|
168
|
+
# so a retried POST /v1/emails cannot double-send. Body-based, matching
|
|
169
|
+
# the API.
|
|
170
|
+
payload_body = body
|
|
171
|
+
if (
|
|
172
|
+
method == "POST"
|
|
173
|
+
and path == "/v1/emails"
|
|
174
|
+
and isinstance(payload_body, dict)
|
|
175
|
+
and payload_body.get("idempotency_key") is None
|
|
176
|
+
):
|
|
177
|
+
payload_body = {**payload_body, "idempotency_key": str(uuid.uuid4())}
|
|
178
|
+
|
|
80
179
|
headers = {
|
|
81
180
|
"authorization": f"Bearer {self.api_key}",
|
|
82
181
|
"user-agent": f"sendbyte-python/{_VERSION}",
|
|
83
182
|
}
|
|
84
183
|
payload: Optional[bytes] = None
|
|
85
|
-
if
|
|
184
|
+
if payload_body is not None:
|
|
86
185
|
headers["content-type"] = "application/json"
|
|
87
|
-
payload = json.dumps(
|
|
186
|
+
payload = json.dumps(payload_body).encode("utf-8")
|
|
187
|
+
|
|
188
|
+
url = f"{self.base_url}{path}"
|
|
189
|
+
last_error: Optional[SendByteError] = None
|
|
190
|
+
for attempt in range(1, self.max_attempts + 1):
|
|
191
|
+
try:
|
|
192
|
+
status, text, response_headers = self._call_transport(
|
|
193
|
+
method, url, headers, payload
|
|
194
|
+
)
|
|
195
|
+
except urllib.error.URLError as err:
|
|
196
|
+
# Network or timeout error: retry if attempts remain.
|
|
197
|
+
last_error = SendByteError(
|
|
198
|
+
"request_failed",
|
|
199
|
+
f"Could not reach the SendByte API: {err.reason}",
|
|
200
|
+
0,
|
|
201
|
+
)
|
|
202
|
+
if attempt < self.max_attempts:
|
|
203
|
+
time.sleep(_backoff_delay(attempt, None))
|
|
204
|
+
continue
|
|
205
|
+
raise last_error
|
|
206
|
+
|
|
207
|
+
if status >= 400:
|
|
208
|
+
if _is_retryable_status(status) and attempt < self.max_attempts:
|
|
209
|
+
last_error = _parse_error(text, status)
|
|
210
|
+
time.sleep(_backoff_delay(attempt, response_headers.get("retry-after")))
|
|
211
|
+
continue
|
|
212
|
+
raise _parse_error(text, status)
|
|
213
|
+
|
|
214
|
+
# 2xx: parse the success body, guarding against a non-JSON proxy response.
|
|
215
|
+
if not text:
|
|
216
|
+
return {}
|
|
217
|
+
try:
|
|
218
|
+
return json.loads(text)
|
|
219
|
+
except ValueError:
|
|
220
|
+
raise SendByteError(
|
|
221
|
+
"invalid_response",
|
|
222
|
+
f"The API returned a 2xx response that was not valid JSON (status {status}).",
|
|
223
|
+
status,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
raise last_error or SendByteError(
|
|
227
|
+
"request_failed", "Request failed after exhausting retries.", 0
|
|
228
|
+
)
|
|
88
229
|
|
|
89
|
-
status, text = self.transport(method, f"{self.base_url}{path}", headers, payload)
|
|
90
|
-
data = json.loads(text) if text else {}
|
|
91
230
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
231
|
+
def _parse_error(text: str, status: int) -> SendByteError:
|
|
232
|
+
"""Parse a non-2xx body into a typed error, never raising on non-JSON bodies."""
|
|
233
|
+
data: Any = {}
|
|
234
|
+
if text:
|
|
235
|
+
try:
|
|
236
|
+
data = json.loads(text)
|
|
237
|
+
except ValueError:
|
|
238
|
+
# A gateway/proxy 502/504 may return HTML or plain text.
|
|
239
|
+
return SendByteError(
|
|
240
|
+
"request_failed", f"Request failed with status {status}.", status
|
|
99
241
|
)
|
|
100
|
-
|
|
242
|
+
error = data.get("error", {}) if isinstance(data, dict) else {}
|
|
243
|
+
return SendByteError(
|
|
244
|
+
error.get("code", "request_failed"),
|
|
245
|
+
error.get("message", f"Request failed with status {status}"),
|
|
246
|
+
status,
|
|
247
|
+
error.get("docs_url"),
|
|
248
|
+
)
|
|
101
249
|
|
|
102
250
|
|
|
103
251
|
def _build_send_body(
|
|
@@ -116,6 +264,7 @@ def _build_send_body(
|
|
|
116
264
|
"attachments",
|
|
117
265
|
"tags",
|
|
118
266
|
"headers",
|
|
267
|
+
"list_unsubscribe",
|
|
119
268
|
"scheduled_at",
|
|
120
269
|
"template_id",
|
|
121
270
|
"variables",
|
|
@@ -200,7 +349,14 @@ class _Webhooks:
|
|
|
200
349
|
|
|
201
350
|
|
|
202
351
|
class SendByte:
|
|
203
|
-
"""Synchronous SendByte client.
|
|
352
|
+
"""Synchronous SendByte client.
|
|
353
|
+
|
|
354
|
+
Retries 429 and 5xx responses (and network errors) with exponential
|
|
355
|
+
backoff plus jitter, honoring Retry-After, up to ``max_attempts``. Each
|
|
356
|
+
attempt is bounded by ``timeout`` seconds. On POST /v1/emails an
|
|
357
|
+
idempotency key is generated and reused across retries when the caller did
|
|
358
|
+
not supply one, so a retried send cannot double-send.
|
|
359
|
+
"""
|
|
204
360
|
|
|
205
361
|
def __init__(
|
|
206
362
|
self,
|
|
@@ -208,8 +364,10 @@ class SendByte:
|
|
|
208
364
|
*,
|
|
209
365
|
base_url: str = DEFAULT_BASE_URL,
|
|
210
366
|
transport: Optional[Transport] = None,
|
|
367
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
368
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
211
369
|
):
|
|
212
|
-
http = _Http(api_key, base_url, transport)
|
|
370
|
+
http = _Http(api_key, base_url, transport, max_attempts, timeout)
|
|
213
371
|
self.emails = _Emails(http)
|
|
214
372
|
self.domains = _Domains(http)
|
|
215
373
|
self.webhooks = _Webhooks(http)
|
|
@@ -228,8 +386,16 @@ class AsyncSendByte:
|
|
|
228
386
|
*,
|
|
229
387
|
base_url: str = DEFAULT_BASE_URL,
|
|
230
388
|
transport: Optional[Transport] = None,
|
|
389
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
390
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
231
391
|
):
|
|
232
|
-
self._sync = SendByte(
|
|
392
|
+
self._sync = SendByte(
|
|
393
|
+
api_key,
|
|
394
|
+
base_url=base_url,
|
|
395
|
+
transport=transport,
|
|
396
|
+
max_attempts=max_attempts,
|
|
397
|
+
timeout=timeout,
|
|
398
|
+
)
|
|
233
399
|
|
|
234
400
|
async def _run(self, fn: Callable[[], Any]) -> Any:
|
|
235
401
|
loop = asyncio.get_event_loop()
|
|
@@ -253,6 +419,9 @@ class AsyncSendByte:
|
|
|
253
419
|
async def create_domain(self, domain: str) -> Dict[str, Any]:
|
|
254
420
|
return await self._run(lambda: self._sync.domains.create(domain))
|
|
255
421
|
|
|
422
|
+
async def get_domain(self, domain_id: str) -> Dict[str, Any]:
|
|
423
|
+
return await self._run(lambda: self._sync.domains.get(domain_id))
|
|
424
|
+
|
|
256
425
|
async def verify_domain(self, domain_id: str) -> Dict[str, Any]:
|
|
257
426
|
return await self._run(lambda: self._sync.domains.verify(domain_id))
|
|
258
427
|
|
|
@@ -266,6 +435,12 @@ class AsyncSendByte:
|
|
|
266
435
|
async def list_webhooks(self) -> Dict[str, Any]:
|
|
267
436
|
return await self._run(lambda: self._sync.webhooks.list())
|
|
268
437
|
|
|
438
|
+
async def disable_webhook(self, endpoint_id: str) -> Dict[str, Any]:
|
|
439
|
+
return await self._run(lambda: self._sync.webhooks.disable(endpoint_id))
|
|
440
|
+
|
|
441
|
+
async def replay_webhook(self, delivery_id: str) -> Dict[str, Any]:
|
|
442
|
+
return await self._run(lambda: self._sync.webhooks.replay(delivery_id))
|
|
443
|
+
|
|
269
444
|
|
|
270
445
|
def verify_webhook_signature(
|
|
271
446
|
secret: str,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""SDK tests: stdlib unittest, fake transport, no network."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from sendbyte import AsyncSendByte, SendByte, SendByteError, verify_webhook_signature
|
|
10
|
+
|
|
11
|
+
EMAIL = {
|
|
12
|
+
"id": "em_1",
|
|
13
|
+
"from": "a@b.ng",
|
|
14
|
+
"to": ["c@d.ng"],
|
|
15
|
+
"subject": "Hi",
|
|
16
|
+
"status": "queued",
|
|
17
|
+
"sandbox": True,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeTransport:
|
|
22
|
+
def __init__(self, status=200, body=None):
|
|
23
|
+
self.status = status
|
|
24
|
+
self.body = body if body is not None else EMAIL
|
|
25
|
+
self.calls = []
|
|
26
|
+
|
|
27
|
+
def __call__(self, method, url, headers, payload):
|
|
28
|
+
self.calls.append(
|
|
29
|
+
{
|
|
30
|
+
"method": method,
|
|
31
|
+
"url": url,
|
|
32
|
+
"headers": headers,
|
|
33
|
+
"body": json.loads(payload) if payload else None,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
return self.status, json.dumps(self.body)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SequenceTransport:
|
|
40
|
+
"""Returns a scripted list of (status, text[, headers]) tuples, one per call."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, responses):
|
|
43
|
+
self.responses = responses
|
|
44
|
+
self.calls = []
|
|
45
|
+
|
|
46
|
+
def __call__(self, method, url, headers, payload):
|
|
47
|
+
self.calls.append(
|
|
48
|
+
{
|
|
49
|
+
"method": method,
|
|
50
|
+
"url": url,
|
|
51
|
+
"headers": headers,
|
|
52
|
+
"body": json.loads(payload) if payload else None,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return self.responses[min(len(self.calls) - 1, len(self.responses) - 1)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ClientTests(unittest.TestCase):
|
|
59
|
+
def test_rejects_malformed_keys(self):
|
|
60
|
+
with self.assertRaises(SendByteError) as ctx:
|
|
61
|
+
SendByte("pk_live_nope")
|
|
62
|
+
self.assertEqual(ctx.exception.code, "invalid_api_key")
|
|
63
|
+
|
|
64
|
+
def test_sends_with_auth_and_body(self):
|
|
65
|
+
transport = FakeTransport(201)
|
|
66
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
67
|
+
result = client.emails.send(
|
|
68
|
+
from_="a@b.ng",
|
|
69
|
+
to="c@d.ng",
|
|
70
|
+
subject="Hi",
|
|
71
|
+
html="<p>x</p>",
|
|
72
|
+
idempotency_key="order-1",
|
|
73
|
+
)
|
|
74
|
+
self.assertEqual(result["id"], "em_1")
|
|
75
|
+
call = transport.calls[0]
|
|
76
|
+
self.assertEqual(call["url"], "https://api.test/v1/emails")
|
|
77
|
+
self.assertEqual(call["headers"]["authorization"], "Bearer sk_test_abc")
|
|
78
|
+
self.assertEqual(call["body"]["from"], "a@b.ng")
|
|
79
|
+
self.assertEqual(call["body"]["idempotency_key"], "order-1")
|
|
80
|
+
|
|
81
|
+
def test_rejects_unknown_send_kwargs(self):
|
|
82
|
+
client = SendByte("sk_test_abc", transport=FakeTransport())
|
|
83
|
+
with self.assertRaises(TypeError):
|
|
84
|
+
client.emails.send(from_="a@b.ng", to="c@d.ng", subject="x", htlm="typo")
|
|
85
|
+
|
|
86
|
+
def test_list_builds_query(self):
|
|
87
|
+
transport = FakeTransport(200, {"data": [], "has_more": False})
|
|
88
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
89
|
+
client.emails.list(limit=5, status="delivered")
|
|
90
|
+
self.assertEqual(transport.calls[0]["url"], "https://api.test/v1/emails?limit=5&status=delivered")
|
|
91
|
+
|
|
92
|
+
def test_maps_api_errors(self):
|
|
93
|
+
transport = FakeTransport(
|
|
94
|
+
403,
|
|
95
|
+
{
|
|
96
|
+
"error": {
|
|
97
|
+
"code": "domain_not_verified",
|
|
98
|
+
"message": "paylink.ng is not verified.",
|
|
99
|
+
"docs_url": "https://docs.sendbyte.africa/errors/domain_not_verified",
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
client = SendByte("sk_live_abc", base_url="https://api.test", transport=transport)
|
|
104
|
+
with self.assertRaises(SendByteError) as ctx:
|
|
105
|
+
client.emails.send(from_="a@paylink.ng", to="c@d.ng", subject="x", text="x")
|
|
106
|
+
self.assertEqual(ctx.exception.code, "domain_not_verified")
|
|
107
|
+
self.assertEqual(ctx.exception.status, 403)
|
|
108
|
+
self.assertIn("/errors/domain_not_verified", ctx.exception.docs_url)
|
|
109
|
+
|
|
110
|
+
def test_domains_and_webhooks_surfaces(self):
|
|
111
|
+
transport = FakeTransport(200, {"data": []})
|
|
112
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
113
|
+
client.domains.list()
|
|
114
|
+
client.webhooks.list()
|
|
115
|
+
urls = [c["url"] for c in transport.calls]
|
|
116
|
+
self.assertEqual(urls, ["https://api.test/v1/domains", "https://api.test/v1/webhooks"])
|
|
117
|
+
|
|
118
|
+
def test_async_client(self):
|
|
119
|
+
transport = FakeTransport(201)
|
|
120
|
+
client = AsyncSendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
121
|
+
|
|
122
|
+
async def run():
|
|
123
|
+
return await client.send_email(from_="a@b.ng", to="c@d.ng", subject="Hi", text="x")
|
|
124
|
+
|
|
125
|
+
result = asyncio.get_event_loop().run_until_complete(run())
|
|
126
|
+
self.assertEqual(result["id"], "em_1")
|
|
127
|
+
|
|
128
|
+
def test_auto_idempotency_key_when_absent(self):
|
|
129
|
+
transport = FakeTransport(201)
|
|
130
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
131
|
+
client.emails.send(from_="a@b.ng", to="c@d.ng", subject="Hi", text="x")
|
|
132
|
+
key = transport.calls[0]["body"]["idempotency_key"]
|
|
133
|
+
self.assertRegex(
|
|
134
|
+
key,
|
|
135
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def test_retries_429_and_reuses_idempotency_key(self):
|
|
139
|
+
transport = SequenceTransport(
|
|
140
|
+
[
|
|
141
|
+
(429, json.dumps({"error": {"code": "rate_limited"}}), {"retry-after": "0"}),
|
|
142
|
+
(201, json.dumps(EMAIL), {}),
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
146
|
+
result = client.emails.send(from_="a@b.ng", to="c@d.ng", subject="Hi", text="x")
|
|
147
|
+
self.assertEqual(result["id"], "em_1")
|
|
148
|
+
self.assertEqual(len(transport.calls), 2)
|
|
149
|
+
self.assertEqual(
|
|
150
|
+
transport.calls[0]["body"]["idempotency_key"],
|
|
151
|
+
transport.calls[1]["body"]["idempotency_key"],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def test_retries_5xx_then_raises_typed_error(self):
|
|
155
|
+
transport = SequenceTransport(
|
|
156
|
+
[(503, json.dumps({"error": {"code": "unavailable"}}), {})]
|
|
157
|
+
)
|
|
158
|
+
client = SendByte(
|
|
159
|
+
"sk_test_abc", base_url="https://api.test", transport=transport, max_attempts=2
|
|
160
|
+
)
|
|
161
|
+
with self.assertRaises(SendByteError) as ctx:
|
|
162
|
+
client.emails.send(from_="a@b.ng", to="c@d.ng", subject="x", text="x")
|
|
163
|
+
self.assertEqual(ctx.exception.status, 503)
|
|
164
|
+
self.assertEqual(len(transport.calls), 2)
|
|
165
|
+
|
|
166
|
+
def test_non_json_error_body_raises_typed_error(self):
|
|
167
|
+
transport = SequenceTransport([(502, "<html>502 Bad Gateway</html>", {})])
|
|
168
|
+
client = SendByte(
|
|
169
|
+
"sk_test_abc", base_url="https://api.test", transport=transport, max_attempts=1
|
|
170
|
+
)
|
|
171
|
+
with self.assertRaises(SendByteError) as ctx:
|
|
172
|
+
client.emails.send(from_="a@b.ng", to="c@d.ng", subject="x", text="x")
|
|
173
|
+
self.assertEqual(ctx.exception.code, "request_failed")
|
|
174
|
+
self.assertEqual(ctx.exception.status, 502)
|
|
175
|
+
|
|
176
|
+
def test_non_json_success_body_raises_invalid_response(self):
|
|
177
|
+
transport = SequenceTransport([(200, "<html>not json</html>", {})])
|
|
178
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
179
|
+
with self.assertRaises(SendByteError) as ctx:
|
|
180
|
+
client.emails.list()
|
|
181
|
+
self.assertEqual(ctx.exception.code, "invalid_response")
|
|
182
|
+
self.assertEqual(ctx.exception.status, 200)
|
|
183
|
+
|
|
184
|
+
def test_list_unsubscribe_passes_through(self):
|
|
185
|
+
transport = FakeTransport(201)
|
|
186
|
+
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
187
|
+
client.emails.send(
|
|
188
|
+
from_="a@b.ng",
|
|
189
|
+
to="c@d.ng",
|
|
190
|
+
subject="Hi",
|
|
191
|
+
text="x",
|
|
192
|
+
list_unsubscribe={"url": "https://x.ng/u", "mailto": "unsub@x.ng"},
|
|
193
|
+
)
|
|
194
|
+
self.assertEqual(
|
|
195
|
+
transport.calls[0]["body"]["list_unsubscribe"],
|
|
196
|
+
{"url": "https://x.ng/u", "mailto": "unsub@x.ng"},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def test_refuses_live_key_over_insecure_base_url(self):
|
|
200
|
+
with self.assertRaises(SendByteError) as ctx:
|
|
201
|
+
SendByte("sk_live_abc", base_url="http://api.example.com")
|
|
202
|
+
self.assertEqual(ctx.exception.code, "insecure_base_url")
|
|
203
|
+
# http tolerated for localhost dev; test keys always allowed.
|
|
204
|
+
SendByte("sk_live_abc", base_url="http://localhost:8080", transport=FakeTransport())
|
|
205
|
+
SendByte("sk_test_abc", base_url="http://api.example.com", transport=FakeTransport())
|
|
206
|
+
|
|
207
|
+
def test_async_parity_methods(self):
|
|
208
|
+
transport = FakeTransport(200, {"id": "dom_1", "ses_verified": True})
|
|
209
|
+
client = AsyncSendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
210
|
+
|
|
211
|
+
async def run():
|
|
212
|
+
# The methods missing before this fix: get_domain, disable_webhook, replay_webhook.
|
|
213
|
+
await client.get_domain("dom_1")
|
|
214
|
+
await client.disable_webhook("wh_1")
|
|
215
|
+
await client.replay_webhook("evt_1")
|
|
216
|
+
return [c["url"] for c in transport.calls]
|
|
217
|
+
|
|
218
|
+
urls = asyncio.new_event_loop().run_until_complete(run())
|
|
219
|
+
self.assertEqual(
|
|
220
|
+
urls,
|
|
221
|
+
[
|
|
222
|
+
"https://api.test/v1/domains/dom_1",
|
|
223
|
+
"https://api.test/v1/webhooks/wh_1",
|
|
224
|
+
"https://api.test/v1/webhooks/deliveries/evt_1/replay",
|
|
225
|
+
],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class WebhookSignatureTests(unittest.TestCase):
|
|
230
|
+
SECRET = "whsec_secret"
|
|
231
|
+
NOW = 1_750_000_000
|
|
232
|
+
BODY = '{"type":"email.delivered"}'
|
|
233
|
+
|
|
234
|
+
def sign(self, timestamp):
|
|
235
|
+
digest = hmac.new(
|
|
236
|
+
self.SECRET.encode(), f"{timestamp}.{self.BODY}".encode(), hashlib.sha256
|
|
237
|
+
).hexdigest()
|
|
238
|
+
return f"t={timestamp},v1={digest}"
|
|
239
|
+
|
|
240
|
+
def test_accepts_valid(self):
|
|
241
|
+
self.assertTrue(
|
|
242
|
+
verify_webhook_signature(self.SECRET, self.sign(self.NOW), self.BODY, now=self.NOW)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def test_rejects_tampered_stale_and_missing(self):
|
|
246
|
+
self.assertFalse(
|
|
247
|
+
verify_webhook_signature(self.SECRET, self.sign(self.NOW), '{"type":"x"}', now=self.NOW)
|
|
248
|
+
)
|
|
249
|
+
self.assertFalse(
|
|
250
|
+
verify_webhook_signature(self.SECRET, self.sign(self.NOW - 301), self.BODY, now=self.NOW)
|
|
251
|
+
)
|
|
252
|
+
self.assertFalse(verify_webhook_signature(self.SECRET, None, self.BODY, now=self.NOW))
|
|
253
|
+
self.assertFalse(verify_webhook_signature(self.SECRET, "garbage", self.BODY, now=self.NOW))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
unittest.main()
|
sendbyte-0.1.0/tests/test_sdk.py
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
"""SDK tests: stdlib unittest, fake transport, no network."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import hashlib
|
|
5
|
-
import hmac
|
|
6
|
-
import json
|
|
7
|
-
import unittest
|
|
8
|
-
|
|
9
|
-
from sendbyte import AsyncSendByte, SendByte, SendByteError, verify_webhook_signature
|
|
10
|
-
|
|
11
|
-
EMAIL = {
|
|
12
|
-
"id": "em_1",
|
|
13
|
-
"from": "a@b.ng",
|
|
14
|
-
"to": ["c@d.ng"],
|
|
15
|
-
"subject": "Hi",
|
|
16
|
-
"status": "queued",
|
|
17
|
-
"sandbox": True,
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class FakeTransport:
|
|
22
|
-
def __init__(self, status=200, body=None):
|
|
23
|
-
self.status = status
|
|
24
|
-
self.body = body if body is not None else EMAIL
|
|
25
|
-
self.calls = []
|
|
26
|
-
|
|
27
|
-
def __call__(self, method, url, headers, payload):
|
|
28
|
-
self.calls.append(
|
|
29
|
-
{
|
|
30
|
-
"method": method,
|
|
31
|
-
"url": url,
|
|
32
|
-
"headers": headers,
|
|
33
|
-
"body": json.loads(payload) if payload else None,
|
|
34
|
-
}
|
|
35
|
-
)
|
|
36
|
-
return self.status, json.dumps(self.body)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class ClientTests(unittest.TestCase):
|
|
40
|
-
def test_rejects_malformed_keys(self):
|
|
41
|
-
with self.assertRaises(SendByteError) as ctx:
|
|
42
|
-
SendByte("pk_live_nope")
|
|
43
|
-
self.assertEqual(ctx.exception.code, "invalid_api_key")
|
|
44
|
-
|
|
45
|
-
def test_sends_with_auth_and_body(self):
|
|
46
|
-
transport = FakeTransport(201)
|
|
47
|
-
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
48
|
-
result = client.emails.send(
|
|
49
|
-
from_="a@b.ng",
|
|
50
|
-
to="c@d.ng",
|
|
51
|
-
subject="Hi",
|
|
52
|
-
html="<p>x</p>",
|
|
53
|
-
idempotency_key="order-1",
|
|
54
|
-
)
|
|
55
|
-
self.assertEqual(result["id"], "em_1")
|
|
56
|
-
call = transport.calls[0]
|
|
57
|
-
self.assertEqual(call["url"], "https://api.test/v1/emails")
|
|
58
|
-
self.assertEqual(call["headers"]["authorization"], "Bearer sk_test_abc")
|
|
59
|
-
self.assertEqual(call["body"]["from"], "a@b.ng")
|
|
60
|
-
self.assertEqual(call["body"]["idempotency_key"], "order-1")
|
|
61
|
-
|
|
62
|
-
def test_rejects_unknown_send_kwargs(self):
|
|
63
|
-
client = SendByte("sk_test_abc", transport=FakeTransport())
|
|
64
|
-
with self.assertRaises(TypeError):
|
|
65
|
-
client.emails.send(from_="a@b.ng", to="c@d.ng", subject="x", htlm="typo")
|
|
66
|
-
|
|
67
|
-
def test_list_builds_query(self):
|
|
68
|
-
transport = FakeTransport(200, {"data": [], "has_more": False})
|
|
69
|
-
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
70
|
-
client.emails.list(limit=5, status="delivered")
|
|
71
|
-
self.assertEqual(transport.calls[0]["url"], "https://api.test/v1/emails?limit=5&status=delivered")
|
|
72
|
-
|
|
73
|
-
def test_maps_api_errors(self):
|
|
74
|
-
transport = FakeTransport(
|
|
75
|
-
403,
|
|
76
|
-
{
|
|
77
|
-
"error": {
|
|
78
|
-
"code": "domain_not_verified",
|
|
79
|
-
"message": "paylink.ng is not verified.",
|
|
80
|
-
"docs_url": "https://docs.sendbyte.africa/errors/domain_not_verified",
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
)
|
|
84
|
-
client = SendByte("sk_live_abc", base_url="https://api.test", transport=transport)
|
|
85
|
-
with self.assertRaises(SendByteError) as ctx:
|
|
86
|
-
client.emails.send(from_="a@paylink.ng", to="c@d.ng", subject="x", text="x")
|
|
87
|
-
self.assertEqual(ctx.exception.code, "domain_not_verified")
|
|
88
|
-
self.assertEqual(ctx.exception.status, 403)
|
|
89
|
-
self.assertIn("/errors/domain_not_verified", ctx.exception.docs_url)
|
|
90
|
-
|
|
91
|
-
def test_domains_and_webhooks_surfaces(self):
|
|
92
|
-
transport = FakeTransport(200, {"data": []})
|
|
93
|
-
client = SendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
94
|
-
client.domains.list()
|
|
95
|
-
client.webhooks.list()
|
|
96
|
-
urls = [c["url"] for c in transport.calls]
|
|
97
|
-
self.assertEqual(urls, ["https://api.test/v1/domains", "https://api.test/v1/webhooks"])
|
|
98
|
-
|
|
99
|
-
def test_async_client(self):
|
|
100
|
-
transport = FakeTransport(201)
|
|
101
|
-
client = AsyncSendByte("sk_test_abc", base_url="https://api.test", transport=transport)
|
|
102
|
-
|
|
103
|
-
async def run():
|
|
104
|
-
return await client.send_email(from_="a@b.ng", to="c@d.ng", subject="Hi", text="x")
|
|
105
|
-
|
|
106
|
-
result = asyncio.get_event_loop().run_until_complete(run())
|
|
107
|
-
self.assertEqual(result["id"], "em_1")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
class WebhookSignatureTests(unittest.TestCase):
|
|
111
|
-
SECRET = "whsec_secret"
|
|
112
|
-
NOW = 1_750_000_000
|
|
113
|
-
BODY = '{"type":"email.delivered"}'
|
|
114
|
-
|
|
115
|
-
def sign(self, timestamp):
|
|
116
|
-
digest = hmac.new(
|
|
117
|
-
self.SECRET.encode(), f"{timestamp}.{self.BODY}".encode(), hashlib.sha256
|
|
118
|
-
).hexdigest()
|
|
119
|
-
return f"t={timestamp},v1={digest}"
|
|
120
|
-
|
|
121
|
-
def test_accepts_valid(self):
|
|
122
|
-
self.assertTrue(
|
|
123
|
-
verify_webhook_signature(self.SECRET, self.sign(self.NOW), self.BODY, now=self.NOW)
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
def test_rejects_tampered_stale_and_missing(self):
|
|
127
|
-
self.assertFalse(
|
|
128
|
-
verify_webhook_signature(self.SECRET, self.sign(self.NOW), '{"type":"x"}', now=self.NOW)
|
|
129
|
-
)
|
|
130
|
-
self.assertFalse(
|
|
131
|
-
verify_webhook_signature(self.SECRET, self.sign(self.NOW - 301), self.BODY, now=self.NOW)
|
|
132
|
-
)
|
|
133
|
-
self.assertFalse(verify_webhook_signature(self.SECRET, None, self.BODY, now=self.NOW))
|
|
134
|
-
self.assertFalse(verify_webhook_signature(self.SECRET, "garbage", self.BODY, now=self.NOW))
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if __name__ == "__main__":
|
|
138
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|