sendbyte 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Echospectra Technology Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendbyte
3
+ Version: 0.1.0
4
+ Summary: SendByte Python SDK: the email API for Africa.
5
+ Author: Echospectra Technology Limited
6
+ License: MIT
7
+ Project-URL: Homepage, https://sendbyte.africa
8
+ Project-URL: Documentation, https://docs.sendbyte.africa
9
+ Project-URL: Repository, https://github.com/Sendbyte-Africa/sendbyte-python
10
+ Project-URL: Issues, https://github.com/Sendbyte-Africa/sendbyte-python/issues
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Communications :: Email
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # sendbyte
23
+
24
+ The official Python SDK for [SendByte](https://sendbyte.africa), the email API for Africa. Zero runtime dependencies (standard library only), with both synchronous and asynchronous clients.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install sendbyte
30
+ ```
31
+
32
+ Requires Python 3.8 or newer.
33
+
34
+ ## Quickstart
35
+
36
+ ```python
37
+ from sendbyte import SendByte
38
+
39
+ sendbyte = SendByte("sk_test_...")
40
+
41
+ email = sendbyte.emails.send(
42
+ from_="PayLink <receipts@paylink.ng>",
43
+ to="amaka@halo.ng",
44
+ subject="Receipt for your payment",
45
+ html="<p>Thank you. Your payment was received.</p>",
46
+ )
47
+
48
+ print(email["id"])
49
+ ```
50
+
51
+ `from_` is spelled with a trailing underscore because `from` is a reserved word in Python. Keys that start with `sk_test_` run in the sandbox: every endpoint behaves the same, but nothing is actually delivered. Switch to an `sk_live_` key to send for real.
52
+
53
+ ## Configuration
54
+
55
+ ```python
56
+ sendbyte = SendByte("sk_live_...", base_url="https://api.sendbyte.africa")
57
+ ```
58
+
59
+ ## Async client
60
+
61
+ `AsyncSendByte` exposes the same surface with awaitable methods, running the transport in the default executor so the SDK stays dependency free.
62
+
63
+ ```python
64
+ import asyncio
65
+ from sendbyte import AsyncSendByte
66
+
67
+ async def main():
68
+ sendbyte = AsyncSendByte("sk_test_...")
69
+ email = await sendbyte.send_email(
70
+ from_="PayLink <receipts@paylink.ng>",
71
+ to="amaka@halo.ng",
72
+ subject="Receipt",
73
+ html="<p>Thank you.</p>",
74
+ )
75
+ print(email["id"])
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ ## API
81
+
82
+ ### Emails
83
+
84
+ ```python
85
+ sendbyte.emails.send(from_=..., to=..., subject=..., html=...)
86
+ sendbyte.emails.get("em_123")
87
+ sendbyte.emails.list(limit=20, status="delivered")
88
+ ```
89
+
90
+ `to`, `cc`, `bcc`, and `reply_to` accept a single address or a list. Pass `idempotency_key` to make retries safe, `scheduled_at` for future sends, and `template_id` with `variables` to render a server side template.
91
+
92
+ ### Domains
93
+
94
+ ```python
95
+ domain = sendbyte.domains.create("paylink.ng")
96
+ # domain["dns_records"] lists the SPF, DKIM, and DMARC records to publish
97
+ sendbyte.domains.list()
98
+ sendbyte.domains.get(domain["id"])
99
+ sendbyte.domains.verify(domain["id"])
100
+ ```
101
+
102
+ ### Webhooks
103
+
104
+ ```python
105
+ endpoint = sendbyte.webhooks.create(
106
+ "https://example.com/hooks/sendbyte",
107
+ ["email.delivered", "email.bounced"],
108
+ )
109
+ # endpoint["secret"] is shown once, store it now
110
+ sendbyte.webhooks.list()
111
+ sendbyte.webhooks.disable(endpoint["id"])
112
+ sendbyte.webhooks.replay("evt_123")
113
+ ```
114
+
115
+ ## Verifying webhook signatures
116
+
117
+ Every webhook request carries a `sendbyte-signature` header. Verify it against the raw request body before trusting the payload.
118
+
119
+ ```python
120
+ from sendbyte import verify_webhook_signature
121
+
122
+ @app.post("/hooks/sendbyte")
123
+ def hooks(request):
124
+ valid = verify_webhook_signature(
125
+ secret=os.environ["SENDBYTE_WEBHOOK_SECRET"],
126
+ header=request.headers.get("sendbyte-signature"),
127
+ body=request.get_data(), # the raw, unparsed body
128
+ )
129
+ if not valid:
130
+ return "", 401
131
+ # handle the event
132
+ return "", 200
133
+ ```
134
+
135
+ The check rejects missing, malformed, tampered, and stale signatures (default tolerance is 300 seconds) and never raises.
136
+
137
+ ## Errors
138
+
139
+ Failed requests raise a `SendByteError` carrying the API error shape.
140
+
141
+ ```python
142
+ from sendbyte import SendByteError
143
+
144
+ try:
145
+ sendbyte.emails.send(from_=..., to=..., subject=..., html=...)
146
+ except SendByteError as err:
147
+ print(err.code, err.status, err.docs_url)
148
+ ```
149
+
150
+ ## Links
151
+
152
+ - Documentation: https://docs.sendbyte.africa
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,135 @@
1
+ # sendbyte
2
+
3
+ The official Python SDK for [SendByte](https://sendbyte.africa), the email API for Africa. Zero runtime dependencies (standard library only), with both synchronous and asynchronous clients.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install sendbyte
9
+ ```
10
+
11
+ Requires Python 3.8 or newer.
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from sendbyte import SendByte
17
+
18
+ sendbyte = SendByte("sk_test_...")
19
+
20
+ email = sendbyte.emails.send(
21
+ from_="PayLink <receipts@paylink.ng>",
22
+ to="amaka@halo.ng",
23
+ subject="Receipt for your payment",
24
+ html="<p>Thank you. Your payment was received.</p>",
25
+ )
26
+
27
+ print(email["id"])
28
+ ```
29
+
30
+ `from_` is spelled with a trailing underscore because `from` is a reserved word in Python. Keys that start with `sk_test_` run in the sandbox: every endpoint behaves the same, but nothing is actually delivered. Switch to an `sk_live_` key to send for real.
31
+
32
+ ## Configuration
33
+
34
+ ```python
35
+ sendbyte = SendByte("sk_live_...", base_url="https://api.sendbyte.africa")
36
+ ```
37
+
38
+ ## Async client
39
+
40
+ `AsyncSendByte` exposes the same surface with awaitable methods, running the transport in the default executor so the SDK stays dependency free.
41
+
42
+ ```python
43
+ import asyncio
44
+ from sendbyte import AsyncSendByte
45
+
46
+ async def main():
47
+ sendbyte = AsyncSendByte("sk_test_...")
48
+ email = await sendbyte.send_email(
49
+ from_="PayLink <receipts@paylink.ng>",
50
+ to="amaka@halo.ng",
51
+ subject="Receipt",
52
+ html="<p>Thank you.</p>",
53
+ )
54
+ print(email["id"])
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ## API
60
+
61
+ ### Emails
62
+
63
+ ```python
64
+ sendbyte.emails.send(from_=..., to=..., subject=..., html=...)
65
+ sendbyte.emails.get("em_123")
66
+ sendbyte.emails.list(limit=20, status="delivered")
67
+ ```
68
+
69
+ `to`, `cc`, `bcc`, and `reply_to` accept a single address or a list. Pass `idempotency_key` to make retries safe, `scheduled_at` for future sends, and `template_id` with `variables` to render a server side template.
70
+
71
+ ### Domains
72
+
73
+ ```python
74
+ domain = sendbyte.domains.create("paylink.ng")
75
+ # domain["dns_records"] lists the SPF, DKIM, and DMARC records to publish
76
+ sendbyte.domains.list()
77
+ sendbyte.domains.get(domain["id"])
78
+ sendbyte.domains.verify(domain["id"])
79
+ ```
80
+
81
+ ### Webhooks
82
+
83
+ ```python
84
+ endpoint = sendbyte.webhooks.create(
85
+ "https://example.com/hooks/sendbyte",
86
+ ["email.delivered", "email.bounced"],
87
+ )
88
+ # endpoint["secret"] is shown once, store it now
89
+ sendbyte.webhooks.list()
90
+ sendbyte.webhooks.disable(endpoint["id"])
91
+ sendbyte.webhooks.replay("evt_123")
92
+ ```
93
+
94
+ ## Verifying webhook signatures
95
+
96
+ Every webhook request carries a `sendbyte-signature` header. Verify it against the raw request body before trusting the payload.
97
+
98
+ ```python
99
+ from sendbyte import verify_webhook_signature
100
+
101
+ @app.post("/hooks/sendbyte")
102
+ def hooks(request):
103
+ valid = verify_webhook_signature(
104
+ secret=os.environ["SENDBYTE_WEBHOOK_SECRET"],
105
+ header=request.headers.get("sendbyte-signature"),
106
+ body=request.get_data(), # the raw, unparsed body
107
+ )
108
+ if not valid:
109
+ return "", 401
110
+ # handle the event
111
+ return "", 200
112
+ ```
113
+
114
+ The check rejects missing, malformed, tampered, and stale signatures (default tolerance is 300 seconds) and never raises.
115
+
116
+ ## Errors
117
+
118
+ Failed requests raise a `SendByteError` carrying the API error shape.
119
+
120
+ ```python
121
+ from sendbyte import SendByteError
122
+
123
+ try:
124
+ sendbyte.emails.send(from_=..., to=..., subject=..., html=...)
125
+ except SendByteError as err:
126
+ print(err.code, err.status, err.docs_url)
127
+ ```
128
+
129
+ ## Links
130
+
131
+ - Documentation: https://docs.sendbyte.africa
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sendbyte"
7
+ version = "0.1.0"
8
+ description = "SendByte Python SDK: the email API for Africa."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "Echospectra Technology Limited" }]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Communications :: Email",
19
+ "Typing :: Typed",
20
+ ]
21
+ # Zero runtime dependencies: stdlib urllib for transport.
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://sendbyte.africa"
26
+ Documentation = "https://docs.sendbyte.africa"
27
+ Repository = "https://github.com/Sendbyte-Africa/sendbyte-python"
28
+ Issues = "https://github.com/Sendbyte-Africa/sendbyte-python/issues"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["sendbyte*"]
@@ -0,0 +1,304 @@
1
+ """SendByte Python SDK: the email API for Africa.
2
+
3
+ Zero runtime dependencies (stdlib urllib). Sync and async clients with the
4
+ same surface; errors carry the API's error shape (code, message, docs_url).
5
+
6
+ from sendbyte import SendByte
7
+
8
+ sendbyte = SendByte("sk_test_...")
9
+ email = sendbyte.emails.send(
10
+ from_="PayLink <receipts@paylink.ng>",
11
+ to="amaka@halo.ng",
12
+ subject="Receipt",
13
+ html="<p>Thank you.</p>",
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import time
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
28
+
29
+ __all__ = [
30
+ "SendByte",
31
+ "AsyncSendByte",
32
+ "SendByteError",
33
+ "verify_webhook_signature",
34
+ "SIGNATURE_HEADER",
35
+ ]
36
+
37
+ DEFAULT_BASE_URL = "https://api.sendbyte.africa"
38
+ SIGNATURE_HEADER = "sendbyte-signature"
39
+ _VERSION = "0.1.0"
40
+
41
+ # transport(method, url, headers, body) -> (status, response_text)
42
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, str]]
43
+
44
+
45
+ class SendByteError(Exception):
46
+ """An error returned by the SendByte API."""
47
+
48
+ def __init__(self, code: str, message: str, status: int, docs_url: Optional[str] = None):
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.status = status
52
+ self.docs_url = docs_url
53
+
54
+
55
+ def _default_transport(
56
+ method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
57
+ ) -> Tuple[int, str]:
58
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
59
+ try:
60
+ with urllib.request.urlopen(request, timeout=30) as response:
61
+ return response.status, response.read().decode("utf-8")
62
+ except urllib.error.HTTPError as err: # non-2xx still carries a JSON body
63
+ return err.code, err.read().decode("utf-8")
64
+
65
+
66
+ class _Http:
67
+ def __init__(self, api_key: str, base_url: str, transport: Optional[Transport]):
68
+ if not api_key or not (api_key.startswith("sk_live_") or api_key.startswith("sk_test_")):
69
+ raise SendByteError(
70
+ "invalid_api_key",
71
+ "API key must start with sk_live_ or sk_test_. "
72
+ "Create one in the dashboard under API keys.",
73
+ 0,
74
+ )
75
+ self.api_key = api_key
76
+ self.base_url = base_url.rstrip("/")
77
+ self.transport = transport or _default_transport
78
+
79
+ def request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
80
+ headers = {
81
+ "authorization": f"Bearer {self.api_key}",
82
+ "user-agent": f"sendbyte-python/{_VERSION}",
83
+ }
84
+ payload: Optional[bytes] = None
85
+ if body is not None:
86
+ headers["content-type"] = "application/json"
87
+ payload = json.dumps(body).encode("utf-8")
88
+
89
+ status, text = self.transport(method, f"{self.base_url}{path}", headers, payload)
90
+ data = json.loads(text) if text else {}
91
+
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"),
99
+ )
100
+ return data
101
+
102
+
103
+ def _build_send_body(
104
+ from_: str,
105
+ to: Union[str, List[str]],
106
+ subject: str,
107
+ **options: Any,
108
+ ) -> Dict[str, Any]:
109
+ body: Dict[str, Any] = {"from": from_, "to": to, "subject": subject}
110
+ for key in (
111
+ "html",
112
+ "text",
113
+ "cc",
114
+ "bcc",
115
+ "reply_to",
116
+ "attachments",
117
+ "tags",
118
+ "headers",
119
+ "scheduled_at",
120
+ "template_id",
121
+ "variables",
122
+ "idempotency_key",
123
+ ):
124
+ value = options.pop(key, None)
125
+ if value is not None:
126
+ body[key] = value
127
+ if options:
128
+ raise TypeError(f"Unknown arguments: {', '.join(sorted(options))}")
129
+ return body
130
+
131
+
132
+ def _list_query(limit: Optional[int], after: Optional[str], status: Optional[str]) -> str:
133
+ params = {}
134
+ if limit is not None:
135
+ params["limit"] = str(limit)
136
+ if after is not None:
137
+ params["after"] = after
138
+ if status is not None:
139
+ params["status"] = status
140
+ return f"?{urllib.parse.urlencode(params)}" if params else ""
141
+
142
+
143
+ class _Emails:
144
+ def __init__(self, http: _Http):
145
+ self._http = http
146
+
147
+ def send(
148
+ self, *, from_: str, to: Union[str, List[str]], subject: str, **options: Any
149
+ ) -> Dict[str, Any]:
150
+ return self._http.request("POST", "/v1/emails", _build_send_body(from_, to, subject, **options))
151
+
152
+ def get(self, email_id: str) -> Dict[str, Any]:
153
+ return self._http.request("GET", f"/v1/emails/{email_id}")
154
+
155
+ def list(
156
+ self,
157
+ *,
158
+ limit: Optional[int] = None,
159
+ after: Optional[str] = None,
160
+ status: Optional[str] = None,
161
+ ) -> Dict[str, Any]:
162
+ return self._http.request("GET", f"/v1/emails{_list_query(limit, after, status)}")
163
+
164
+
165
+ class _Domains:
166
+ def __init__(self, http: _Http):
167
+ self._http = http
168
+
169
+ def create(self, domain: str) -> Dict[str, Any]:
170
+ return self._http.request("POST", "/v1/domains", {"domain": domain})
171
+
172
+ def list(self) -> Dict[str, Any]:
173
+ return self._http.request("GET", "/v1/domains")
174
+
175
+ def get(self, domain_id: str) -> Dict[str, Any]:
176
+ return self._http.request("GET", f"/v1/domains/{domain_id}")
177
+
178
+ def verify(self, domain_id: str) -> Dict[str, Any]:
179
+ return self._http.request("POST", f"/v1/domains/{domain_id}/verify")
180
+
181
+
182
+ class _Webhooks:
183
+ def __init__(self, http: _Http):
184
+ self._http = http
185
+
186
+ def create(self, url: str, events: Optional[List[str]] = None) -> Dict[str, Any]:
187
+ body: Dict[str, Any] = {"url": url}
188
+ if events is not None:
189
+ body["events"] = events
190
+ return self._http.request("POST", "/v1/webhooks", body)
191
+
192
+ def list(self) -> Dict[str, Any]:
193
+ return self._http.request("GET", "/v1/webhooks")
194
+
195
+ def disable(self, endpoint_id: str) -> Dict[str, Any]:
196
+ return self._http.request("DELETE", f"/v1/webhooks/{endpoint_id}")
197
+
198
+ def replay(self, delivery_id: str) -> Dict[str, Any]:
199
+ return self._http.request("POST", f"/v1/webhooks/deliveries/{delivery_id}/replay")
200
+
201
+
202
+ class SendByte:
203
+ """Synchronous SendByte client."""
204
+
205
+ def __init__(
206
+ self,
207
+ api_key: str,
208
+ *,
209
+ base_url: str = DEFAULT_BASE_URL,
210
+ transport: Optional[Transport] = None,
211
+ ):
212
+ http = _Http(api_key, base_url, transport)
213
+ self.emails = _Emails(http)
214
+ self.domains = _Domains(http)
215
+ self.webhooks = _Webhooks(http)
216
+
217
+
218
+ class AsyncSendByte:
219
+ """Asynchronous SendByte client (same surface, awaitable methods).
220
+
221
+ Transport runs in the default executor, keeping the SDK dependency-free
222
+ while staying event-loop friendly.
223
+ """
224
+
225
+ def __init__(
226
+ self,
227
+ api_key: str,
228
+ *,
229
+ base_url: str = DEFAULT_BASE_URL,
230
+ transport: Optional[Transport] = None,
231
+ ):
232
+ self._sync = SendByte(api_key, base_url=base_url, transport=transport)
233
+
234
+ async def _run(self, fn: Callable[[], Any]) -> Any:
235
+ loop = asyncio.get_event_loop()
236
+ return await loop.run_in_executor(None, fn)
237
+
238
+ # Emails
239
+ async def send_email(
240
+ self, *, from_: str, to: Union[str, List[str]], subject: str, **options: Any
241
+ ) -> Dict[str, Any]:
242
+ return await self._run(
243
+ lambda: self._sync.emails.send(from_=from_, to=to, subject=subject, **options)
244
+ )
245
+
246
+ async def get_email(self, email_id: str) -> Dict[str, Any]:
247
+ return await self._run(lambda: self._sync.emails.get(email_id))
248
+
249
+ async def list_emails(self, **params: Any) -> Dict[str, Any]:
250
+ return await self._run(lambda: self._sync.emails.list(**params))
251
+
252
+ # Domains
253
+ async def create_domain(self, domain: str) -> Dict[str, Any]:
254
+ return await self._run(lambda: self._sync.domains.create(domain))
255
+
256
+ async def verify_domain(self, domain_id: str) -> Dict[str, Any]:
257
+ return await self._run(lambda: self._sync.domains.verify(domain_id))
258
+
259
+ async def list_domains(self) -> Dict[str, Any]:
260
+ return await self._run(lambda: self._sync.domains.list())
261
+
262
+ # Webhooks
263
+ async def create_webhook(self, url: str, events: Optional[List[str]] = None) -> Dict[str, Any]:
264
+ return await self._run(lambda: self._sync.webhooks.create(url, events))
265
+
266
+ async def list_webhooks(self) -> Dict[str, Any]:
267
+ return await self._run(lambda: self._sync.webhooks.list())
268
+
269
+
270
+ def verify_webhook_signature(
271
+ secret: str,
272
+ header: Optional[str],
273
+ body: Union[str, bytes],
274
+ *,
275
+ tolerance_seconds: int = 300,
276
+ now: Optional[int] = None,
277
+ ) -> bool:
278
+ """Verify a webhook request came from SendByte.
279
+
280
+ The header format is ``t=<unix seconds>,v1=<hex hmac-sha256>`` over
281
+ ``"<t>.<raw body>"``. Returns False for missing, malformed, stale, or
282
+ forged signatures; never raises.
283
+ """
284
+ if not header:
285
+ return False
286
+ parts: Dict[str, str] = {}
287
+ for piece in header.split(","):
288
+ key, _, value = piece.partition("=")
289
+ parts[key] = value
290
+ timestamp_raw = parts.get("t", "")
291
+ signature = parts.get("v1", "")
292
+ if not timestamp_raw.isdigit() or not signature:
293
+ return False
294
+
295
+ timestamp = int(timestamp_raw)
296
+ current = now if now is not None else int(time.time())
297
+ if abs(current - timestamp) > tolerance_seconds:
298
+ return False
299
+
300
+ raw = body.encode("utf-8") if isinstance(body, str) else body
301
+ expected = hmac.new(
302
+ secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + raw, hashlib.sha256
303
+ ).hexdigest()
304
+ return hmac.compare_digest(expected, signature)
File without changes
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendbyte
3
+ Version: 0.1.0
4
+ Summary: SendByte Python SDK: the email API for Africa.
5
+ Author: Echospectra Technology Limited
6
+ License: MIT
7
+ Project-URL: Homepage, https://sendbyte.africa
8
+ Project-URL: Documentation, https://docs.sendbyte.africa
9
+ Project-URL: Repository, https://github.com/Sendbyte-Africa/sendbyte-python
10
+ Project-URL: Issues, https://github.com/Sendbyte-Africa/sendbyte-python/issues
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Communications :: Email
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # sendbyte
23
+
24
+ The official Python SDK for [SendByte](https://sendbyte.africa), the email API for Africa. Zero runtime dependencies (standard library only), with both synchronous and asynchronous clients.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install sendbyte
30
+ ```
31
+
32
+ Requires Python 3.8 or newer.
33
+
34
+ ## Quickstart
35
+
36
+ ```python
37
+ from sendbyte import SendByte
38
+
39
+ sendbyte = SendByte("sk_test_...")
40
+
41
+ email = sendbyte.emails.send(
42
+ from_="PayLink <receipts@paylink.ng>",
43
+ to="amaka@halo.ng",
44
+ subject="Receipt for your payment",
45
+ html="<p>Thank you. Your payment was received.</p>",
46
+ )
47
+
48
+ print(email["id"])
49
+ ```
50
+
51
+ `from_` is spelled with a trailing underscore because `from` is a reserved word in Python. Keys that start with `sk_test_` run in the sandbox: every endpoint behaves the same, but nothing is actually delivered. Switch to an `sk_live_` key to send for real.
52
+
53
+ ## Configuration
54
+
55
+ ```python
56
+ sendbyte = SendByte("sk_live_...", base_url="https://api.sendbyte.africa")
57
+ ```
58
+
59
+ ## Async client
60
+
61
+ `AsyncSendByte` exposes the same surface with awaitable methods, running the transport in the default executor so the SDK stays dependency free.
62
+
63
+ ```python
64
+ import asyncio
65
+ from sendbyte import AsyncSendByte
66
+
67
+ async def main():
68
+ sendbyte = AsyncSendByte("sk_test_...")
69
+ email = await sendbyte.send_email(
70
+ from_="PayLink <receipts@paylink.ng>",
71
+ to="amaka@halo.ng",
72
+ subject="Receipt",
73
+ html="<p>Thank you.</p>",
74
+ )
75
+ print(email["id"])
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ ## API
81
+
82
+ ### Emails
83
+
84
+ ```python
85
+ sendbyte.emails.send(from_=..., to=..., subject=..., html=...)
86
+ sendbyte.emails.get("em_123")
87
+ sendbyte.emails.list(limit=20, status="delivered")
88
+ ```
89
+
90
+ `to`, `cc`, `bcc`, and `reply_to` accept a single address or a list. Pass `idempotency_key` to make retries safe, `scheduled_at` for future sends, and `template_id` with `variables` to render a server side template.
91
+
92
+ ### Domains
93
+
94
+ ```python
95
+ domain = sendbyte.domains.create("paylink.ng")
96
+ # domain["dns_records"] lists the SPF, DKIM, and DMARC records to publish
97
+ sendbyte.domains.list()
98
+ sendbyte.domains.get(domain["id"])
99
+ sendbyte.domains.verify(domain["id"])
100
+ ```
101
+
102
+ ### Webhooks
103
+
104
+ ```python
105
+ endpoint = sendbyte.webhooks.create(
106
+ "https://example.com/hooks/sendbyte",
107
+ ["email.delivered", "email.bounced"],
108
+ )
109
+ # endpoint["secret"] is shown once, store it now
110
+ sendbyte.webhooks.list()
111
+ sendbyte.webhooks.disable(endpoint["id"])
112
+ sendbyte.webhooks.replay("evt_123")
113
+ ```
114
+
115
+ ## Verifying webhook signatures
116
+
117
+ Every webhook request carries a `sendbyte-signature` header. Verify it against the raw request body before trusting the payload.
118
+
119
+ ```python
120
+ from sendbyte import verify_webhook_signature
121
+
122
+ @app.post("/hooks/sendbyte")
123
+ def hooks(request):
124
+ valid = verify_webhook_signature(
125
+ secret=os.environ["SENDBYTE_WEBHOOK_SECRET"],
126
+ header=request.headers.get("sendbyte-signature"),
127
+ body=request.get_data(), # the raw, unparsed body
128
+ )
129
+ if not valid:
130
+ return "", 401
131
+ # handle the event
132
+ return "", 200
133
+ ```
134
+
135
+ The check rejects missing, malformed, tampered, and stale signatures (default tolerance is 300 seconds) and never raises.
136
+
137
+ ## Errors
138
+
139
+ Failed requests raise a `SendByteError` carrying the API error shape.
140
+
141
+ ```python
142
+ from sendbyte import SendByteError
143
+
144
+ try:
145
+ sendbyte.emails.send(from_=..., to=..., subject=..., html=...)
146
+ except SendByteError as err:
147
+ print(err.code, err.status, err.docs_url)
148
+ ```
149
+
150
+ ## Links
151
+
152
+ - Documentation: https://docs.sendbyte.africa
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ sendbyte/__init__.py
5
+ sendbyte/py.typed
6
+ sendbyte.egg-info/PKG-INFO
7
+ sendbyte.egg-info/SOURCES.txt
8
+ sendbyte.egg-info/dependency_links.txt
9
+ sendbyte.egg-info/top_level.txt
10
+ tests/test_sdk.py
@@ -0,0 +1 @@
1
+ sendbyte
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,138 @@
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()