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.
- mailkite_dev-0.3.0/PKG-INFO +64 -0
- mailkite_dev-0.3.0/README.md +53 -0
- mailkite_dev-0.3.0/mailkite/__init__.py +170 -0
- mailkite_dev-0.3.0/mailkite_dev.egg-info/PKG-INFO +64 -0
- mailkite_dev-0.3.0/mailkite_dev.egg-info/SOURCES.txt +8 -0
- mailkite_dev-0.3.0/mailkite_dev.egg-info/dependency_links.txt +1 -0
- mailkite_dev-0.3.0/mailkite_dev.egg-info/top_level.txt +1 -0
- mailkite_dev-0.3.0/pyproject.toml +22 -0
- mailkite_dev-0.3.0/setup.cfg +4 -0
- mailkite_dev-0.3.0/tests/test_sdk.py +178 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|