usend 1.0.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.
usend-1.0.0/.gitignore ADDED
@@ -0,0 +1,34 @@
1
+ # Secrets / local config
2
+ deploy/.env
3
+ deploy/.env.*
4
+ !deploy/.env.example
5
+ !deploy/.env.test
6
+
7
+ # OS / editor
8
+ .DS_Store
9
+ *.swp
10
+ .idea/
11
+ .vscode/
12
+
13
+ # Go
14
+ backend/bin/
15
+ backend/dist/
16
+ backend/tmp/
17
+ backend/**/*.test
18
+ *.out
19
+ coverage*.txt
20
+ coverage*.html
21
+
22
+ # Web
23
+ web/node_modules/
24
+ web/dist/
25
+ web/dist-docs/
26
+ web/dist-landing/
27
+ web/.vite/
28
+ node_modules/
29
+ npm-debug.log*
30
+
31
+ # Backups and local artifacts
32
+ *.sql.gz
33
+ backups/
34
+ backend/dist-linux/
usend-1.0.0/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usend
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation
7
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
8
+ to permit persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of
11
+ the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
14
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
16
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
17
+ IN THE SOFTWARE.
usend-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: usend
3
+ Version: 1.0.0
4
+ Summary: The official Python SDK for usend, the email API for developers.
5
+ Project-URL: Homepage, https://usend.et
6
+ Project-URL: Documentation, https://usend.et/docs
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: email,email api,transactional email,usend,webhooks
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications :: Email
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # usend for Python
17
+
18
+ The official Python SDK for [usend](https://usend.et), the email API for developers.
19
+
20
+ ```bash
21
+ pip install usend
22
+ ```
23
+
24
+ ```python
25
+ import os
26
+ from usend import Usend
27
+
28
+ usend = Usend(os.environ["USEND_API_KEY"])
29
+
30
+ email = usend.emails.send({
31
+ "from": "Acme <hello@acme.com>",
32
+ "to": "ann@example.com",
33
+ "subject": "Your login code",
34
+ "html": "<p>Your code is <b>482913</b></p>",
35
+ })
36
+ print(email["id"])
37
+ ```
38
+
39
+ Methods return the API's JSON as dicts and raise `usend.UsendError` (`status_code`, `name`, `message`).
40
+
41
+ - Python 3.9 or newer. No dependencies.
42
+ - The key defaults to `USEND_API_KEY`; the base URL to `USEND_BASE_URL` or `https://api.usend.et`.
43
+ - Resources: `emails`, `batch`, `received`, `templates`, `domains`, `api_keys`, `segments`, `contacts`,
44
+ `broadcasts`, `webhooks`, `suppressions`.
45
+
46
+ Verify a webhook (pass the raw body):
47
+
48
+ ```python
49
+ from usend import verify_webhook
50
+
51
+ event = verify_webhook(raw_body, request.headers, os.environ["USEND_WEBHOOK_SECRET"])
52
+ ```
53
+
54
+ Docs: https://usend.et/docs
usend-1.0.0/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # usend for Python
2
+
3
+ The official Python SDK for [usend](https://usend.et), the email API for developers.
4
+
5
+ ```bash
6
+ pip install usend
7
+ ```
8
+
9
+ ```python
10
+ import os
11
+ from usend import Usend
12
+
13
+ usend = Usend(os.environ["USEND_API_KEY"])
14
+
15
+ email = usend.emails.send({
16
+ "from": "Acme <hello@acme.com>",
17
+ "to": "ann@example.com",
18
+ "subject": "Your login code",
19
+ "html": "<p>Your code is <b>482913</b></p>",
20
+ })
21
+ print(email["id"])
22
+ ```
23
+
24
+ Methods return the API's JSON as dicts and raise `usend.UsendError` (`status_code`, `name`, `message`).
25
+
26
+ - Python 3.9 or newer. No dependencies.
27
+ - The key defaults to `USEND_API_KEY`; the base URL to `USEND_BASE_URL` or `https://api.usend.et`.
28
+ - Resources: `emails`, `batch`, `received`, `templates`, `domains`, `api_keys`, `segments`, `contacts`,
29
+ `broadcasts`, `webhooks`, `suppressions`.
30
+
31
+ Verify a webhook (pass the raw body):
32
+
33
+ ```python
34
+ from usend import verify_webhook
35
+
36
+ event = verify_webhook(raw_body, request.headers, os.environ["USEND_WEBHOOK_SECRET"])
37
+ ```
38
+
39
+ Docs: https://usend.et/docs
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "usend"
7
+ version = "1.0.0"
8
+ description = "The official Python SDK for usend, the email API for developers."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ keywords = ["usend", "email", "transactional email", "email api", "webhooks"]
13
+ classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent", "Topic :: Communications :: Email"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://usend.et"
17
+ Documentation = "https://usend.et/docs"
@@ -0,0 +1,76 @@
1
+ import base64, hashlib, hmac, json, os, threading, time, unittest
2
+ from http.server import BaseHTTPRequestHandler, HTTPServer
3
+
4
+ from usend import Usend, UsendError, verify_webhook
5
+
6
+
7
+ class Handler(BaseHTTPRequestHandler):
8
+ seen = []
9
+
10
+ def _reply(self):
11
+ body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
12
+ Handler.seen.append((self.command, self.path, dict(self.headers), body))
13
+ if self.path == "/emails" and json.loads(body).get("to"):
14
+ out, code = {"id": "e1"}, 200
15
+ else:
16
+ out, code = {"statusCode": 422, "name": "validation_error", "message": "The `to` field is required."}, 422
17
+ data = json.dumps(out).encode()
18
+ self.send_response(code)
19
+ self.send_header("Content-Type", "application/json")
20
+ self.send_header("Content-Length", str(len(data)))
21
+ self.end_headers()
22
+ self.wfile.write(data)
23
+
24
+ do_POST = do_GET = _reply
25
+
26
+ def log_message(self, *a):
27
+ pass
28
+
29
+
30
+ class ClientTest(unittest.TestCase):
31
+ @classmethod
32
+ def setUpClass(cls):
33
+ cls.srv = HTTPServer(("127.0.0.1", 0), Handler)
34
+ threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
35
+ cls.usend = Usend("us_test", base_url=f"http://127.0.0.1:{cls.srv.server_port}/")
36
+
37
+ @classmethod
38
+ def tearDownClass(cls):
39
+ cls.srv.shutdown()
40
+
41
+ def test_send(self):
42
+ res = self.usend.emails.send({"from": "a@x.com", "to": "b@y.com", "subject": "Hi", "html": "x"}, idempotency_key="k1")
43
+ self.assertEqual(res, {"id": "e1"})
44
+ method, path, headers, _ = Handler.seen[-1]
45
+ self.assertEqual((method, path), ("POST", "/emails"))
46
+ self.assertEqual(headers["Authorization"], "Bearer us_test")
47
+ self.assertEqual(headers["Idempotency-Key"], "k1")
48
+
49
+ def test_error(self):
50
+ with self.assertRaises(UsendError) as cm:
51
+ self.usend.emails.send({})
52
+ self.assertEqual((cm.exception.status_code, cm.exception.name), (422, "validation_error"))
53
+
54
+ def test_missing_key(self):
55
+ old = os.environ.pop("USEND_API_KEY", None)
56
+ try:
57
+ with self.assertRaises(UsendError):
58
+ Usend()
59
+ finally:
60
+ if old:
61
+ os.environ["USEND_API_KEY"] = old
62
+
63
+ def test_verify_webhook(self):
64
+ raw = os.urandom(24)
65
+ secret = "whsec_" + base64.b64encode(raw).decode()
66
+ payload = json.dumps({"type": "email.delivered", "data": {"email_id": "e1"}})
67
+ ts = str(int(time.time()))
68
+ sig = base64.b64encode(hmac.new(raw, f"m1.{ts}.{payload}".encode(), hashlib.sha256).digest()).decode()
69
+ headers = {"Webhook-Id": "m1", "Webhook-Timestamp": ts, "Webhook-Signature": "v1," + sig}
70
+ self.assertEqual(verify_webhook(payload, headers, secret)["data"]["email_id"], "e1")
71
+ with self.assertRaises(UsendError):
72
+ verify_webhook(payload + " ", headers, secret)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
@@ -0,0 +1,16 @@
1
+ """usend for Python: the email API for developers.
2
+
3
+ from usend import Usend
4
+
5
+ usend = Usend("us_...") # or set USEND_API_KEY
6
+ email = usend.emails.send({"from": "Acme <hi@acme.com>", "to": "ann@example.com",
7
+ "subject": "Hi", "html": "<p>Hi</p>"})
8
+ print(email["id"])
9
+
10
+ Methods return the API's JSON as dicts and raise UsendError on failure.
11
+ """
12
+
13
+ from .client import Usend, UsendError, verify_webhook
14
+
15
+ __all__ = ["Usend", "UsendError", "verify_webhook"]
16
+ __version__ = "1.0.0"
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any, Mapping, Optional
13
+
14
+ DEFAULT_BASE_URL = "https://api.usend.et"
15
+ VERSION = "1.0.0"
16
+
17
+ Json = dict[str, Any]
18
+
19
+
20
+ class UsendError(Exception):
21
+ """An error from the usend API, with its status code and error name."""
22
+
23
+ def __init__(self, message: str, name: str = "application_error", status_code: Optional[int] = None):
24
+ super().__init__(message)
25
+ self.message = message
26
+ self.name = name
27
+ self.status_code = status_code
28
+
29
+ def __repr__(self) -> str:
30
+ return f"UsendError(status_code={self.status_code!r}, name={self.name!r}, message={self.message!r})"
31
+
32
+
33
+ def _q(value: str) -> str:
34
+ return urllib.parse.quote(str(value), safe="")
35
+
36
+
37
+ def _qs(params: Optional[Mapping[str, Any]]) -> str:
38
+ if not params:
39
+ return ""
40
+ clean = {k: v for k, v in params.items() if v is not None}
41
+ return "?" + urllib.parse.urlencode(clean) if clean else ""
42
+
43
+
44
+ class _Resource:
45
+ def __init__(self, client: "Usend"):
46
+ self._c = client
47
+
48
+
49
+ class Emails(_Resource):
50
+ def send(self, email: Json, idempotency_key: Optional[str] = None) -> Json:
51
+ return self._c._request("POST", "/emails", email, _idem(idempotency_key))
52
+
53
+ def get(self, email_id: str) -> Json:
54
+ return self._c._request("GET", f"/emails/{_q(email_id)}")
55
+
56
+ def list(self, **params: Any) -> Json:
57
+ return self._c._request("GET", f"/emails{_qs(params)}")
58
+
59
+ def update(self, email_id: str, scheduled_at: str) -> Json:
60
+ return self._c._request("PATCH", f"/emails/{_q(email_id)}", {"scheduled_at": scheduled_at})
61
+
62
+ def cancel(self, email_id: str) -> Json:
63
+ return self._c._request("POST", f"/emails/{_q(email_id)}/cancel")
64
+
65
+ def events(self, email_id: str) -> Json:
66
+ return self._c._request("GET", f"/emails/{_q(email_id)}/events")
67
+
68
+ def attachments(self, email_id: str) -> Json:
69
+ return self._c._request("GET", f"/emails/{_q(email_id)}/attachments")
70
+
71
+ def attachment(self, email_id: str, attachment_id: str) -> Json:
72
+ return self._c._request("GET", f"/emails/{_q(email_id)}/attachments/{_q(attachment_id)}")
73
+
74
+
75
+ class Batch(_Resource):
76
+ def send(self, emails: list, idempotency_key: Optional[str] = None, validation: Optional[str] = None) -> Json:
77
+ headers = _idem(idempotency_key)
78
+ if validation:
79
+ headers["x-batch-validation"] = validation
80
+ return self._c._request("POST", "/emails/batch", emails, headers)
81
+
82
+
83
+ class Received(_Resource):
84
+ def list(self, **params: Any) -> Json:
85
+ return self._c._request("GET", f"/inbound/emails{_qs(params)}")
86
+
87
+ def get(self, email_id: str) -> Json:
88
+ return self._c._request("GET", f"/inbound/emails/{_q(email_id)}")
89
+
90
+
91
+ class _Crud(_Resource):
92
+ path = ""
93
+
94
+ def create(self, body: Json) -> Json:
95
+ return self._c._request("POST", self.path, body)
96
+
97
+ def list(self, **params: Any) -> Json:
98
+ return self._c._request("GET", f"{self.path}{_qs(params)}")
99
+
100
+ def get(self, ident: str) -> Json:
101
+ return self._c._request("GET", f"{self.path}/{_q(ident)}")
102
+
103
+ def update(self, ident: str, body: Json) -> Json:
104
+ return self._c._request("PATCH", f"{self.path}/{_q(ident)}", body)
105
+
106
+ def remove(self, ident: str) -> Json:
107
+ return self._c._request("DELETE", f"{self.path}/{_q(ident)}")
108
+
109
+
110
+ class Templates(_Crud):
111
+ path = "/templates"
112
+
113
+ def publish(self, id_or_alias: str) -> Json:
114
+ return self._c._request("POST", f"/templates/{_q(id_or_alias)}/publish")
115
+
116
+ def duplicate(self, id_or_alias: str) -> Json:
117
+ return self._c._request("POST", f"/templates/{_q(id_or_alias)}/duplicate")
118
+
119
+
120
+ class Domains(_Crud):
121
+ path = "/domains"
122
+
123
+ def verify(self, domain_id: str) -> Json:
124
+ return self._c._request("POST", f"/domains/{_q(domain_id)}/verify")
125
+
126
+ def auto_configure(self, domain_id: str, api_token: str, provider: str = "cloudflare") -> Json:
127
+ body = {"provider": provider, "api_token": api_token}
128
+ return self._c._request("POST", f"/domains/{_q(domain_id)}/auto-configure", body)
129
+
130
+
131
+ class ApiKeys(_Crud):
132
+ path = "/api-keys"
133
+
134
+
135
+ class Segments(_Crud):
136
+ path = "/segments"
137
+
138
+
139
+ class Contacts(_Crud):
140
+ path = "/contacts"
141
+
142
+ def segments(self, id_or_email: str) -> Json:
143
+ return self._c._request("GET", f"/contacts/{_q(id_or_email)}/segments")
144
+
145
+ def add_to_segment(self, id_or_email: str, segment_id: str) -> Json:
146
+ return self._c._request("POST", f"/contacts/{_q(id_or_email)}/segments/{_q(segment_id)}")
147
+
148
+ def remove_from_segment(self, id_or_email: str, segment_id: str) -> Json:
149
+ return self._c._request("DELETE", f"/contacts/{_q(id_or_email)}/segments/{_q(segment_id)}")
150
+
151
+
152
+ class Broadcasts(_Crud):
153
+ path = "/broadcasts"
154
+
155
+ def send(self, broadcast_id: str, scheduled_at: Optional[str] = None) -> Json:
156
+ body = {"scheduled_at": scheduled_at} if scheduled_at else {}
157
+ return self._c._request("POST", f"/broadcasts/{_q(broadcast_id)}/send", body)
158
+
159
+ def test(self, broadcast_id: str, to: str) -> Json:
160
+ return self._c._request("POST", f"/broadcasts/{_q(broadcast_id)}/test", {"to": to})
161
+
162
+
163
+ class Webhooks(_Crud):
164
+ path = "/webhooks"
165
+
166
+ def deliveries(self, webhook_id: str, **params: Any) -> Json:
167
+ return self._c._request("GET", f"/webhooks/{_q(webhook_id)}/deliveries{_qs(params)}")
168
+
169
+ def replay(self, webhook_id: str, delivery_id: str) -> Json:
170
+ return self._c._request("POST", f"/webhooks/{_q(webhook_id)}/deliveries/{_q(delivery_id)}/replay")
171
+
172
+ @staticmethod
173
+ def verify(payload: str, headers: Mapping[str, str], secret: str, tolerance_seconds: int = 300) -> Json:
174
+ return verify_webhook(payload, headers, secret, tolerance_seconds)
175
+
176
+
177
+ class Suppressions(_Crud):
178
+ path = "/suppressions"
179
+
180
+
181
+ class Usend:
182
+ """Client for the usend API. `base_url` defaults to USEND_BASE_URL or https://api.usend.et."""
183
+
184
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 30.0):
185
+ self.api_key = api_key or os.environ.get("USEND_API_KEY")
186
+ if not self.api_key:
187
+ raise UsendError('Missing API key. Pass it to Usend("us_...") or set USEND_API_KEY.', "missing_api_key")
188
+ self.base_url = (base_url or os.environ.get("USEND_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
189
+ self.timeout = timeout
190
+ self.emails = Emails(self)
191
+ self.batch = Batch(self)
192
+ self.received = Received(self)
193
+ self.templates = Templates(self)
194
+ self.domains = Domains(self)
195
+ self.api_keys = ApiKeys(self)
196
+ self.segments = Segments(self)
197
+ self.contacts = Contacts(self)
198
+ self.broadcasts = Broadcasts(self)
199
+ self.webhooks = Webhooks(self)
200
+ self.suppressions = Suppressions(self)
201
+
202
+ def _request(self, method: str, path: str, body: Any = None, headers: Optional[dict] = None) -> Json:
203
+ h = {"Authorization": f"Bearer {self.api_key}", "User-Agent": f"usend-python/{VERSION}", "Accept": "application/json"}
204
+ data = None
205
+ if body is not None:
206
+ data = json.dumps(body).encode()
207
+ h["Content-Type"] = "application/json"
208
+ h.update(headers or {})
209
+ req = urllib.request.Request(self.base_url + path, data=data, headers=h, method=method)
210
+ try:
211
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
212
+ raw = res.read()
213
+ return json.loads(raw) if raw else {}
214
+ except urllib.error.HTTPError as e:
215
+ try:
216
+ err = json.loads(e.read())
217
+ except ValueError:
218
+ err = {}
219
+ raise UsendError(err.get("message") or f"HTTP {e.code}", err.get("name") or "application_error", e.code) from None
220
+ except urllib.error.URLError as e:
221
+ raise UsendError(str(e.reason)) from None
222
+
223
+
224
+ def _idem(key: Optional[str]) -> dict:
225
+ return {"Idempotency-Key": key} if key else {}
226
+
227
+
228
+ def verify_webhook(payload: str, headers: Mapping[str, str], secret: str, tolerance_seconds: int = 300) -> Json:
229
+ """Verify a webhook delivery (Standard Webhooks) and return the parsed event; raises UsendError if invalid.
230
+
231
+ `payload` must be the raw request body exactly as received.
232
+ """
233
+ lower = {k.lower(): v for k, v in headers.items()}
234
+ msg_id, ts, sigs = lower.get("webhook-id"), lower.get("webhook-timestamp"), lower.get("webhook-signature")
235
+ if not (msg_id and ts and sigs):
236
+ raise UsendError("Missing webhook-id, webhook-timestamp or webhook-signature header", "invalid_webhook")
237
+ if abs(time.time() - int(ts)) > tolerance_seconds:
238
+ raise UsendError("Webhook timestamp is too old or too new", "invalid_webhook")
239
+ key = base64.b64decode(secret.removeprefix("whsec_"))
240
+ expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.{payload}".encode(), hashlib.sha256).digest()).decode()
241
+ for part in sigs.split(" "):
242
+ version, _, sig = part.partition(",")
243
+ if version == "v1" and hmac.compare_digest(sig, expected):
244
+ return json.loads(payload)
245
+ raise UsendError("Invalid webhook signature", "invalid_webhook")