mailkite-dev 0.3.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.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: mailkite-dev
3
+ Version: 0.3.0
4
+ Summary: Official MailKite SDK for Python — send and manage email over your own authenticated domain.
5
+ License: MIT
6
+ Project-URL: Homepage, https://mailkite.dev/docs/libraries
7
+ Project-URL: Repository, https://github.com/mailkite/mailkite
8
+ Keywords: mailkite,email,transactional,api
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+
12
+ # MailKite for Python
13
+
14
+ Official [MailKite](https://mailkite.dev) SDK. One low-level `request()` plus one
15
+ method per endpoint. Zero dependencies — standard library only. Python 3.7+.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install mailkite-dev
21
+ ```
22
+
23
+ > Published as `mailkite-dev` for now (the `mailkite` name is being reclaimed). The
24
+ > import is unchanged — `from mailkite import MailKite`.
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ import os
30
+ from mailkite import MailKite
31
+
32
+ mk = MailKite(os.environ["MAILKITE_API_KEY"])
33
+
34
+ res = mk.send({
35
+ "from": "hello@myapp.ai",
36
+ "to": "ada@example.com",
37
+ "subject": "Your invoice #1042",
38
+ "html": "<p>Thanks! Receipt attached.</p>",
39
+ })
40
+ ```
41
+
42
+ Point at a different base URL with `MailKite(key, "https://api.mailkite.dev")`.
43
+
44
+ ## Methods
45
+
46
+ `send(message)`, `agent(message)`, `route(message)`, `listDomains()`, `createDomain({"domain": ...})`,
47
+ `getDomain(id)`, `deleteDomain(id)`, `verifyDomain(id)`,
48
+ `setWebhook(id, {"url": ...})`, `deleteWebhook(id)`, `testWebhook(id)`,
49
+ `checkDomainAvailability(domain)`, `registerDomain({"domain": ..., "contact": {...}})`,
50
+ `listRoutes()`, `createRoute({...})`, `listMessages()`, `getMessage(id)`,
51
+ `retryDelivery(id)`.
52
+
53
+ ## Errors
54
+
55
+ ```python
56
+ from mailkite import MailKiteError
57
+
58
+ try:
59
+ mk.send(msg)
60
+ except MailKiteError as e:
61
+ print(e.status, e.message)
62
+ ```
63
+
64
+ See the [full docs](https://mailkite.dev/docs/libraries).
@@ -0,0 +1,53 @@
1
+ # MailKite for Python
2
+
3
+ Official [MailKite](https://mailkite.dev) SDK. One low-level `request()` plus one
4
+ method per endpoint. Zero dependencies — standard library only. Python 3.7+.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install mailkite-dev
10
+ ```
11
+
12
+ > Published as `mailkite-dev` for now (the `mailkite` name is being reclaimed). The
13
+ > import is unchanged — `from mailkite import MailKite`.
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ import os
19
+ from mailkite import MailKite
20
+
21
+ mk = MailKite(os.environ["MAILKITE_API_KEY"])
22
+
23
+ res = mk.send({
24
+ "from": "hello@myapp.ai",
25
+ "to": "ada@example.com",
26
+ "subject": "Your invoice #1042",
27
+ "html": "<p>Thanks! Receipt attached.</p>",
28
+ })
29
+ ```
30
+
31
+ Point at a different base URL with `MailKite(key, "https://api.mailkite.dev")`.
32
+
33
+ ## Methods
34
+
35
+ `send(message)`, `agent(message)`, `route(message)`, `listDomains()`, `createDomain({"domain": ...})`,
36
+ `getDomain(id)`, `deleteDomain(id)`, `verifyDomain(id)`,
37
+ `setWebhook(id, {"url": ...})`, `deleteWebhook(id)`, `testWebhook(id)`,
38
+ `checkDomainAvailability(domain)`, `registerDomain({"domain": ..., "contact": {...}})`,
39
+ `listRoutes()`, `createRoute({...})`, `listMessages()`, `getMessage(id)`,
40
+ `retryDelivery(id)`.
41
+
42
+ ## Errors
43
+
44
+ ```python
45
+ from mailkite import MailKiteError
46
+
47
+ try:
48
+ mk.send(msg)
49
+ except MailKiteError as e:
50
+ print(e.status, e.message)
51
+ ```
52
+
53
+ See the [full docs](https://mailkite.dev/docs/libraries).
@@ -0,0 +1,170 @@
1
+ """MailKite SDK for Python.
2
+
3
+ Shape shared by every MailKite SDK: one low-level ``request()`` plus one thin
4
+ method per API endpoint. Zero dependencies — uses the standard library.
5
+
6
+ from mailkite import MailKite
7
+ mk = MailKite(os.environ["MAILKITE_API_KEY"])
8
+ res = mk.send({"from": ..., "to": ..., "subject": ..., "text": ...})
9
+ """
10
+
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+
19
+ DEFAULT_BASE_URL = "https://api.mailkite.dev"
20
+ # Reject webhook events older than this (ms) to block replays. Pass 0 to disable.
21
+ DEFAULT_TOLERANCE_MS = 5 * 60 * 1000
22
+
23
+ __all__ = ["MailKite", "MailKiteError", "verify_webhook"]
24
+
25
+
26
+ def verify_webhook(signature, payload, secret, toleranceMs=DEFAULT_TOLERANCE_MS):
27
+ """Verify an ``x-mailkite-signature`` header on an inbound webhook delivery.
28
+
29
+ Local HMAC-SHA256 check — no network call. Pass the raw, unparsed body
30
+ (``str`` or ``bytes``); the signature is over the exact bytes received.
31
+ Returns ``True`` only when the signature matches and the event is fresh.
32
+ """
33
+ if not isinstance(signature, str) or not signature:
34
+ return False
35
+ parts = {}
36
+ for seg in signature.split(","):
37
+ if "=" in seg:
38
+ k, v = seg.split("=", 1)
39
+ parts[k.strip()] = v.strip()
40
+ t = parts.get("t")
41
+ v1 = parts.get("v1")
42
+ if not t or not v1 or not t.lstrip("-").isdigit():
43
+ return False
44
+ # The t in the header is milliseconds since the epoch.
45
+ if toleranceMs and toleranceMs > 0:
46
+ if abs(time.time() * 1000 - int(t)) > toleranceMs:
47
+ return False
48
+ body = payload.encode("utf-8") if isinstance(payload, str) else payload
49
+ signed = (t + ".").encode("utf-8") + body
50
+ expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
51
+ return hmac.compare_digest(expected, v1)
52
+
53
+
54
+ class MailKiteError(Exception):
55
+ def __init__(self, status, message, body=None):
56
+ super().__init__(message)
57
+ self.status = status
58
+ self.message = message
59
+ self.body = body
60
+
61
+
62
+ class MailKite:
63
+ def __init__(self, apiKey, baseUrl=DEFAULT_BASE_URL):
64
+ self.apiKey = apiKey
65
+ self.baseUrl = baseUrl.rstrip("/")
66
+
67
+ # Low-level request. Every method below is a one-liner on top of this.
68
+ def request(self, method, path, body=None):
69
+ headers = {"Authorization": "Bearer " + self.apiKey}
70
+ data = None
71
+ if body is not None:
72
+ data = json.dumps(body).encode("utf-8")
73
+ headers["Content-Type"] = "application/json"
74
+ req = urllib.request.Request(self.baseUrl + path, data=data, method=method, headers=headers)
75
+ try:
76
+ with urllib.request.urlopen(req) as resp:
77
+ text = resp.read().decode("utf-8")
78
+ return json.loads(text) if text else None
79
+ except urllib.error.HTTPError as e:
80
+ text = e.read().decode("utf-8")
81
+ parsed = json.loads(text) if text else None
82
+ message = parsed.get("error") if isinstance(parsed, dict) else None
83
+ raise MailKiteError(e.code, message or e.reason or "HTTP %d" % e.code, parsed)
84
+
85
+ # --- Sending ----------------------------------------------------------
86
+ def send(self, message):
87
+ """Send an email. ``message`` is a dict with ``from``, ``to`` and a
88
+ body (``text`` and/or ``html``). ``subject`` is optional — it may come
89
+ from a template. Pass ``templateId`` (str) to render a stored template
90
+ and ``templateData`` (dict) to supply its variables."""
91
+ return self.request("POST", "/v1/send", message)
92
+
93
+ def agent(self, message):
94
+ """Send a message to an AI agent inbox. ``message`` is a dict with
95
+ ``text`` (required) and optional ``subject``, ``from``, ``html``,
96
+ ``routeId``, ``address`` and ``model``."""
97
+ return self.request("POST", "/v1/agent", message)
98
+
99
+ def route(self, message):
100
+ """Route a message. ``message`` is a dict with ``from`` (required) and
101
+ optional ``routeId``, ``address``, ``subject``, ``text`` and ``html``."""
102
+ return self.request("POST", "/v1/route", message)
103
+
104
+ # --- Templates --------------------------------------------------------
105
+ def listTemplates(self):
106
+ return self.request("GET", "/api/templates")
107
+
108
+ def listBaseTemplates(self):
109
+ return self.request("GET", "/api/templates/base")
110
+
111
+ def getTemplate(self, id):
112
+ return self.request("GET", "/api/templates/%s" % id)
113
+
114
+ def createTemplate(self, body):
115
+ return self.request("POST", "/api/templates", body)
116
+
117
+ # --- Domains ----------------------------------------------------------
118
+ def listDomains(self):
119
+ return self.request("GET", "/api/domains")
120
+
121
+ def createDomain(self, body):
122
+ return self.request("POST", "/api/domains", body)
123
+
124
+ def getDomain(self, id):
125
+ return self.request("GET", "/api/domains/%s" % id)
126
+
127
+ def deleteDomain(self, id):
128
+ return self.request("DELETE", "/api/domains/%s" % id)
129
+
130
+ def verifyDomain(self, id):
131
+ return self.request("POST", "/api/domains/%s/verify" % id)
132
+
133
+ def setWebhook(self, id, body):
134
+ return self.request("PUT", "/api/domains/%s/webhook" % id, body)
135
+
136
+ def deleteWebhook(self, id):
137
+ return self.request("DELETE", "/api/domains/%s/webhook" % id)
138
+
139
+ def testWebhook(self, id):
140
+ return self.request("POST", "/api/domains/%s/webhook/test" % id)
141
+
142
+ def checkDomainAvailability(self, domain):
143
+ return self.request("GET", "/api/domains/register/check?domain=%s" % urllib.parse.quote(domain))
144
+
145
+ def registerDomain(self, body):
146
+ return self.request("POST", "/api/domains/register", body)
147
+
148
+ # --- Routes -----------------------------------------------------------
149
+ def listRoutes(self):
150
+ return self.request("GET", "/api/routes")
151
+
152
+ def createRoute(self, body):
153
+ return self.request("POST", "/api/routes", body)
154
+
155
+ # --- Messages & deliveries -------------------------------------------
156
+ def listMessages(self):
157
+ return self.request("GET", "/api/messages")
158
+
159
+ def getMessage(self, id):
160
+ return self.request("GET", "/api/messages/%s" % id)
161
+
162
+ def retryDelivery(self, id):
163
+ return self.request("POST", "/api/deliveries/%s/retry" % id)
164
+
165
+ # --- Webhooks ---------------------------------------------------------
166
+ def verifyWebhook(self, signature, payload, secret, toleranceMs=DEFAULT_TOLERANCE_MS):
167
+ """Verify an ``x-mailkite-signature`` header. See module-level
168
+ :func:`verify_webhook` — this is a thin instance wrapper, so you can
169
+ call it on an existing client without re-importing."""
170
+ return verify_webhook(signature, payload, secret, toleranceMs)
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: mailkite-dev
3
+ Version: 0.3.0
4
+ Summary: Official MailKite SDK for Python — send and manage email over your own authenticated domain.
5
+ License: MIT
6
+ Project-URL: Homepage, https://mailkite.dev/docs/libraries
7
+ Project-URL: Repository, https://github.com/mailkite/mailkite
8
+ Keywords: mailkite,email,transactional,api
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+
12
+ # MailKite for Python
13
+
14
+ Official [MailKite](https://mailkite.dev) SDK. One low-level `request()` plus one
15
+ method per endpoint. Zero dependencies — standard library only. Python 3.7+.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install mailkite-dev
21
+ ```
22
+
23
+ > Published as `mailkite-dev` for now (the `mailkite` name is being reclaimed). The
24
+ > import is unchanged — `from mailkite import MailKite`.
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ import os
30
+ from mailkite import MailKite
31
+
32
+ mk = MailKite(os.environ["MAILKITE_API_KEY"])
33
+
34
+ res = mk.send({
35
+ "from": "hello@myapp.ai",
36
+ "to": "ada@example.com",
37
+ "subject": "Your invoice #1042",
38
+ "html": "<p>Thanks! Receipt attached.</p>",
39
+ })
40
+ ```
41
+
42
+ Point at a different base URL with `MailKite(key, "https://api.mailkite.dev")`.
43
+
44
+ ## Methods
45
+
46
+ `send(message)`, `agent(message)`, `route(message)`, `listDomains()`, `createDomain({"domain": ...})`,
47
+ `getDomain(id)`, `deleteDomain(id)`, `verifyDomain(id)`,
48
+ `setWebhook(id, {"url": ...})`, `deleteWebhook(id)`, `testWebhook(id)`,
49
+ `checkDomainAvailability(domain)`, `registerDomain({"domain": ..., "contact": {...}})`,
50
+ `listRoutes()`, `createRoute({...})`, `listMessages()`, `getMessage(id)`,
51
+ `retryDelivery(id)`.
52
+
53
+ ## Errors
54
+
55
+ ```python
56
+ from mailkite import MailKiteError
57
+
58
+ try:
59
+ mk.send(msg)
60
+ except MailKiteError as e:
61
+ print(e.status, e.message)
62
+ ```
63
+
64
+ See the [full docs](https://mailkite.dev/docs/libraries).
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ mailkite/__init__.py
4
+ mailkite_dev.egg-info/PKG-INFO
5
+ mailkite_dev.egg-info/SOURCES.txt
6
+ mailkite_dev.egg-info/dependency_links.txt
7
+ mailkite_dev.egg-info/top_level.txt
8
+ tests/test_sdk.py
@@ -0,0 +1 @@
1
+ mailkite
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Temporary distribution name while the `mailkite` project on PyPI is reclaimed on the
7
+ # old account. Installs as `pip install mailkite-dev`; still imports as `import mailkite`
8
+ # (the package dir below is unchanged). Rename back to `mailkite` once reclaimed.
9
+ name = "mailkite-dev"
10
+ version = "0.3.0"
11
+ description = "Official MailKite SDK for Python — send and manage email over your own authenticated domain."
12
+ readme = "README.md"
13
+ requires-python = ">=3.7"
14
+ license = { text = "MIT" }
15
+ keywords = ["mailkite", "email", "transactional", "api"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://mailkite.dev/docs/libraries"
19
+ Repository = "https://github.com/mailkite/mailkite"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["mailkite*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,178 @@
1
+ """Unit tests for the MailKite Python SDK. Covers every public function:
2
+
3
+ - request() (auth, content-type, JSON body, errors, base-url trim, empty body)
4
+ - one thin method per endpoint (correct verb + path + body)
5
+ - verify_webhook / verifyWebhook (valid / tampered / wrong-secret / malformed /
6
+ replay window)
7
+
8
+ Run with: python3 -m unittest discover -s tests
9
+ """
10
+
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import os
15
+ import sys
16
+ import threading
17
+ import time
18
+ import unittest
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from mailkite import MailKite, MailKiteError, verify_webhook # noqa: E402
24
+
25
+ # ---- in-process mock server -------------------------------------------------
26
+ STATE = {"status": 200, "body": {"ok": True}, "last": None}
27
+
28
+
29
+ class Handler(BaseHTTPRequestHandler):
30
+ def log_message(self, *_): # silence
31
+ pass
32
+
33
+ def _handle(self):
34
+ length = int(self.headers.get("content-length") or 0)
35
+ raw = self.rfile.read(length).decode("utf-8") if length else ""
36
+ STATE["last"] = {
37
+ "method": self.command,
38
+ "path": self.path,
39
+ "headers": {k.lower(): v for k, v in self.headers.items()},
40
+ "raw": raw,
41
+ }
42
+ body = STATE["body"]
43
+ payload = b"" if body is None else json.dumps(body).encode("utf-8")
44
+ self.send_response(STATE["status"])
45
+ self.send_header("content-type", "application/json")
46
+ self.end_headers()
47
+ self.wfile.write(payload)
48
+
49
+ do_GET = do_POST = do_PUT = do_DELETE = _handle
50
+
51
+
52
+ def reply(status, body):
53
+ STATE["status"], STATE["body"] = status, body
54
+
55
+
56
+ SECRET = "whsec_mailkite_test"
57
+ PAYLOAD = '{"type":"email.received","id":"evt_123","message":"It works."}'
58
+ V1 = "3d790f831e170ddba4d001f27532bf2c1fc68ebed52eef72fe453dfa1196b03c"
59
+ HEADER = "t=1750000000000,v1=" + V1
60
+
61
+
62
+ def fresh_header(secret, body):
63
+ t = int(time.time() * 1000)
64
+ sig = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
65
+ return f"t={t},v1={sig}"
66
+
67
+
68
+ class SDKTest(unittest.TestCase):
69
+ @classmethod
70
+ def setUpClass(cls):
71
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
72
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
73
+ cls.thread.start()
74
+ port = cls.server.server_address[1]
75
+ cls.base = f"http://127.0.0.1:{port}"
76
+ cls.key = "mk_live_test"
77
+ cls.mk = MailKite(cls.key, cls.base)
78
+
79
+ @classmethod
80
+ def tearDownClass(cls):
81
+ cls.server.shutdown()
82
+
83
+ # ---- constructor --------------------------------------------------------
84
+ def test_base_url_trim(self):
85
+ self.assertEqual(MailKite("k", "https://api.x.dev///").baseUrl, "https://api.x.dev")
86
+ self.assertEqual(MailKite("k").baseUrl, "https://api.mailkite.dev")
87
+
88
+ # ---- request() ----------------------------------------------------------
89
+ def test_request_auth_and_json(self):
90
+ reply(200, {"id": "x", "status": "queued"})
91
+ out = self.mk.request("POST", "/v1/send", {"a": 1})
92
+ last = STATE["last"]
93
+ self.assertEqual(last["headers"]["authorization"], "Bearer " + self.key)
94
+ self.assertIn("application/json", last["headers"]["content-type"])
95
+ self.assertEqual(json.loads(last["raw"]), {"a": 1})
96
+ self.assertEqual(out, {"id": "x", "status": "queued"})
97
+
98
+ def test_request_no_body(self):
99
+ reply(200, [])
100
+ self.mk.request("GET", "/api/domains")
101
+ self.assertEqual(STATE["last"]["raw"], "")
102
+ self.assertNotIn("content-type", {k: v for k, v in STATE["last"]["headers"].items() if k == "content-type"})
103
+
104
+ def test_request_empty_body_returns_none(self):
105
+ reply(204, None)
106
+ self.assertIsNone(self.mk.request("DELETE", "/api/x"))
107
+
108
+ def test_request_error_maps_to_exception(self):
109
+ reply(404, {"error": "not found"})
110
+ with self.assertRaises(MailKiteError) as ctx:
111
+ self.mk.request("GET", "/api/messages/nope")
112
+ self.assertEqual(ctx.exception.status, 404)
113
+ self.assertEqual(ctx.exception.message, "not found")
114
+ self.assertEqual(ctx.exception.body, {"error": "not found"})
115
+
116
+ def test_request_error_without_error_field(self):
117
+ reply(500, {"nope": True})
118
+ with self.assertRaises(MailKiteError) as ctx:
119
+ self.mk.request("GET", "/x")
120
+ self.assertEqual(ctx.exception.status, 500)
121
+
122
+ # ---- endpoint methods ---------------------------------------------------
123
+ def test_endpoint_methods(self):
124
+ mk = self.mk
125
+ cases = [
126
+ (lambda: mk.send({"from": "a", "to": "b", "subject": "s", "text": "t"}), "POST", "/v1/send", {"from": "a", "to": "b", "subject": "s", "text": "t"}),
127
+ (lambda: mk.listDomains(), "GET", "/api/domains", None),
128
+ (lambda: mk.createDomain({"domain": "x.dev"}), "POST", "/api/domains", {"domain": "x.dev"}),
129
+ (lambda: mk.getDomain("dom_1"), "GET", "/api/domains/dom_1", None),
130
+ (lambda: mk.deleteDomain("dom_1"), "DELETE", "/api/domains/dom_1", None),
131
+ (lambda: mk.verifyDomain("dom_1"), "POST", "/api/domains/dom_1/verify", None),
132
+ (lambda: mk.setWebhook("dom_1", {"url": "https://h.dev"}), "PUT", "/api/domains/dom_1/webhook", {"url": "https://h.dev"}),
133
+ (lambda: mk.deleteWebhook("dom_1"), "DELETE", "/api/domains/dom_1/webhook", None),
134
+ (lambda: mk.testWebhook("dom_1"), "POST", "/api/domains/dom_1/webhook/test", None),
135
+ (lambda: mk.listRoutes(), "GET", "/api/routes", None),
136
+ (lambda: mk.createRoute({"match": "*@x", "action": "webhook", "destination": "u"}), "POST", "/api/routes", {"match": "*@x", "action": "webhook", "destination": "u"}),
137
+ (lambda: mk.listMessages(), "GET", "/api/messages", None),
138
+ (lambda: mk.getMessage("msg_1"), "GET", "/api/messages/msg_1", None),
139
+ (lambda: mk.retryDelivery("dlv_1"), "POST", "/api/deliveries/dlv_1/retry", None),
140
+ ]
141
+ for call, method, path, body in cases:
142
+ reply(200, {"ok": True})
143
+ call()
144
+ last = STATE["last"]
145
+ self.assertEqual(last["method"], method, path)
146
+ self.assertEqual(last["path"], path)
147
+ if body is None:
148
+ self.assertEqual(last["raw"], "")
149
+ else:
150
+ self.assertEqual(json.loads(last["raw"]), body)
151
+
152
+ # ---- verify_webhook -----------------------------------------------------
153
+ def test_verify_valid(self):
154
+ self.assertTrue(verify_webhook(HEADER, PAYLOAD, SECRET, 0))
155
+ self.assertTrue(self.mk.verifyWebhook(HEADER, PAYLOAD, SECRET, 0))
156
+
157
+ def test_verify_bytes_payload(self):
158
+ self.assertTrue(verify_webhook(HEADER, PAYLOAD.encode("utf-8"), SECRET, 0))
159
+
160
+ def test_verify_tampered_body(self):
161
+ self.assertFalse(verify_webhook(HEADER, PAYLOAD + " ", SECRET, 0))
162
+
163
+ def test_verify_wrong_secret(self):
164
+ self.assertFalse(verify_webhook(HEADER, PAYLOAD, "whsec_wrong", 0))
165
+
166
+ def test_verify_malformed(self):
167
+ for h in ["", "garbage", "t=1750000000000", "v1=" + V1, "t=nan,v1=" + V1, None]:
168
+ self.assertFalse(verify_webhook(h, PAYLOAD, SECRET, 0))
169
+
170
+ def test_verify_replay_window(self):
171
+ # Fixed vector is far in the past → default 5-min window rejects it.
172
+ self.assertFalse(verify_webhook(HEADER, PAYLOAD, SECRET))
173
+ # Freshly signed event → passes the default window.
174
+ self.assertTrue(verify_webhook(fresh_header(SECRET, PAYLOAD), PAYLOAD, SECRET))
175
+
176
+
177
+ if __name__ == "__main__":
178
+ unittest.main()