zerelay 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.
zerelay-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerelay
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.
zerelay-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: zerelay
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Zerelay email and SMS API
5
+ License: MIT
6
+ Project-URL: Homepage, https://zerelay.com
7
+ Project-URL: Documentation, https://zerelay.com/developer/docs
8
+ Keywords: zerelay,email,sms,transactional-email,smtp,nigeria,webhooks
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Communications :: Email
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # Zerelay for Python
19
+
20
+ Official Python SDK for the [Zerelay](https://zerelay.com) email and SMS API. No dependencies; Python 3.8+.
21
+
22
+ ```bash
23
+ pip install zerelay
24
+ ```
25
+
26
+ ## Send an email
27
+
28
+ ```python
29
+ from zerelay import Zerelay
30
+
31
+ zerelay = Zerelay() # reads ZERELAY_API_KEY, or pass the key: Zerelay("sk_...")
32
+
33
+ result = zerelay.emails.send(
34
+ to="jane@example.com",
35
+ subject="Your order has shipped",
36
+ body="<p>It will arrive on Friday.</p>",
37
+ )
38
+ print(result["task_id"])
39
+ ```
40
+
41
+ For cc/bcc, separate text and HTML parts, templates or categories, `emails.mail_send()` takes the SendGrid v3 `/mail/send` format:
42
+
43
+ ```python
44
+ zerelay.emails.mail_send({
45
+ "personalizations": [{"to": [{"email": "jane@example.com"}], "cc": [{"email": "ops@example.com"}]}],
46
+ "from": {"email": "orders@yourdomain.com", "name": "Your Store"},
47
+ "subject": "Order #1001",
48
+ "content": [{"type": "text/html", "value": "<p>Thanks for your order</p>"}],
49
+ })
50
+ ```
51
+
52
+ ## Send an SMS
53
+
54
+ ```python
55
+ message = zerelay.sms.send(to="08031234567", body="Your code is 4821")
56
+ print(message["status"], message["cost_kobo"])
57
+ ```
58
+
59
+ ## Test mode
60
+
61
+ Create a **test key** (`sk_test_...`) under **API Keys** in the dashboard. Test keys check every request exactly like a live key does, but nothing is sent, charged or counted toward your quota. Webhooks still fire, marked `"test_mode": true`. Send to `bounce@simulator.zerelay.com` to simulate a bounce.
62
+
63
+ ## Verify webhooks
64
+
65
+ Verify the **raw** request body:
66
+
67
+ ```python
68
+ from zerelay import verify_webhook, ZerelayWebhookError
69
+
70
+ # Flask
71
+ @app.post("/webhooks/zerelay")
72
+ def zerelay_webhook():
73
+ try:
74
+ event = verify_webhook(request.get_data(), request.headers, os.environ["ZERELAY_WEBHOOK_SECRET"])
75
+ except ZerelayWebhookError as exc:
76
+ return str(exc), 400
77
+ # event["event"] is e.g. "delivered", "bounce", "sms.delivered"; de-duplicate on event["id"]
78
+ return "", 200
79
+ ```
80
+
81
+ In Django use `request.body`. In FastAPI use `await request.body()`.
82
+
83
+ ## Errors and retries
84
+
85
+ Failed requests raise `ZerelayError` with `status`, `message` (the API's explanation) and `body`. The client retries automatically:
86
+
87
+ - **Rate limits (429)** are always retried, after the `Retry-After` delay.
88
+ - **Network errors and 502/503/504** are retried only when repeating the request is safe. That covers GETs, plus `emails.send()` and `sms.send()`, which send an automatic `Idempotency-Key`.
89
+
90
+ Change this with `Zerelay(max_retries=0)`.
91
+
92
+ Any other endpoint can be called with `zerelay.request("GET", "/path", query={...}, body={...})`. The full reference is at <https://api.zerelay.com/docs>.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,79 @@
1
+ # Zerelay for Python
2
+
3
+ Official Python SDK for the [Zerelay](https://zerelay.com) email and SMS API. No dependencies; Python 3.8+.
4
+
5
+ ```bash
6
+ pip install zerelay
7
+ ```
8
+
9
+ ## Send an email
10
+
11
+ ```python
12
+ from zerelay import Zerelay
13
+
14
+ zerelay = Zerelay() # reads ZERELAY_API_KEY, or pass the key: Zerelay("sk_...")
15
+
16
+ result = zerelay.emails.send(
17
+ to="jane@example.com",
18
+ subject="Your order has shipped",
19
+ body="<p>It will arrive on Friday.</p>",
20
+ )
21
+ print(result["task_id"])
22
+ ```
23
+
24
+ For cc/bcc, separate text and HTML parts, templates or categories, `emails.mail_send()` takes the SendGrid v3 `/mail/send` format:
25
+
26
+ ```python
27
+ zerelay.emails.mail_send({
28
+ "personalizations": [{"to": [{"email": "jane@example.com"}], "cc": [{"email": "ops@example.com"}]}],
29
+ "from": {"email": "orders@yourdomain.com", "name": "Your Store"},
30
+ "subject": "Order #1001",
31
+ "content": [{"type": "text/html", "value": "<p>Thanks for your order</p>"}],
32
+ })
33
+ ```
34
+
35
+ ## Send an SMS
36
+
37
+ ```python
38
+ message = zerelay.sms.send(to="08031234567", body="Your code is 4821")
39
+ print(message["status"], message["cost_kobo"])
40
+ ```
41
+
42
+ ## Test mode
43
+
44
+ Create a **test key** (`sk_test_...`) under **API Keys** in the dashboard. Test keys check every request exactly like a live key does, but nothing is sent, charged or counted toward your quota. Webhooks still fire, marked `"test_mode": true`. Send to `bounce@simulator.zerelay.com` to simulate a bounce.
45
+
46
+ ## Verify webhooks
47
+
48
+ Verify the **raw** request body:
49
+
50
+ ```python
51
+ from zerelay import verify_webhook, ZerelayWebhookError
52
+
53
+ # Flask
54
+ @app.post("/webhooks/zerelay")
55
+ def zerelay_webhook():
56
+ try:
57
+ event = verify_webhook(request.get_data(), request.headers, os.environ["ZERELAY_WEBHOOK_SECRET"])
58
+ except ZerelayWebhookError as exc:
59
+ return str(exc), 400
60
+ # event["event"] is e.g. "delivered", "bounce", "sms.delivered"; de-duplicate on event["id"]
61
+ return "", 200
62
+ ```
63
+
64
+ In Django use `request.body`. In FastAPI use `await request.body()`.
65
+
66
+ ## Errors and retries
67
+
68
+ Failed requests raise `ZerelayError` with `status`, `message` (the API's explanation) and `body`. The client retries automatically:
69
+
70
+ - **Rate limits (429)** are always retried, after the `Retry-After` delay.
71
+ - **Network errors and 502/503/504** are retried only when repeating the request is safe. That covers GETs, plus `emails.send()` and `sms.send()`, which send an automatic `Idempotency-Key`.
72
+
73
+ Change this with `Zerelay(max_retries=0)`.
74
+
75
+ Any other endpoint can be called with `zerelay.request("GET", "/path", query={...}, body={...})`. The full reference is at <https://api.zerelay.com/docs>.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zerelay"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Zerelay email and SMS API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+ keywords = ["zerelay", "email", "sms", "transactional-email", "smtp", "nigeria", "webhooks"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Communications :: Email",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://zerelay.com"
23
+ Documentation = "https://zerelay.com/developer/docs"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ zerelay = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """Official Python SDK for the Zerelay email and SMS API. https://zerelay.com/developer/docs"""
2
+ from ._client import DEFAULT_BASE_URL, Zerelay
3
+ from ._errors import ZerelayError, ZerelayWebhookError
4
+ from ._version import VERSION
5
+ from ._webhooks import verify_webhook
6
+
7
+ __version__ = VERSION
8
+ __all__ = ["Zerelay", "ZerelayError", "ZerelayWebhookError", "verify_webhook", "DEFAULT_BASE_URL", "VERSION"]
@@ -0,0 +1,215 @@
1
+ import json
2
+ import os
3
+ import random
4
+ import time
5
+ import uuid
6
+ from typing import Any, Dict, List, Optional, Union
7
+ from urllib import error as urlerror
8
+ from urllib import parse, request
9
+
10
+ from ._errors import ZerelayError
11
+ from ._version import VERSION
12
+
13
+ DEFAULT_BASE_URL = "https://api.zerelay.com"
14
+ _RETRYABLE = {429, 502, 503, 504}
15
+
16
+
17
+ def _detail(body: Any, fallback: str) -> str:
18
+ d = body.get("detail") if isinstance(body, dict) else None
19
+ if isinstance(d, list):
20
+ parts = []
21
+ for item in d:
22
+ if isinstance(item, dict) and item.get("msg"):
23
+ parts.append(f"{'.'.join(str(x) for x in item.get('loc', []))}: {item['msg']}")
24
+ else:
25
+ parts.append(str(item))
26
+ return "; ".join(parts)
27
+ return str(d) if d else fallback
28
+
29
+
30
+ class Zerelay:
31
+ """Zerelay API client.
32
+
33
+ zr = Zerelay("sk_...") # or set ZERELAY_API_KEY
34
+ zr.emails.send(to="jane@example.com", subject="Hi", body="<p>Hello</p>")
35
+ zr.sms.send(to="08031234567", body="Your code is 1234")
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ api_key: Optional[str] = None,
41
+ *,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ timeout: float = 30.0,
44
+ max_retries: int = 2,
45
+ ) -> None:
46
+ api_key = api_key or os.environ.get("ZERELAY_API_KEY")
47
+ if not api_key:
48
+ raise ValueError("Missing Zerelay API key: pass it to Zerelay(api_key) or set ZERELAY_API_KEY")
49
+ self.api_key = api_key
50
+ self.base_url = base_url.rstrip("/")
51
+ self.timeout = timeout
52
+ self.max_retries = max_retries
53
+ self.emails = Emails(self)
54
+ self.sms = Sms(self)
55
+
56
+ def key_info(self) -> Dict[str, Any]:
57
+ """What the configured key is: live/test, full/send-only, and the account's rate limits."""
58
+ return self.request("GET", "/api-keys/current")
59
+
60
+ def request(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ *,
65
+ query: Optional[Dict[str, Any]] = None,
66
+ body: Any = None,
67
+ headers: Optional[Dict[str, str]] = None,
68
+ idempotent: bool = False,
69
+ ) -> Any:
70
+ """Call any endpoint (see https://api.zerelay.com/docs). 429s are always retried;
71
+ network errors and 502-504 only for GETs and requests carrying an Idempotency-Key."""
72
+ url = self.base_url + path
73
+ if query:
74
+ clean = {k: ("true" if v is True else "false" if v is False else v) for k, v in query.items() if v is not None}
75
+ if clean:
76
+ url += "?" + parse.urlencode(clean)
77
+ data = json.dumps(body).encode("utf-8") if body is not None else None
78
+ all_headers = {
79
+ "X-API-Key": self.api_key,
80
+ "User-Agent": f"zerelay-python/{VERSION}",
81
+ "Accept": "application/json",
82
+ }
83
+ if data is not None:
84
+ all_headers["Content-Type"] = "application/json"
85
+ all_headers.update(headers or {})
86
+ can_retry_failures = method.upper() == "GET" or idempotent
87
+
88
+ attempt = 0
89
+ while True:
90
+ req = request.Request(url, data=data, method=method.upper(), headers=all_headers)
91
+ try:
92
+ with request.urlopen(req, timeout=self.timeout) as resp:
93
+ raw = resp.read()
94
+ status = resp.status
95
+ test_mode = resp.headers.get("X-Zerelay-Test-Mode") == "true"
96
+ if not raw:
97
+ return {"status": status, "test_mode": test_mode}
98
+ return json.loads(raw)
99
+ except urlerror.HTTPError as exc:
100
+ raw = exc.read()
101
+ try:
102
+ parsed: Any = json.loads(raw) if raw else None
103
+ except ValueError:
104
+ parsed = raw.decode("utf-8", "replace")
105
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
106
+ retry_after_s = float(retry_after) if retry_after and retry_after.replace(".", "", 1).isdigit() else None
107
+ retryable = exc.code == 429 or (can_retry_failures and exc.code in _RETRYABLE)
108
+ if retryable and attempt < self.max_retries:
109
+ time.sleep(min(retry_after_s, 60) if retry_after_s else self._backoff(attempt))
110
+ attempt += 1
111
+ continue
112
+ raise ZerelayError(_detail(parsed, f"HTTP {exc.code}"), status=exc.code, body=parsed, retry_after=retry_after_s) from None
113
+ except (urlerror.URLError, TimeoutError, ConnectionError, OSError) as exc:
114
+ if can_retry_failures and attempt < self.max_retries:
115
+ time.sleep(self._backoff(attempt))
116
+ attempt += 1
117
+ continue
118
+ raise ZerelayError(f"Request to {method.upper()} {path} failed: {exc}") from None
119
+
120
+ @staticmethod
121
+ def _backoff(attempt: int) -> float:
122
+ return min(8.0, 0.5 * 2 ** attempt) * random.uniform(0.8, 1.2)
123
+
124
+
125
+ def _drop_none(values: Dict[str, Any]) -> Dict[str, Any]:
126
+ return {k: v for k, v in values.items() if v is not None}
127
+
128
+
129
+ class Emails:
130
+ def __init__(self, client: Zerelay) -> None:
131
+ self._c = client
132
+
133
+ def send(
134
+ self,
135
+ *,
136
+ to: str,
137
+ subject: str,
138
+ body: str,
139
+ sender_identity_id: Optional[int] = None,
140
+ attachments: Optional[List[Dict[str, str]]] = None,
141
+ idempotency_key: Optional[str] = None,
142
+ ) -> Dict[str, Any]:
143
+ """Send one email. Retries are safe: an Idempotency-Key is generated unless you pass one."""
144
+ return self._c.request(
145
+ "POST", "/send-email",
146
+ body=_drop_none({"to": to, "subject": subject, "body": body, "sender_identity_id": sender_identity_id, "attachments": attachments}),
147
+ headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
148
+ idempotent=True,
149
+ )
150
+
151
+ def send_bulk(
152
+ self,
153
+ *,
154
+ recipients: List[str],
155
+ subject: str,
156
+ body: str,
157
+ sender_identity_id: Optional[int] = None,
158
+ attachments: Optional[List[Dict[str, str]]] = None,
159
+ ) -> Dict[str, Any]:
160
+ """One message to many recipients (each gets their own copy)."""
161
+ return self._c.request(
162
+ "POST", "/send-bulk-emails",
163
+ body=_drop_none({"recipients": recipients, "subject": subject, "body": body, "sender_identity_id": sender_identity_id, "attachments": attachments}),
164
+ )
165
+
166
+ def mail_send(self, payload: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Full control (cc, bcc, separate text/html, templates, categories): SendGrid v3 format."""
168
+ return self._c.request("POST", "/v3/mail/send", body=payload)
169
+
170
+ def get(self, task_id: str) -> Dict[str, Any]:
171
+ return self._c.request("GET", f"/email-status/{parse.quote(task_id, safe='')}")
172
+
173
+ def list(self, **query: Any) -> List[Dict[str, Any]]:
174
+ return self._c.request("GET", "/emails", query=query)
175
+
176
+
177
+ class Sms:
178
+ def __init__(self, client: Zerelay) -> None:
179
+ self._c = client
180
+
181
+ def send(
182
+ self,
183
+ *,
184
+ to: Union[str, List[str]],
185
+ body: str,
186
+ sender_id: Optional[str] = None,
187
+ route: Optional[str] = None,
188
+ schedule_at: Optional[str] = None,
189
+ reference: Optional[str] = None,
190
+ idempotency_key: Optional[str] = None,
191
+ ) -> Dict[str, Any]:
192
+ """Send an SMS to one or more Nigerian numbers, paid from your SMS wallet."""
193
+ return self._c.request(
194
+ "POST", "/sms/send",
195
+ body=_drop_none({"to": to, "body": body, "sender_id": sender_id, "route": route, "schedule_at": schedule_at, "reference": reference}),
196
+ headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
197
+ idempotent=True,
198
+ )
199
+
200
+ def quote(self, *, to: Union[str, List[str]], body: str, sender_id: Optional[str] = None, route: Optional[str] = None) -> Dict[str, Any]:
201
+ """Price a send without sending or charging."""
202
+ return self._c.request("POST", "/sms/quote", body=_drop_none({"to": to, "body": body, "sender_id": sender_id, "route": route}), idempotent=True)
203
+
204
+ def get(self, message_id: str, *, refresh: bool = False) -> Dict[str, Any]:
205
+ return self._c.request("GET", f"/sms/messages/{parse.quote(message_id, safe='')}", query={"refresh": refresh})
206
+
207
+ def list(self, **query: Any) -> List[Dict[str, Any]]:
208
+ return self._c.request("GET", "/sms/messages", query=query)
209
+
210
+ def cancel(self, message_id: str) -> Dict[str, Any]:
211
+ """Cancel a scheduled message (fully refunded)."""
212
+ return self._c.request("POST", f"/sms/messages/{parse.quote(message_id, safe='')}/cancel")
213
+
214
+ def wallet(self) -> Dict[str, Any]:
215
+ return self._c.request("GET", "/sms/wallet")
@@ -0,0 +1,16 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class ZerelayError(Exception):
5
+ """An API error. `status` is the HTTP status (None for network errors)."""
6
+
7
+ def __init__(self, message: str, *, status: Optional[int] = None, body: Any = None, retry_after: Optional[float] = None) -> None:
8
+ super().__init__(message)
9
+ self.message = message
10
+ self.status = status
11
+ self.body = body
12
+ self.retry_after = retry_after
13
+
14
+
15
+ class ZerelayWebhookError(Exception):
16
+ """A webhook failed verification (bad signature, stale timestamp, missing headers)."""
@@ -0,0 +1 @@
1
+ VERSION = "0.1.0"
@@ -0,0 +1,58 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import time
5
+ from typing import Any, Mapping, Optional, Union
6
+
7
+ from ._errors import ZerelayWebhookError
8
+
9
+
10
+ def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
11
+ getter = getattr(headers, "get", None)
12
+ if getter is not None:
13
+ value = getter(name)
14
+ if value is None:
15
+ value = getter(name.lower())
16
+ if value is not None:
17
+ return value[0] if isinstance(value, (list, tuple)) else str(value)
18
+ lower = name.lower()
19
+ for key, value in dict(headers).items():
20
+ if str(key).lower() == lower:
21
+ return value[0] if isinstance(value, (list, tuple)) else str(value)
22
+ return None
23
+
24
+
25
+ def verify_webhook(
26
+ payload: Union[bytes, str],
27
+ headers: Mapping[str, Any],
28
+ secret: str,
29
+ tolerance_seconds: int = 300,
30
+ now: Optional[float] = None,
31
+ ) -> Any:
32
+ """Verify a Zerelay webhook and return the parsed event.
33
+
34
+ Pass the RAW request body (e.g. Flask `request.get_data()`, Django `request.body`,
35
+ FastAPI `await request.body()`), not re-serialised JSON. Raises ZerelayWebhookError if
36
+ the signature is wrong or the timestamp is older than `tolerance_seconds` (0 disables).
37
+ """
38
+ if not secret:
39
+ raise ZerelayWebhookError("Missing webhook signing secret")
40
+ raw = payload if isinstance(payload, bytes) else str(payload).encode("utf-8")
41
+ signature = _header(headers, "X-Zerelay-Signature") or _header(headers, "X-SMTP-Signature")
42
+ timestamp = _header(headers, "X-Zerelay-Timestamp") or _header(headers, "X-SMTP-Timestamp")
43
+ if not signature or not timestamp:
44
+ raise ZerelayWebhookError("Missing X-Zerelay-Signature or X-Zerelay-Timestamp header")
45
+ try:
46
+ ts = int(timestamp)
47
+ except ValueError:
48
+ raise ZerelayWebhookError("Invalid X-Zerelay-Timestamp header") from None
49
+ current = time.time() if now is None else now
50
+ if tolerance_seconds and abs(current - ts) > tolerance_seconds:
51
+ raise ZerelayWebhookError("Webhook timestamp is outside the allowed tolerance (possible replay)")
52
+ expected = "sha256=" + hmac.new(secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw, hashlib.sha256).hexdigest()
53
+ if not hmac.compare_digest(expected, signature):
54
+ raise ZerelayWebhookError("Invalid webhook signature")
55
+ try:
56
+ return json.loads(raw)
57
+ except ValueError:
58
+ raise ZerelayWebhookError("Webhook body is not valid JSON") from None
File without changes
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: zerelay
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Zerelay email and SMS API
5
+ License: MIT
6
+ Project-URL: Homepage, https://zerelay.com
7
+ Project-URL: Documentation, https://zerelay.com/developer/docs
8
+ Keywords: zerelay,email,sms,transactional-email,smtp,nigeria,webhooks
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Communications :: Email
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # Zerelay for Python
19
+
20
+ Official Python SDK for the [Zerelay](https://zerelay.com) email and SMS API. No dependencies; Python 3.8+.
21
+
22
+ ```bash
23
+ pip install zerelay
24
+ ```
25
+
26
+ ## Send an email
27
+
28
+ ```python
29
+ from zerelay import Zerelay
30
+
31
+ zerelay = Zerelay() # reads ZERELAY_API_KEY, or pass the key: Zerelay("sk_...")
32
+
33
+ result = zerelay.emails.send(
34
+ to="jane@example.com",
35
+ subject="Your order has shipped",
36
+ body="<p>It will arrive on Friday.</p>",
37
+ )
38
+ print(result["task_id"])
39
+ ```
40
+
41
+ For cc/bcc, separate text and HTML parts, templates or categories, `emails.mail_send()` takes the SendGrid v3 `/mail/send` format:
42
+
43
+ ```python
44
+ zerelay.emails.mail_send({
45
+ "personalizations": [{"to": [{"email": "jane@example.com"}], "cc": [{"email": "ops@example.com"}]}],
46
+ "from": {"email": "orders@yourdomain.com", "name": "Your Store"},
47
+ "subject": "Order #1001",
48
+ "content": [{"type": "text/html", "value": "<p>Thanks for your order</p>"}],
49
+ })
50
+ ```
51
+
52
+ ## Send an SMS
53
+
54
+ ```python
55
+ message = zerelay.sms.send(to="08031234567", body="Your code is 4821")
56
+ print(message["status"], message["cost_kobo"])
57
+ ```
58
+
59
+ ## Test mode
60
+
61
+ Create a **test key** (`sk_test_...`) under **API Keys** in the dashboard. Test keys check every request exactly like a live key does, but nothing is sent, charged or counted toward your quota. Webhooks still fire, marked `"test_mode": true`. Send to `bounce@simulator.zerelay.com` to simulate a bounce.
62
+
63
+ ## Verify webhooks
64
+
65
+ Verify the **raw** request body:
66
+
67
+ ```python
68
+ from zerelay import verify_webhook, ZerelayWebhookError
69
+
70
+ # Flask
71
+ @app.post("/webhooks/zerelay")
72
+ def zerelay_webhook():
73
+ try:
74
+ event = verify_webhook(request.get_data(), request.headers, os.environ["ZERELAY_WEBHOOK_SECRET"])
75
+ except ZerelayWebhookError as exc:
76
+ return str(exc), 400
77
+ # event["event"] is e.g. "delivered", "bounce", "sms.delivered"; de-duplicate on event["id"]
78
+ return "", 200
79
+ ```
80
+
81
+ In Django use `request.body`. In FastAPI use `await request.body()`.
82
+
83
+ ## Errors and retries
84
+
85
+ Failed requests raise `ZerelayError` with `status`, `message` (the API's explanation) and `body`. The client retries automatically:
86
+
87
+ - **Rate limits (429)** are always retried, after the `Retry-After` delay.
88
+ - **Network errors and 502/503/504** are retried only when repeating the request is safe. That covers GETs, plus `emails.send()` and `sms.send()`, which send an automatic `Idempotency-Key`.
89
+
90
+ Change this with `Zerelay(max_retries=0)`.
91
+
92
+ Any other endpoint can be called with `zerelay.request("GET", "/path", query={...}, body={...})`. The full reference is at <https://api.zerelay.com/docs>.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/zerelay/__init__.py
5
+ src/zerelay/_client.py
6
+ src/zerelay/_errors.py
7
+ src/zerelay/_version.py
8
+ src/zerelay/_webhooks.py
9
+ src/zerelay/py.typed
10
+ src/zerelay.egg-info/PKG-INFO
11
+ src/zerelay.egg-info/SOURCES.txt
12
+ src/zerelay.egg-info/dependency_links.txt
13
+ src/zerelay.egg-info/top_level.txt
14
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ zerelay
@@ -0,0 +1,141 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import os
5
+ import sys
6
+ import threading
7
+ import time
8
+ import unittest
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+
11
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
12
+
13
+ from zerelay import Zerelay, ZerelayError, ZerelayWebhookError, verify_webhook # noqa: E402
14
+
15
+
16
+ class Mock:
17
+ def __init__(self, responder):
18
+ self.calls = []
19
+ mock = self
20
+
21
+ class H(BaseHTTPRequestHandler):
22
+ def log_message(self, *a):
23
+ pass
24
+
25
+ def _handle(self):
26
+ length = int(self.headers.get("Content-Length") or 0)
27
+ body = self.rfile.read(length) if length else b""
28
+ mock.calls.append({"method": self.command, "path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": json.loads(body) if body else None})
29
+ status, data, headers = responder(len(mock.calls))
30
+ self.send_response(status)
31
+ for k, v in (headers or {}).items():
32
+ self.send_header(k, v)
33
+ payload = b"" if data is None else json.dumps(data).encode()
34
+ self.send_header("Content-Length", str(len(payload)))
35
+ self.end_headers()
36
+ self.wfile.write(payload)
37
+
38
+ do_GET = do_POST = _handle
39
+
40
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), H)
41
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
42
+ self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
43
+
44
+ def close(self):
45
+ self.server.shutdown()
46
+
47
+
48
+ class ClientTests(unittest.TestCase):
49
+ def test_send_email_headers_and_idempotency(self):
50
+ m = Mock(lambda n: (200, {"task_id": "t1", "status": "queued", "message": "ok", "test_mode": False}, None))
51
+ zr = Zerelay("sk_test_x", base_url=m.url)
52
+ out = zr.emails.send(to="a@example.com", subject="s", body="b")
53
+ self.assertEqual(out["task_id"], "t1")
54
+ call = m.calls[0]
55
+ self.assertEqual(call["path"], "/send-email")
56
+ self.assertEqual(call["headers"]["x-api-key"], "sk_test_x")
57
+ self.assertTrue(call["headers"]["user-agent"].startswith("zerelay-python/"))
58
+ self.assertEqual(len(call["headers"]["idempotency-key"]), 36)
59
+ self.assertEqual(call["body"], {"to": "a@example.com", "subject": "s", "body": "b"})
60
+ m.close()
61
+
62
+ def test_429_retry_after_keeps_idempotency_key(self):
63
+ m = Mock(lambda n: (429, {"detail": "Rate limit exceeded"}, {"Retry-After": "1"}) if n == 1 else (200, {"id": "sms_1"}, None))
64
+ zr = Zerelay("sk_x", base_url=m.url)
65
+ t0 = time.time()
66
+ self.assertEqual(zr.sms.send(to="08031234567", body="hi")["id"], "sms_1")
67
+ self.assertGreaterEqual(time.time() - t0, 0.9)
68
+ self.assertEqual(m.calls[0]["headers"]["idempotency-key"], m.calls[1]["headers"]["idempotency-key"])
69
+ m.close()
70
+
71
+ def test_503_only_retried_when_safe(self):
72
+ m = Mock(lambda n: (503, {"detail": "Worker unavailable"}, None))
73
+ zr = Zerelay("sk_x", base_url=m.url, max_retries=1)
74
+ with self.assertRaises(ZerelayError) as ctx:
75
+ zr.emails.send_bulk(recipients=["a@example.com"], subject="s", body="b")
76
+ self.assertEqual(ctx.exception.status, 503)
77
+ self.assertEqual(str(ctx.exception), "Worker unavailable")
78
+ self.assertEqual(len(m.calls), 1)
79
+ with self.assertRaises(ZerelayError):
80
+ zr.emails.get("t1")
81
+ self.assertEqual(len(m.calls), 3)
82
+ m.close()
83
+
84
+ def test_validation_detail(self):
85
+ m = Mock(lambda n: (422, {"detail": [{"loc": ["body", "to"], "msg": "value is not a valid email address"}]}, None))
86
+ with self.assertRaisesRegex(ZerelayError, r"body\.to: value is not a valid email address"):
87
+ Zerelay("sk_x", base_url=m.url).emails.send(to="x", subject="s", body="b")
88
+ m.close()
89
+
90
+ def test_202_no_body_and_query(self):
91
+ m = Mock(lambda n: (202, None, {"X-Zerelay-Test-Mode": "true"}) if n == 1 else (200, [], None))
92
+ zr = Zerelay("sk_x", base_url=m.url + "/")
93
+ self.assertEqual(zr.emails.mail_send({"personalizations": []}), {"status": 202, "test_mode": True})
94
+ zr.sms.get("sms_a/b", refresh=True)
95
+ self.assertEqual(m.calls[1]["path"], "/sms/messages/sms_a%2Fb?refresh=true")
96
+ m.close()
97
+
98
+ def test_network_error(self):
99
+ zr = Zerelay("sk_x", base_url="http://127.0.0.1:1", max_retries=0, timeout=2)
100
+ with self.assertRaisesRegex(ZerelayError, "failed"):
101
+ zr.sms.wallet()
102
+
103
+ def test_missing_key(self):
104
+ saved = os.environ.pop("ZERELAY_API_KEY", None)
105
+ with self.assertRaises(ValueError):
106
+ Zerelay()
107
+ if saved:
108
+ os.environ["ZERELAY_API_KEY"] = saved
109
+
110
+
111
+ class WebhookTests(unittest.TestCase):
112
+ secret = "whsec_test"
113
+
114
+ def sign(self, body: bytes, ts: str) -> str:
115
+ return "sha256=" + hmac.new(self.secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
116
+
117
+ def test_valid_and_legacy(self):
118
+ body = json.dumps({"id": 1, "event": "delivered"}).encode()
119
+ ts = str(int(time.time()))
120
+ sig = self.sign(body, ts)
121
+ self.assertEqual(verify_webhook(body, {"X-Zerelay-Signature": sig, "X-Zerelay-Timestamp": ts}, self.secret)["event"], "delivered")
122
+ self.assertEqual(verify_webhook(body.decode(), {"x-smtp-signature": sig, "x-smtp-timestamp": ts}, self.secret)["id"], 1)
123
+
124
+ def test_rejections(self):
125
+ body = b'{"id":1}'
126
+ ts = str(int(time.time()))
127
+ sig = self.sign(body, ts)
128
+ with self.assertRaisesRegex(ZerelayWebhookError, "Invalid webhook signature"):
129
+ verify_webhook(body + b" ", {"x-zerelay-signature": sig, "x-zerelay-timestamp": ts}, self.secret)
130
+ with self.assertRaisesRegex(ZerelayWebhookError, "Invalid webhook signature"):
131
+ verify_webhook(body, {"x-zerelay-signature": sig, "x-zerelay-timestamp": ts}, "whsec_other")
132
+ old = str(int(time.time()) - 3600)
133
+ with self.assertRaisesRegex(ZerelayWebhookError, "tolerance"):
134
+ verify_webhook(body, {"x-zerelay-signature": self.sign(body, old), "x-zerelay-timestamp": old}, self.secret)
135
+ self.assertEqual(verify_webhook(body, {"x-zerelay-signature": self.sign(body, old), "x-zerelay-timestamp": old}, self.secret, tolerance_seconds=0)["id"], 1)
136
+ with self.assertRaisesRegex(ZerelayWebhookError, "Missing"):
137
+ verify_webhook(body, {}, self.secret)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ unittest.main()