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 +21 -0
- sendbyte-0.1.0/PKG-INFO +156 -0
- sendbyte-0.1.0/README.md +135 -0
- sendbyte-0.1.0/pyproject.toml +31 -0
- sendbyte-0.1.0/sendbyte/__init__.py +304 -0
- sendbyte-0.1.0/sendbyte/py.typed +0 -0
- sendbyte-0.1.0/sendbyte.egg-info/PKG-INFO +156 -0
- sendbyte-0.1.0/sendbyte.egg-info/SOURCES.txt +10 -0
- sendbyte-0.1.0/sendbyte.egg-info/dependency_links.txt +1 -0
- sendbyte-0.1.0/sendbyte.egg-info/top_level.txt +1 -0
- sendbyte-0.1.0/setup.cfg +4 -0
- sendbyte-0.1.0/tests/test_sdk.py +138 -0
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.
|
sendbyte-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
sendbyte-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sendbyte
|
sendbyte-0.1.0/setup.cfg
ADDED
|
@@ -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()
|