mailtruss 0.1.0__py3-none-any.whl
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.
mailtruss/__init__.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Mailtruss API client. Standard library only.
|
|
2
|
+
|
|
3
|
+
from mailtruss import Mailtruss
|
|
4
|
+
m = Mailtruss(api_key="ms_live_...")
|
|
5
|
+
m.emails.send(from_="Ada <ada@yourdomain.com>", to="you@example.com", subject="Hi", html="<p>Hello</p>")
|
|
6
|
+
|
|
7
|
+
Python keywords are suffixed with an underscore (`from_`); the suffix is removed before the request is sent.
|
|
8
|
+
Retries (429 and 5xx) happen only for reads, deletes, or sends made with an idempotency key, so a send is never repeated by accident.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any, Callable, Dict, Optional
|
|
19
|
+
|
|
20
|
+
__all__ = ["Mailtruss", "MailtrussError", "MailtrussTimeout"]
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MailtrussError(Exception):
|
|
25
|
+
"""Any non-2xx response. `request_id` can be searched in the dashboard's API logs."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, name: str, message: str, request_id: Optional[str] = None, retry_after: Optional[float] = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status, self.name, self.message, self.request_id, self.retry_after = status, name, message, request_id, retry_after
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
32
|
+
rid = f", request {self.request_id}" if self.request_id else ""
|
|
33
|
+
return f"{self.message} ({self.status} {self.name}{rid})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MailtrussTimeout(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _clean(body: Any) -> Any:
|
|
41
|
+
"""`from_` -> `from`, and drop None values so optional arguments are simply omitted."""
|
|
42
|
+
if isinstance(body, dict):
|
|
43
|
+
return {(k[:-1] if k.endswith("_") and k[:-1] in ("from", "import", "global") else k): _clean(v) for k, v in body.items() if v is not None}
|
|
44
|
+
if isinstance(body, list):
|
|
45
|
+
return [_clean(v) for v in body]
|
|
46
|
+
return body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Resource:
|
|
50
|
+
def __init__(self, c: "Mailtruss"):
|
|
51
|
+
self._c = c
|
|
52
|
+
|
|
53
|
+
def _q(self, **params: Any) -> str:
|
|
54
|
+
items = [(k, str(v)) for k, v in params.items() if v is not None and v != ""]
|
|
55
|
+
return "?" + urllib.parse.urlencode(items) if items else ""
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _id(v: str) -> str:
|
|
59
|
+
return urllib.parse.quote(v, safe="")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Emails(_Resource):
|
|
63
|
+
def send(self, *, idempotency_key: Optional[str] = None, **p: Any) -> dict:
|
|
64
|
+
return self._c.request("POST", "/api/v1/emails", p, idempotency_key=idempotency_key)
|
|
65
|
+
|
|
66
|
+
def batch(self, emails: list, *, idempotency_key: Optional[str] = None) -> dict:
|
|
67
|
+
return self._c.request("POST", "/api/v1/emails/batch", emails, idempotency_key=idempotency_key)
|
|
68
|
+
|
|
69
|
+
def get(self, id: str) -> dict:
|
|
70
|
+
return self._c.request("GET", f"/api/v1/emails/{self._id(id)}")
|
|
71
|
+
|
|
72
|
+
def list(self, limit: Optional[int] = None, after: Optional[str] = None) -> dict:
|
|
73
|
+
return self._c.request("GET", "/api/v1/emails" + self._q(limit=limit, after=after))
|
|
74
|
+
|
|
75
|
+
def reschedule(self, id: str, scheduled_at: str) -> dict:
|
|
76
|
+
return self._c.request("PATCH", f"/api/v1/emails/{self._id(id)}", {"scheduled_at": scheduled_at})
|
|
77
|
+
|
|
78
|
+
def cancel(self, id: str) -> dict:
|
|
79
|
+
return self._c.request("POST", f"/api/v1/emails/{self._id(id)}/cancel")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Domains(_Resource):
|
|
83
|
+
def create(self, **p: Any) -> dict: return self._c.request("POST", "/api/v1/domains", p)
|
|
84
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/domains")
|
|
85
|
+
def get(self, id: str) -> dict: return self._c.request("GET", f"/api/v1/domains/{self._id(id)}")
|
|
86
|
+
def verify(self, id: str) -> dict: return self._c.request("POST", f"/api/v1/domains/{self._id(id)}/verify")
|
|
87
|
+
def set_tracking(self, id: str, *, click_tracking: Optional[bool] = None, open_tracking: Optional[bool] = None) -> dict:
|
|
88
|
+
"""Both are off by default. Tell your recipients before turning them on."""
|
|
89
|
+
return self._c.request("PATCH", f"/api/v1/domains/{self._id(id)}", {"click_tracking": click_tracking, "open_tracking": open_tracking})
|
|
90
|
+
def remove(self, id: str) -> dict: return self._c.request("DELETE", f"/api/v1/domains/{self._id(id)}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _ApiKeys(_Resource):
|
|
94
|
+
def create(self, **p: Any) -> dict: return self._c.request("POST", "/api/v1/api-keys", p)
|
|
95
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/api-keys")
|
|
96
|
+
def remove(self, id: str) -> dict: return self._c.request("DELETE", f"/api/v1/api-keys/{self._id(id)}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Contacts(_Resource):
|
|
100
|
+
def create(self, **p: Any) -> dict: return self._c.request("POST", "/api/v1/contacts", p)
|
|
101
|
+
def get(self, id_or_email: str) -> dict: return self._c.request("GET", f"/api/v1/contacts/{self._id(id_or_email)}")
|
|
102
|
+
def list(self, *, q: Optional[str] = None, tag: Optional[str] = None, status: Optional[str] = None, page: Optional[int] = None, limit: Optional[int] = None) -> dict:
|
|
103
|
+
return self._c.request("GET", "/api/v1/contacts" + self._q(q=q, tag=tag, status=status, page=page, limit=limit))
|
|
104
|
+
def update(self, id_or_email: str, **p: Any) -> dict: return self._c.request("PATCH", f"/api/v1/contacts/{self._id(id_or_email)}", p)
|
|
105
|
+
def remove(self, id_or_email: str) -> dict: return self._c.request("DELETE", f"/api/v1/contacts/{self._id(id_or_email)}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Segments(_Resource):
|
|
109
|
+
def create(self, **p: Any) -> dict: return self._c.request("POST", "/api/v1/segments", p)
|
|
110
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/segments")
|
|
111
|
+
def get(self, id: str) -> dict: return self._c.request("GET", f"/api/v1/segments/{self._id(id)}")
|
|
112
|
+
def remove(self, id: str) -> dict: return self._c.request("DELETE", f"/api/v1/segments/{self._id(id)}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Topics(_Resource):
|
|
116
|
+
def create(self, **p: Any) -> dict: return self._c.request("POST", "/api/v1/topics", p)
|
|
117
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/topics")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _Suppressions(_Resource):
|
|
121
|
+
def add(self, email: str) -> dict: return self._c.request("POST", "/api/v1/suppressions", {"email": email})
|
|
122
|
+
def list(self, *, q: Optional[str] = None, reason: Optional[str] = None, page: Optional[int] = None, limit: Optional[int] = None) -> dict:
|
|
123
|
+
return self._c.request("GET", "/api/v1/suppressions" + self._q(q=q, reason=reason, page=page, limit=limit))
|
|
124
|
+
def remove(self, id_or_email: str) -> dict:
|
|
125
|
+
"""Complaints and unsubscribes cannot be removed (403)."""
|
|
126
|
+
return self._c.request("DELETE", f"/api/v1/suppressions/{self._id(id_or_email)}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Webhooks(_Resource):
|
|
130
|
+
def create(self, **p: Any) -> dict:
|
|
131
|
+
"""The signing secret is returned once, here and from rotate_secret."""
|
|
132
|
+
return self._c.request("POST", "/api/v1/webhooks", p)
|
|
133
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/webhooks")
|
|
134
|
+
def get(self, id: str) -> dict: return self._c.request("GET", f"/api/v1/webhooks/{self._id(id)}")
|
|
135
|
+
def update(self, id: str, **p: Any) -> dict: return self._c.request("PATCH", f"/api/v1/webhooks/{self._id(id)}", p)
|
|
136
|
+
def rotate_secret(self, id: str) -> dict: return self._c.request("POST", f"/api/v1/webhooks/{self._id(id)}/rotate-secret")
|
|
137
|
+
def remove(self, id: str) -> dict: return self._c.request("DELETE", f"/api/v1/webhooks/{self._id(id)}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _Templates(_Resource):
|
|
141
|
+
def create(self, **p: Any) -> dict: return self._c.request("POST", "/api/v1/templates", p)
|
|
142
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/templates")
|
|
143
|
+
def get(self, id: str) -> dict: return self._c.request("GET", f"/api/v1/templates/{self._id(id)}")
|
|
144
|
+
def update(self, id: str, **p: Any) -> dict:
|
|
145
|
+
"""Saves a new, unpublished version."""
|
|
146
|
+
return self._c.request("PATCH", f"/api/v1/templates/{self._id(id)}", p)
|
|
147
|
+
def publish(self, id: str, version: Optional[int] = None) -> dict: return self._c.request("POST", f"/api/v1/templates/{self._id(id)}/publish", {"version": version})
|
|
148
|
+
def remove(self, id: str) -> dict: return self._c.request("DELETE", f"/api/v1/templates/{self._id(id)}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _Broadcasts(_Resource):
|
|
152
|
+
def create(self, **p: Any) -> dict:
|
|
153
|
+
"""Creates a draft. Nothing is sent until send()."""
|
|
154
|
+
return self._c.request("POST", "/api/v1/broadcasts", p)
|
|
155
|
+
def list(self) -> dict: return self._c.request("GET", "/api/v1/broadcasts")
|
|
156
|
+
def get(self, id: str) -> dict: return self._c.request("GET", f"/api/v1/broadcasts/{self._id(id)}")
|
|
157
|
+
def send(self, id: str, scheduled_at: Optional[str] = None) -> dict: return self._c.request("POST", f"/api/v1/broadcasts/{self._id(id)}/send", {"scheduled_at": scheduled_at})
|
|
158
|
+
def cancel(self, id: str) -> dict: return self._c.request("POST", f"/api/v1/broadcasts/{self._id(id)}/cancel")
|
|
159
|
+
def remove(self, id: str) -> dict: return self._c.request("DELETE", f"/api/v1/broadcasts/{self._id(id)}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _Logs(_Resource):
|
|
163
|
+
def list(self, limit: Optional[int] = None, after: Optional[str] = None) -> dict:
|
|
164
|
+
return self._c.request("GET", "/api/v1/logs" + self._q(limit=limit, after=after))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Mailtruss:
|
|
168
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 30.0, max_retries: int = 2,
|
|
169
|
+
opener: Optional[Callable[..., Any]] = None, sleep: Callable[[float], None] = time.sleep):
|
|
170
|
+
key = api_key or os.environ.get("MAILTRUSS_API_KEY")
|
|
171
|
+
if not key:
|
|
172
|
+
raise ValueError("An API key is required (pass api_key or set MAILTRUSS_API_KEY).")
|
|
173
|
+
self._key, self.timeout, self.max_retries, self._sleep = key, timeout, max_retries, sleep
|
|
174
|
+
self.base_url = (base_url or os.environ.get("MAILTRUSS_API_URL") or "https://mailtruss.com").rstrip("/")
|
|
175
|
+
self._open = opener or urllib.request.urlopen
|
|
176
|
+
self.emails, self.domains, self.api_keys = _Emails(self), _Domains(self), _ApiKeys(self)
|
|
177
|
+
self.contacts, self.segments, self.topics = _Contacts(self), _Segments(self), _Topics(self)
|
|
178
|
+
self.suppressions, self.webhooks, self.templates = _Suppressions(self), _Webhooks(self), _Templates(self)
|
|
179
|
+
self.broadcasts, self.logs = _Broadcasts(self), _Logs(self)
|
|
180
|
+
|
|
181
|
+
def request(self, method: str, path: str, body: Any = None, *, idempotency_key: Optional[str] = None) -> Any:
|
|
182
|
+
retryable = bool(idempotency_key) or method in ("GET", "DELETE")
|
|
183
|
+
headers = {"Authorization": f"Bearer {self._key}", "Content-Type": "application/json", "User-Agent": f"mailtruss-python/{__version__}"}
|
|
184
|
+
if idempotency_key:
|
|
185
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
186
|
+
data = None if body is None else json.dumps(_clean(body)).encode()
|
|
187
|
+
attempt = 0
|
|
188
|
+
while True:
|
|
189
|
+
req = urllib.request.Request(self.base_url + path, data=data, method=method, headers=headers)
|
|
190
|
+
try:
|
|
191
|
+
with self._open(req, timeout=self.timeout) as r:
|
|
192
|
+
raw = r.read()
|
|
193
|
+
return json.loads(raw) if raw else None
|
|
194
|
+
except urllib.error.HTTPError as e:
|
|
195
|
+
payload: Dict[str, Any] = {}
|
|
196
|
+
try:
|
|
197
|
+
payload = json.loads(e.read() or b"{}")
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
retry_after = None
|
|
201
|
+
try:
|
|
202
|
+
retry_after = float(e.headers.get("retry-after")) if e.headers.get("retry-after") else None
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
pass
|
|
205
|
+
if retryable and attempt < self.max_retries and (e.code == 429 or e.code >= 500):
|
|
206
|
+
self._sleep(retry_after if retry_after is not None else 2 ** attempt)
|
|
207
|
+
attempt += 1
|
|
208
|
+
continue
|
|
209
|
+
raise MailtrussError(e.code, payload.get("name", "application_error"), payload.get("message", str(e.reason)), e.headers.get("x-request-id"), retry_after) from None
|
|
210
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
211
|
+
if isinstance(getattr(e, "reason", e), TimeoutError) or isinstance(e, TimeoutError):
|
|
212
|
+
raise MailtrussTimeout(f"Request timed out after {self.timeout}s. The email may still have been sent; check the dashboard before retrying.") from None
|
|
213
|
+
if retryable and attempt < self.max_retries:
|
|
214
|
+
self._sleep(0.5 * 2 ** attempt)
|
|
215
|
+
attempt += 1
|
|
216
|
+
continue
|
|
217
|
+
raise
|
mailtruss/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailtruss
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Mailtruss email API (no dependencies)
|
|
5
|
+
Author-email: Mailtruss <hello@mailtruss.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://mailtruss.com
|
|
8
|
+
Project-URL: Documentation, https://mailtruss.com/docs
|
|
9
|
+
Keywords: email,transactional-email,email-api,mailtruss
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# mailtruss (Python)
|
|
22
|
+
|
|
23
|
+
Python client for the [Mailtruss](https://mailtruss.com) email API. `pip install mailtruss`.
|
|
24
|
+
|
|
25
|
+
No dependencies. Python 3.9+.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from mailtruss import Mailtruss
|
|
29
|
+
m = Mailtruss(api_key="ms_live_…") # or set MAILTRUSS_API_KEY
|
|
30
|
+
m.emails.send(from_="Ada <ada@yourdomain.com>", to="you@example.com", subject="Hi", html="<p>Hello</p>", idempotency_key="order-42")
|
|
31
|
+
m.contacts.create(email="ann@example.com", first_name="Ann", tags=["beta"])
|
|
32
|
+
m.broadcasts.create(name="News", from_="news@yourdomain.com", subject="Hello", html="<p>…</p>")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Every resource of the HTTP API is covered (see `/api/v1/openapi.json`). `from_` is sent as `from`. A send is retried only when you pass an `idempotency_key`; reads and deletes retry on 429 and 5xx.
|
|
36
|
+
|
|
37
|
+
Run the tests: `python3 -m unittest discover -s tests` from this folder.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mailtruss/__init__.py,sha256=61QKlOuQcAD0YwhrGpigvcVddV0qWoeTK01eWSB7zZE,12178
|
|
2
|
+
mailtruss/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
mailtruss-0.1.0.dist-info/licenses/LICENSE,sha256=3GEnv7fYwmEQSObwRXstmMOtY7WcFMqp-HLf1pIapdU,1066
|
|
4
|
+
mailtruss-0.1.0.dist-info/METADATA,sha256=A-hUDrVCMOav-NcVnqqZZLSXgQiRem7IHzq4mdWAdus,1557
|
|
5
|
+
mailtruss-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
mailtruss-0.1.0.dist-info/top_level.txt,sha256=6EJrUXlkXEWKAC-xAmbyrG41lmAkdQdpayrw92msDfI,10
|
|
7
|
+
mailtruss-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mailtruss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mailtruss
|