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 +34 -0
- usend-1.0.0/LICENSE +17 -0
- usend-1.0.0/PKG-INFO +54 -0
- usend-1.0.0/README.md +39 -0
- usend-1.0.0/pyproject.toml +17 -0
- usend-1.0.0/tests/test_client.py +76 -0
- usend-1.0.0/usend/__init__.py +16 -0
- usend-1.0.0/usend/client.py +245 -0
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")
|