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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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