sendbyte 0.1.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sendbyte
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: SendByte Python SDK: the email API for Africa.
5
5
  Author: Echospectra Technology Limited
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sendbyte"
7
- version = "0.1.0"
7
+ version = "0.2.1"
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.1.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
- Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, str]]
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=30) as response:
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__(self, api_key: str, base_url: str, transport: Optional[Transport]):
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 or _default_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 body is not None:
184
+ if payload_body is not None:
86
185
  headers["content-type"] = "application/json"
87
- payload = json.dumps(body).encode("utf-8")
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
- if status >= 400:
93
- error = data.get("error", {}) if isinstance(data, dict) else {}
94
- raise SendByteError(
95
- error.get("code", "request_failed"),
96
- error.get("message", f"Request failed with status {status}"),
97
- status,
98
- error.get("docs_url"),
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
- return data
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,16 @@ 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 by the built-in transport is bounded by ``timeout`` seconds; a
357
+ custom ``transport`` you inject is responsible for its own timeout (it keeps
358
+ the historical ``(method, url, headers, body)`` signature). On POST
359
+ /v1/emails an idempotency key is generated and reused across retries when the
360
+ caller did not supply one, so a retried send cannot double-send.
361
+ """
204
362
 
205
363
  def __init__(
206
364
  self,
@@ -208,8 +366,10 @@ class SendByte:
208
366
  *,
209
367
  base_url: str = DEFAULT_BASE_URL,
210
368
  transport: Optional[Transport] = None,
369
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
370
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
211
371
  ):
212
- http = _Http(api_key, base_url, transport)
372
+ http = _Http(api_key, base_url, transport, max_attempts, timeout)
213
373
  self.emails = _Emails(http)
214
374
  self.domains = _Domains(http)
215
375
  self.webhooks = _Webhooks(http)
@@ -228,8 +388,16 @@ class AsyncSendByte:
228
388
  *,
229
389
  base_url: str = DEFAULT_BASE_URL,
230
390
  transport: Optional[Transport] = None,
391
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
392
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
231
393
  ):
232
- self._sync = SendByte(api_key, base_url=base_url, transport=transport)
394
+ self._sync = SendByte(
395
+ api_key,
396
+ base_url=base_url,
397
+ transport=transport,
398
+ max_attempts=max_attempts,
399
+ timeout=timeout,
400
+ )
233
401
 
234
402
  async def _run(self, fn: Callable[[], Any]) -> Any:
235
403
  loop = asyncio.get_event_loop()
@@ -253,6 +421,9 @@ class AsyncSendByte:
253
421
  async def create_domain(self, domain: str) -> Dict[str, Any]:
254
422
  return await self._run(lambda: self._sync.domains.create(domain))
255
423
 
424
+ async def get_domain(self, domain_id: str) -> Dict[str, Any]:
425
+ return await self._run(lambda: self._sync.domains.get(domain_id))
426
+
256
427
  async def verify_domain(self, domain_id: str) -> Dict[str, Any]:
257
428
  return await self._run(lambda: self._sync.domains.verify(domain_id))
258
429
 
@@ -266,6 +437,12 @@ class AsyncSendByte:
266
437
  async def list_webhooks(self) -> Dict[str, Any]:
267
438
  return await self._run(lambda: self._sync.webhooks.list())
268
439
 
440
+ async def disable_webhook(self, endpoint_id: str) -> Dict[str, Any]:
441
+ return await self._run(lambda: self._sync.webhooks.disable(endpoint_id))
442
+
443
+ async def replay_webhook(self, delivery_id: str) -> Dict[str, Any]:
444
+ return await self._run(lambda: self._sync.webhooks.replay(delivery_id))
445
+
269
446
 
270
447
  def verify_webhook_signature(
271
448
  secret: str,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sendbyte
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: SendByte Python SDK: the email API for Africa.
5
5
  Author: Echospectra Technology Limited
6
6
  License: MIT
@@ -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()
@@ -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