axene-mailer 0.1.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.
- axene_mailer-0.1.0/.gitignore +20 -0
- axene_mailer-0.1.0/LICENSE +21 -0
- axene_mailer-0.1.0/PKG-INFO +75 -0
- axene_mailer-0.1.0/README.md +54 -0
- axene_mailer-0.1.0/pyproject.toml +32 -0
- axene_mailer-0.1.0/src/axene_mailer/__init__.py +36 -0
- axene_mailer-0.1.0/src/axene_mailer/_http.py +127 -0
- axene_mailer-0.1.0/src/axene_mailer/_serialize.py +73 -0
- axene_mailer-0.1.0/src/axene_mailer/client.py +51 -0
- axene_mailer-0.1.0/src/axene_mailer/errors.py +23 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/__init__.py +1 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/contacts.py +104 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/domains.py +77 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/emails.py +106 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/suppressions.py +55 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/templates.py +79 -0
- axene_mailer-0.1.0/src/axene_mailer/resources/webhooks.py +61 -0
- axene_mailer-0.1.0/tests/test_client.py +174 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Axene Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axene-mailer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for Axene Mailer: send receipts, confirmations, and campaigns from your own domain. Priced in KES, billed via M-Pesa.
|
|
5
|
+
Project-URL: Homepage, https://axene.io/docs/mailer/getting-started/welcome
|
|
6
|
+
Project-URL: Repository, https://github.com/Axene-Solutions/axene-sdks
|
|
7
|
+
Project-URL: Issues, https://github.com/Axene-Solutions/axene-sdks/issues
|
|
8
|
+
Author: Axene Solutions
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: africa,axene,email,kes,mailer,mpesa,transactional
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Communications :: Email
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# axene-mailer (Python)
|
|
23
|
+
|
|
24
|
+
Official Python client for [Axene Mailer](https://axene.io). Send receipts,
|
|
25
|
+
confirmations, and campaigns from your own domain, priced in KES, billed via M-Pesa.
|
|
26
|
+
|
|
27
|
+
Pure standard library: no runtime dependencies. Python 3.8+.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install axene-mailer
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from axene_mailer import Axene
|
|
39
|
+
|
|
40
|
+
axene = Axene(api_key="axm_k_your_api_key")
|
|
41
|
+
|
|
42
|
+
res = axene.emails.send({
|
|
43
|
+
"from": {"email": "hello@yourdomain.com", "name": "Your Shop"},
|
|
44
|
+
"to": "customer@example.com",
|
|
45
|
+
"subject": "Your receipt",
|
|
46
|
+
"html": "<p>Thanks for your order.</p>",
|
|
47
|
+
"text": "Thanks for your order.",
|
|
48
|
+
})
|
|
49
|
+
print("queued", res["id"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`from`, `to`, `cc`, `bcc` accept a string, a `{"email", "name"}` dict, or a list of either.
|
|
53
|
+
|
|
54
|
+
### More
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
axene.emails.send_batch([{...}, {...}]) # bare array under the hood
|
|
58
|
+
axene.emails.get(res["id"]) # status
|
|
59
|
+
axene.emails.validate({...}) # dry-run: would this send?
|
|
60
|
+
axene.domains.list() # your sending domains
|
|
61
|
+
|
|
62
|
+
# Scheduling (Starter plan and up)
|
|
63
|
+
from datetime import datetime, timedelta, timezone
|
|
64
|
+
axene.emails.send({..., "send_at": datetime.now(timezone.utc) + timedelta(hours=1)})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Errors and retries
|
|
68
|
+
|
|
69
|
+
Non-2xx responses raise `AxeneError` (`.status`, `.code`, `.args[0]` message).
|
|
70
|
+
The client retries 429 and 5xx with exponential backoff (configurable via
|
|
71
|
+
`max_retries`).
|
|
72
|
+
|
|
73
|
+
Get an API key at [mail.axene.io](https://mail.axene.io). Docs: <https://axene.io/docs/mailer/getting-started/welcome>.
|
|
74
|
+
|
|
75
|
+
MIT (c) Axene Solutions
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# axene-mailer (Python)
|
|
2
|
+
|
|
3
|
+
Official Python client for [Axene Mailer](https://axene.io). Send receipts,
|
|
4
|
+
confirmations, and campaigns from your own domain, priced in KES, billed via M-Pesa.
|
|
5
|
+
|
|
6
|
+
Pure standard library: no runtime dependencies. Python 3.8+.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install axene-mailer
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from axene_mailer import Axene
|
|
18
|
+
|
|
19
|
+
axene = Axene(api_key="axm_k_your_api_key")
|
|
20
|
+
|
|
21
|
+
res = axene.emails.send({
|
|
22
|
+
"from": {"email": "hello@yourdomain.com", "name": "Your Shop"},
|
|
23
|
+
"to": "customer@example.com",
|
|
24
|
+
"subject": "Your receipt",
|
|
25
|
+
"html": "<p>Thanks for your order.</p>",
|
|
26
|
+
"text": "Thanks for your order.",
|
|
27
|
+
})
|
|
28
|
+
print("queued", res["id"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`from`, `to`, `cc`, `bcc` accept a string, a `{"email", "name"}` dict, or a list of either.
|
|
32
|
+
|
|
33
|
+
### More
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
axene.emails.send_batch([{...}, {...}]) # bare array under the hood
|
|
37
|
+
axene.emails.get(res["id"]) # status
|
|
38
|
+
axene.emails.validate({...}) # dry-run: would this send?
|
|
39
|
+
axene.domains.list() # your sending domains
|
|
40
|
+
|
|
41
|
+
# Scheduling (Starter plan and up)
|
|
42
|
+
from datetime import datetime, timedelta, timezone
|
|
43
|
+
axene.emails.send({..., "send_at": datetime.now(timezone.utc) + timedelta(hours=1)})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Errors and retries
|
|
47
|
+
|
|
48
|
+
Non-2xx responses raise `AxeneError` (`.status`, `.code`, `.args[0]` message).
|
|
49
|
+
The client retries 429 and 5xx with exponential backoff (configurable via
|
|
50
|
+
`max_retries`).
|
|
51
|
+
|
|
52
|
+
Get an API key at [mail.axene.io](https://mail.axene.io). Docs: <https://axene.io/docs/mailer/getting-started/welcome>.
|
|
53
|
+
|
|
54
|
+
MIT (c) Axene Solutions
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "axene-mailer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for Axene Mailer: send receipts, confirmations, and campaigns from your own domain. Priced in KES, billed via M-Pesa."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Axene Solutions" }]
|
|
13
|
+
keywords = ["email", "mailer", "axene", "africa", "kes", "mpesa", "transactional"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Communications :: Email",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://axene.io/docs/mailer/getting-started/welcome"
|
|
28
|
+
Repository = "https://github.com/Axene-Solutions/axene-sdks"
|
|
29
|
+
Issues = "https://github.com/Axene-Solutions/axene-sdks/issues"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/axene_mailer"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Axene Mailer SDK for Python.
|
|
2
|
+
|
|
3
|
+
Professional email for Africa: send receipts, confirmations, and campaigns from
|
|
4
|
+
your own domain. Priced in KES, billed via M-Pesa.
|
|
5
|
+
|
|
6
|
+
from axene_mailer import Axene
|
|
7
|
+
|
|
8
|
+
axene = Axene(api_key="axm_k_your_api_key")
|
|
9
|
+
axene.emails.send({
|
|
10
|
+
"from": "hello@yourdomain.com",
|
|
11
|
+
"to": "customer@example.com",
|
|
12
|
+
"subject": "Your receipt",
|
|
13
|
+
"html": "<p>Thanks for your order.</p>",
|
|
14
|
+
})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .client import Axene
|
|
18
|
+
from .errors import AxeneError
|
|
19
|
+
from .resources.contacts import Contacts
|
|
20
|
+
from .resources.domains import Domains
|
|
21
|
+
from .resources.emails import Emails
|
|
22
|
+
from .resources.suppressions import Suppressions
|
|
23
|
+
from .resources.templates import Templates
|
|
24
|
+
from .resources.webhooks import Webhooks
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Axene",
|
|
28
|
+
"AxeneError",
|
|
29
|
+
"Emails",
|
|
30
|
+
"Domains",
|
|
31
|
+
"Contacts",
|
|
32
|
+
"Suppressions",
|
|
33
|
+
"Templates",
|
|
34
|
+
"Webhooks",
|
|
35
|
+
]
|
|
36
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTTP transport: the single place that talks to the network. Owns
|
|
2
|
+
authentication, JSON encoding, timeouts, retries with backoff, and turning
|
|
3
|
+
non-2xx responses into :class:`AxeneError`. Uses only the standard library so
|
|
4
|
+
the package has no runtime dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from .errors import AxeneError
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BASE = "https://mail.axene.io"
|
|
19
|
+
_USER_AGENT = "axene-mailer-python/0.1.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HttpTransport:
|
|
23
|
+
"""Performs authenticated JSON requests, retrying ``429`` and ``5xx``."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
base_url: Optional[str] = None,
|
|
29
|
+
max_retries: int = 3,
|
|
30
|
+
timeout: float = 30.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise ValueError("api_key is required")
|
|
34
|
+
self._api_key = api_key
|
|
35
|
+
self._base_url = (base_url or _DEFAULT_BASE).rstrip("/")
|
|
36
|
+
self._max_retries = max(1, max_retries)
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
|
|
39
|
+
def request(self, method: str, path: str, body: Any = None) -> Any:
|
|
40
|
+
"""Send a request and return the parsed JSON response."""
|
|
41
|
+
url = f"{self._base_url}{path}"
|
|
42
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
43
|
+
last_error: Optional[Exception] = None
|
|
44
|
+
|
|
45
|
+
for attempt in range(1, self._max_retries + 1):
|
|
46
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
47
|
+
req.add_header("Authorization", f"Bearer {self._api_key}")
|
|
48
|
+
req.add_header("Content-Type", "application/json")
|
|
49
|
+
req.add_header("User-Agent", _USER_AGENT)
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
52
|
+
raw = resp.read().decode("utf-8")
|
|
53
|
+
return json.loads(raw) if raw else None
|
|
54
|
+
except urllib.error.HTTPError as e: # the server responded with a non-2xx
|
|
55
|
+
status = e.code
|
|
56
|
+
if self._is_retryable(status) and attempt < self._max_retries:
|
|
57
|
+
time.sleep(self._backoff(e, attempt))
|
|
58
|
+
continue
|
|
59
|
+
raise self._to_error(status, e.read().decode("utf-8", "replace"))
|
|
60
|
+
except urllib.error.URLError as e: # transport / DNS / timeout
|
|
61
|
+
last_error = e
|
|
62
|
+
if attempt < self._max_retries:
|
|
63
|
+
time.sleep(self._backoff(None, attempt))
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
raise AxeneError(0, f"Axene request failed: {last_error}")
|
|
67
|
+
|
|
68
|
+
def upload(self, path: str, file_bytes: bytes, filename: str) -> Any:
|
|
69
|
+
"""Upload a single file as ``multipart/form-data`` under the field name
|
|
70
|
+
``file``.
|
|
71
|
+
|
|
72
|
+
The multipart body is built by hand (boundary included) so the package
|
|
73
|
+
stays dependency-free. Not retried, since uploads are not idempotent.
|
|
74
|
+
"""
|
|
75
|
+
url = f"{self._base_url}{path}"
|
|
76
|
+
boundary = "axene" + os.urandom(16).hex()
|
|
77
|
+
crlf = b"\r\n"
|
|
78
|
+
head = (
|
|
79
|
+
f'--{boundary}\r\n'
|
|
80
|
+
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
81
|
+
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
82
|
+
).encode("utf-8")
|
|
83
|
+
tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
|
84
|
+
data = head + file_bytes + tail
|
|
85
|
+
|
|
86
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
87
|
+
req.add_header("Authorization", f"Bearer {self._api_key}")
|
|
88
|
+
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
|
89
|
+
req.add_header("User-Agent", _USER_AGENT)
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
92
|
+
raw = resp.read().decode("utf-8")
|
|
93
|
+
return json.loads(raw) if raw else None
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
raise self._to_error(e.code, e.read().decode("utf-8", "replace"))
|
|
96
|
+
except urllib.error.URLError as e:
|
|
97
|
+
raise AxeneError(0, f"Axene upload failed: {e}")
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _is_retryable(status: int) -> bool:
|
|
101
|
+
return status == 429 or status >= 500
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _backoff(err: Optional[urllib.error.HTTPError], attempt: int) -> float:
|
|
105
|
+
if err is not None:
|
|
106
|
+
retry_after = err.headers.get("Retry-After") if err.headers else None
|
|
107
|
+
if retry_after and retry_after.isdigit():
|
|
108
|
+
return float(retry_after)
|
|
109
|
+
return 0.25 * (2 ** (attempt - 1))
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _to_error(status: int, raw: str) -> AxeneError:
|
|
113
|
+
"""Map the API's ``{"detail": {"code", "message"}}`` (or string) body."""
|
|
114
|
+
message = f"Axene request failed ({status})"
|
|
115
|
+
code: Optional[str] = None
|
|
116
|
+
payload: Any = None
|
|
117
|
+
try:
|
|
118
|
+
payload = json.loads(raw)
|
|
119
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
120
|
+
if isinstance(detail, dict):
|
|
121
|
+
message = detail.get("message", message)
|
|
122
|
+
code = detail.get("code")
|
|
123
|
+
elif isinstance(detail, str):
|
|
124
|
+
message = detail
|
|
125
|
+
except (ValueError, AttributeError):
|
|
126
|
+
pass # non-JSON body: keep the generic message
|
|
127
|
+
return AxeneError(status, message, code, payload)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Internal helpers that translate ergonomic inputs into the exact JSON the
|
|
2
|
+
API expects. Not part of the public API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
Address = Union[str, Dict[str, Any]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _to_address(a: Address) -> Dict[str, Any]:
|
|
13
|
+
"""A bare string becomes ``{"email": ...}``."""
|
|
14
|
+
return {"email": a} if isinstance(a, str) else a
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_address_list(a: Optional[Union[Address, List[Address]]]) -> Optional[List[Dict[str, Any]]]:
|
|
18
|
+
if a is None:
|
|
19
|
+
return None
|
|
20
|
+
items = a if isinstance(a, list) else [a]
|
|
21
|
+
return [_to_address(x) for x in items]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _iso(value: Any) -> Optional[str]:
|
|
25
|
+
if value is None:
|
|
26
|
+
return None
|
|
27
|
+
return value.isoformat() if isinstance(value, datetime) else value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prune(o: Dict[str, Any]) -> Dict[str, Any]:
|
|
31
|
+
"""Drop keys whose value is ``None`` so they are omitted from the JSON body."""
|
|
32
|
+
return {k: v for k, v in o.items() if v is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def query(params: Dict[str, Any]) -> str:
|
|
36
|
+
"""Build a URL query string, skipping ``None`` values.
|
|
37
|
+
|
|
38
|
+
Returns ``""`` when nothing is set, or ``"?a=1&b=2"`` otherwise. ``datetime``
|
|
39
|
+
values are serialized to ISO 8601.
|
|
40
|
+
"""
|
|
41
|
+
from urllib.parse import urlencode
|
|
42
|
+
|
|
43
|
+
pairs = []
|
|
44
|
+
for k, v in params.items():
|
|
45
|
+
if v is None:
|
|
46
|
+
continue
|
|
47
|
+
pairs.append((k, _iso(v) if isinstance(v, datetime) else str(v)))
|
|
48
|
+
encoded = urlencode(pairs)
|
|
49
|
+
return f"?{encoded}" if encoded else ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def serialize_send(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
53
|
+
"""Build the JSON body for a send.
|
|
54
|
+
|
|
55
|
+
The API names the sender field ``from_`` on the wire; callers pass a clean
|
|
56
|
+
``"from"`` key, so the mapping happens here. Keys with ``None`` values are
|
|
57
|
+
omitted.
|
|
58
|
+
"""
|
|
59
|
+
body = {
|
|
60
|
+
"from_": _to_address(p["from"]),
|
|
61
|
+
"to": _to_address_list(p["to"]),
|
|
62
|
+
"subject": p["subject"],
|
|
63
|
+
"html": p.get("html"),
|
|
64
|
+
"text": p.get("text"),
|
|
65
|
+
"cc": _to_address_list(p.get("cc")),
|
|
66
|
+
"bcc": _to_address_list(p.get("bcc")),
|
|
67
|
+
"reply_to": _to_address(p["reply_to"]) if p.get("reply_to") else None,
|
|
68
|
+
"headers": p.get("headers"),
|
|
69
|
+
"tags": p.get("tags"),
|
|
70
|
+
"send_at": _iso(p.get("send_at")),
|
|
71
|
+
"attachments": p.get("attachments"),
|
|
72
|
+
}
|
|
73
|
+
return {k: v for k, v in body.items() if v is not None}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The Axene client: composes the HTTP transport with the resource groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._http import HttpTransport
|
|
8
|
+
from .resources.contacts import Contacts
|
|
9
|
+
from .resources.domains import Domains
|
|
10
|
+
from .resources.emails import Emails
|
|
11
|
+
from .resources.suppressions import Suppressions
|
|
12
|
+
from .resources.templates import Templates
|
|
13
|
+
from .resources.webhooks import Webhooks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Axene:
|
|
17
|
+
"""Axene Mailer API client.
|
|
18
|
+
|
|
19
|
+
Example::
|
|
20
|
+
|
|
21
|
+
from axene_mailer import Axene
|
|
22
|
+
|
|
23
|
+
axene = Axene(api_key="axm_k_your_api_key")
|
|
24
|
+
axene.emails.send({
|
|
25
|
+
"from": "hello@yourdomain.com",
|
|
26
|
+
"to": "customer@example.com",
|
|
27
|
+
"subject": "Your receipt",
|
|
28
|
+
"html": "<p>Thanks for your order.</p>",
|
|
29
|
+
})
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str,
|
|
35
|
+
base_url: Optional[str] = None,
|
|
36
|
+
max_retries: int = 3,
|
|
37
|
+
timeout: float = 30.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
http = HttpTransport(api_key, base_url=base_url, max_retries=max_retries, timeout=timeout)
|
|
40
|
+
#: Send and inspect emails.
|
|
41
|
+
self.emails = Emails(http)
|
|
42
|
+
#: Register, verify, and transfer sending domains.
|
|
43
|
+
self.domains = Domains(http)
|
|
44
|
+
#: Manage subscriber lists, contacts, CSV imports, and bulk sends.
|
|
45
|
+
self.contacts = Contacts(http)
|
|
46
|
+
#: Manage the do-not-send suppression list.
|
|
47
|
+
self.suppressions = Suppressions(http)
|
|
48
|
+
#: Manage reusable email templates.
|
|
49
|
+
self.templates = Templates(http)
|
|
50
|
+
#: Manage webhook subscriptions and inspect deliveries.
|
|
51
|
+
self.webhooks = Webhooks(http)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Error types raised by the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AxeneError(Exception):
|
|
9
|
+
"""Raised for any non-2xx API response, or a transport failure that
|
|
10
|
+
survives all retries.
|
|
11
|
+
|
|
12
|
+
Inspect :attr:`status` and :attr:`code` to branch on specific failures
|
|
13
|
+
(for example a ``422`` with code ``"invalid"``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status: int, message: str, code: Optional[str] = None, detail: Any = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
#: HTTP status code. ``0`` indicates a transport/network failure.
|
|
19
|
+
self.status = status
|
|
20
|
+
#: Machine-readable error code from the API body, when present.
|
|
21
|
+
self.code = code
|
|
22
|
+
#: The raw parsed response body, for debugging.
|
|
23
|
+
self.detail = detail
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API resource groups."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The ``contacts`` resource: manage subscriber lists, their contacts, CSV
|
|
2
|
+
imports, and templated bulk sends."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .._http import HttpTransport
|
|
9
|
+
from .._serialize import prune, query
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Contacts:
|
|
13
|
+
"""Accessed as ``axene.contacts``."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
16
|
+
self._http = http
|
|
17
|
+
|
|
18
|
+
def list_lists(self) -> List[Dict[str, Any]]:
|
|
19
|
+
"""List all subscriber lists in the active workspace."""
|
|
20
|
+
return self._http.request("GET", "/v1/contacts/")
|
|
21
|
+
|
|
22
|
+
def create_list(
|
|
23
|
+
self,
|
|
24
|
+
name: str,
|
|
25
|
+
description: Optional[str] = None,
|
|
26
|
+
icon_seed: Optional[str] = None,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""Create a subscriber list."""
|
|
29
|
+
body = prune({"name": name, "description": description, "icon_seed": icon_seed})
|
|
30
|
+
return self._http.request("POST", "/v1/contacts/", body)
|
|
31
|
+
|
|
32
|
+
def get_list(self, list_id: str, page: int = 0, limit: int = 50) -> Dict[str, Any]:
|
|
33
|
+
"""Get a list with a page of its contacts (zero-based ``page``)."""
|
|
34
|
+
return self._http.request("GET", f"/v1/contacts/{list_id}{query({'page': page, 'limit': limit})}")
|
|
35
|
+
|
|
36
|
+
def update_list(
|
|
37
|
+
self,
|
|
38
|
+
list_id: str,
|
|
39
|
+
name: Optional[str] = None,
|
|
40
|
+
description: Optional[str] = None,
|
|
41
|
+
icon_seed: Optional[str] = None,
|
|
42
|
+
) -> Dict[str, Any]:
|
|
43
|
+
"""Update a list's name, description, or icon (partial)."""
|
|
44
|
+
body = prune({"name": name, "description": description, "icon_seed": icon_seed})
|
|
45
|
+
return self._http.request("PATCH", f"/v1/contacts/{list_id}", body)
|
|
46
|
+
|
|
47
|
+
def delete_list(self, list_id: str) -> None:
|
|
48
|
+
"""Delete a list and all of its contacts."""
|
|
49
|
+
return self._http.request("DELETE", f"/v1/contacts/{list_id}")
|
|
50
|
+
|
|
51
|
+
def add_contact(
|
|
52
|
+
self,
|
|
53
|
+
list_id: str,
|
|
54
|
+
email: str,
|
|
55
|
+
name: Optional[str] = None,
|
|
56
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
"""Add a single contact to a list."""
|
|
59
|
+
body = prune({"email": email, "name": name, "metadata": metadata})
|
|
60
|
+
return self._http.request("POST", f"/v1/contacts/{list_id}/contacts", body)
|
|
61
|
+
|
|
62
|
+
def remove_contact(self, list_id: str, contact_id: str) -> None:
|
|
63
|
+
"""Remove a contact from a list."""
|
|
64
|
+
return self._http.request("DELETE", f"/v1/contacts/{list_id}/contacts/{contact_id}")
|
|
65
|
+
|
|
66
|
+
def upload_csv(
|
|
67
|
+
self,
|
|
68
|
+
list_id: str,
|
|
69
|
+
file_bytes: bytes,
|
|
70
|
+
filename: str = "contacts.csv",
|
|
71
|
+
) -> Dict[str, Any]:
|
|
72
|
+
"""Import contacts from a CSV file (header row required).
|
|
73
|
+
|
|
74
|
+
The email column is auto-detected; other columns become contact
|
|
75
|
+
metadata. Sent as ``multipart/form-data`` under the field ``file``.
|
|
76
|
+
"""
|
|
77
|
+
return self._http.upload(f"/v1/contacts/{list_id}/upload", file_bytes, filename)
|
|
78
|
+
|
|
79
|
+
def bulk_send(
|
|
80
|
+
self,
|
|
81
|
+
list_id: str,
|
|
82
|
+
sender_address_id: str,
|
|
83
|
+
subject: str,
|
|
84
|
+
html: Optional[str] = None,
|
|
85
|
+
text: Optional[str] = None,
|
|
86
|
+
tags: Optional[List[str]] = None,
|
|
87
|
+
) -> Dict[str, Any]:
|
|
88
|
+
"""Send a templated email to every contact in a list.
|
|
89
|
+
|
|
90
|
+
``contact_list_id`` is injected automatically from ``list_id``.
|
|
91
|
+
Subject/html/text may use ``{{email}}``, ``{{name}}``, and
|
|
92
|
+
``{{metadata_key}}`` placeholders.
|
|
93
|
+
"""
|
|
94
|
+
body = prune(
|
|
95
|
+
{
|
|
96
|
+
"contact_list_id": list_id,
|
|
97
|
+
"sender_address_id": sender_address_id,
|
|
98
|
+
"subject": subject,
|
|
99
|
+
"html": html,
|
|
100
|
+
"text": text,
|
|
101
|
+
"tags": tags,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return self._http.request("POST", f"/v1/contacts/{list_id}/send", body)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The ``domains`` resource: register, verify, inspect, and transfer sending
|
|
2
|
+
domains."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .._http import HttpTransport
|
|
9
|
+
from .._serialize import query
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Domains:
|
|
13
|
+
"""Accessed as ``axene.domains``."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
16
|
+
self._http = http
|
|
17
|
+
|
|
18
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
19
|
+
"""List your sending domains and their verification status."""
|
|
20
|
+
return self._http.request("GET", "/v1/domains/")
|
|
21
|
+
|
|
22
|
+
def create(self, name: str) -> Dict[str, Any]:
|
|
23
|
+
"""Register a new sending domain. Returns the DNS records to publish."""
|
|
24
|
+
return self._http.request("POST", "/v1/domains/", {"name": name})
|
|
25
|
+
|
|
26
|
+
def get(self, domain_id: str) -> Dict[str, Any]:
|
|
27
|
+
"""Fetch a domain with its DKIM selector and DNS records."""
|
|
28
|
+
return self._http.request("GET", f"/v1/domains/{domain_id}")
|
|
29
|
+
|
|
30
|
+
def delete(self, domain_id: str) -> None:
|
|
31
|
+
"""Delete a domain."""
|
|
32
|
+
return self._http.request("DELETE", f"/v1/domains/{domain_id}")
|
|
33
|
+
|
|
34
|
+
def verify(self, domain_id: str) -> Dict[str, Any]:
|
|
35
|
+
"""Re-check DNS and verify the domain."""
|
|
36
|
+
return self._http.request("POST", f"/v1/domains/{domain_id}/verify")
|
|
37
|
+
|
|
38
|
+
def health(self, domain_id: str) -> Dict[str, Any]:
|
|
39
|
+
"""Run live DNS health checks (DKIM, SPF, DMARC, return-path, MX)."""
|
|
40
|
+
return self._http.request("GET", f"/v1/domains/{domain_id}/health")
|
|
41
|
+
|
|
42
|
+
def diagnose(self, domain_id: str) -> Dict[str, Any]:
|
|
43
|
+
"""Diagnose configuration issues and get a health score."""
|
|
44
|
+
return self._http.request("GET", f"/v1/domains/{domain_id}/diagnose")
|
|
45
|
+
|
|
46
|
+
def mx_status(self, domain_id: str) -> Dict[str, Any]:
|
|
47
|
+
"""Current MX status for inbound/forwarding (shape varies by provider)."""
|
|
48
|
+
return self._http.request("GET", f"/v1/domains/{domain_id}/mx-status")
|
|
49
|
+
|
|
50
|
+
def published_records(self, domain_id: str) -> Dict[str, Any]:
|
|
51
|
+
"""The values currently published in DNS for each of the domain's records."""
|
|
52
|
+
return self._http.request("GET", f"/v1/domains/{domain_id}/published-records")
|
|
53
|
+
|
|
54
|
+
def rotate_dkim(self, domain_id: str) -> Dict[str, Any]:
|
|
55
|
+
"""Rotate the domain's DKIM key, returning the new record to publish."""
|
|
56
|
+
return self._http.request("POST", f"/v1/domains/{domain_id}/rotate-dkim")
|
|
57
|
+
|
|
58
|
+
def transfer(
|
|
59
|
+
self,
|
|
60
|
+
domain_id: str,
|
|
61
|
+
target_email: str,
|
|
62
|
+
note: Optional[str] = None,
|
|
63
|
+
) -> Dict[str, Any]:
|
|
64
|
+
"""Initiate a transfer of this domain to another Axene account."""
|
|
65
|
+
return self._http.request(
|
|
66
|
+
"POST",
|
|
67
|
+
f"/v1/domains/{domain_id}/transfer",
|
|
68
|
+
{"target_email": target_email, "note": note},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def check_availability(self, name: str) -> Dict[str, Any]:
|
|
72
|
+
"""Check whether a domain name is available to add (checks public DNS)."""
|
|
73
|
+
return self._http.request("GET", f"/v1/domains/check-availability{query({'name': name})}")
|
|
74
|
+
|
|
75
|
+
def check(self, name: str) -> Dict[str, Any]:
|
|
76
|
+
"""Check whether a domain name already exists in your account."""
|
|
77
|
+
return self._http.request("GET", f"/v1/domains/check/{name}")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The ``emails`` resource: send, look up, search, schedule, and inspect
|
|
2
|
+
messages."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from .._http import HttpTransport
|
|
10
|
+
from .._serialize import _iso, query, serialize_send
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Emails:
|
|
14
|
+
"""Accessed as ``axene.emails``."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
17
|
+
self._http = http
|
|
18
|
+
|
|
19
|
+
def send(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
20
|
+
"""Send a single email.
|
|
21
|
+
|
|
22
|
+
``message`` keys: ``from`` (required), ``to`` (required), ``subject``
|
|
23
|
+
(required), and optionally ``html``, ``text``, ``cc``, ``bcc``,
|
|
24
|
+
``reply_to``, ``headers``, ``tags``, ``send_at`` (``datetime`` or ISO
|
|
25
|
+
string), ``attachments``. ``from``/``to``/``cc``/``bcc`` accept a
|
|
26
|
+
string, a ``{"email", "name"}`` dict, or a list.
|
|
27
|
+
"""
|
|
28
|
+
return self._http.request("POST", "/v1/emails/", serialize_send(message))
|
|
29
|
+
|
|
30
|
+
def send_batch(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
31
|
+
"""Send up to your plan's batch limit. The API accepts a bare array."""
|
|
32
|
+
return self._http.request("POST", "/v1/emails/batch", [serialize_send(m) for m in messages])
|
|
33
|
+
|
|
34
|
+
def validate(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
35
|
+
"""Dry-run a send: check whether ``message`` would be accepted (sender
|
|
36
|
+
registered, domain verified, plan limits, account not restricted)
|
|
37
|
+
without actually sending it. Returns ``valid``, ``can_send``,
|
|
38
|
+
``issues``, ``plan`` and ``usage``.
|
|
39
|
+
"""
|
|
40
|
+
return self._http.request("POST", "/v1/emails/validate", serialize_send(message))
|
|
41
|
+
|
|
42
|
+
def list(
|
|
43
|
+
self,
|
|
44
|
+
status: Optional[str] = None,
|
|
45
|
+
page: int = 0,
|
|
46
|
+
limit: int = 20,
|
|
47
|
+
) -> List[Dict[str, Any]]:
|
|
48
|
+
"""List recent emails, newest first (zero-based ``page``)."""
|
|
49
|
+
params = {"status": status, "page": page, "limit": limit}
|
|
50
|
+
return self._http.request("GET", f"/v1/emails/{query(params)}")
|
|
51
|
+
|
|
52
|
+
def get(self, email_id: str) -> Dict[str, Any]:
|
|
53
|
+
"""Fetch a single email with its bodies and events."""
|
|
54
|
+
return self._http.request("GET", f"/v1/emails/{email_id}")
|
|
55
|
+
|
|
56
|
+
def events(self, email_id: str) -> List[Dict[str, Any]]:
|
|
57
|
+
"""List delivery / open / click / bounce events for an email."""
|
|
58
|
+
return self._http.request("GET", f"/v1/emails/{email_id}/events")
|
|
59
|
+
|
|
60
|
+
def retry(self, email_id: str) -> Dict[str, Any]:
|
|
61
|
+
"""Re-send a bounced, rejected, or failed email as a new message."""
|
|
62
|
+
return self._http.request("POST", f"/v1/emails/{email_id}/retry")
|
|
63
|
+
|
|
64
|
+
def search(
|
|
65
|
+
self,
|
|
66
|
+
q: Optional[str] = None,
|
|
67
|
+
status: Optional[str] = None,
|
|
68
|
+
tag: Optional[str] = None,
|
|
69
|
+
page: int = 0,
|
|
70
|
+
limit: int = 20,
|
|
71
|
+
) -> List[Dict[str, Any]]:
|
|
72
|
+
"""Search emails.
|
|
73
|
+
|
|
74
|
+
``q`` supports inline tokens (``to:``, ``from:``, ``status:``,
|
|
75
|
+
``domain:``, ``tag:``); leftover words are matched as free text.
|
|
76
|
+
"""
|
|
77
|
+
params = {"q": q, "status": status, "tag": tag, "page": page, "limit": limit}
|
|
78
|
+
return self._http.request("GET", f"/v1/emails/search{query(params)}")
|
|
79
|
+
|
|
80
|
+
def list_scheduled(self) -> List[Dict[str, Any]]:
|
|
81
|
+
"""List emails scheduled for future delivery, soonest first."""
|
|
82
|
+
return self._http.request("GET", "/v1/emails/scheduled")
|
|
83
|
+
|
|
84
|
+
def cancel_scheduled(self, email_id: str) -> Dict[str, Any]:
|
|
85
|
+
"""Cancel a scheduled email."""
|
|
86
|
+
return self._http.request("DELETE", f"/v1/emails/scheduled/{email_id}")
|
|
87
|
+
|
|
88
|
+
def send_scheduled_now(self, email_id: str) -> Dict[str, Any]:
|
|
89
|
+
"""Send a scheduled email immediately instead of waiting."""
|
|
90
|
+
return self._http.request("POST", f"/v1/emails/scheduled/{email_id}/send-now")
|
|
91
|
+
|
|
92
|
+
def updates(self, since: Union[str, datetime]) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Poll for emails whose status changed at or after ``since`` (a
|
|
94
|
+
``datetime`` or ISO 8601 string). Capped at 50 rows.
|
|
95
|
+
"""
|
|
96
|
+
return self._http.request("GET", f"/v1/emails/updates{query({'since': _iso(since)})}")
|
|
97
|
+
|
|
98
|
+
def get_saved_searches(self) -> List[Dict[str, Any]]:
|
|
99
|
+
"""Get the caller's saved searches."""
|
|
100
|
+
result = self._http.request("GET", "/v1/emails/saved-searches")
|
|
101
|
+
return result.get("searches", []) if isinstance(result, dict) else result
|
|
102
|
+
|
|
103
|
+
def set_saved_searches(self, searches: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
104
|
+
"""Replace the caller's saved searches (max 50)."""
|
|
105
|
+
result = self._http.request("PUT", "/v1/emails/saved-searches", {"searches": searches})
|
|
106
|
+
return result.get("searches", []) if isinstance(result, dict) else result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The ``suppressions`` resource: manage the do-not-send list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .._http import HttpTransport
|
|
8
|
+
from .._serialize import query
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Suppressions:
|
|
12
|
+
"""Accessed as ``axene.suppressions``."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list(
|
|
18
|
+
self,
|
|
19
|
+
page: int = 0,
|
|
20
|
+
limit: int = 50,
|
|
21
|
+
search: Optional[str] = None,
|
|
22
|
+
) -> Dict[str, Any]:
|
|
23
|
+
"""List suppressed addresses.
|
|
24
|
+
|
|
25
|
+
Returns a paginated envelope ``{items, total, page, limit}`` (zero-based
|
|
26
|
+
``page``).
|
|
27
|
+
"""
|
|
28
|
+
params = {"page": page, "limit": limit, "search": search}
|
|
29
|
+
return self._http.request("GET", f"/v1/suppressions{query(params)}")
|
|
30
|
+
|
|
31
|
+
def add(self, email: str, reason: str = "manual") -> Dict[str, Any]:
|
|
32
|
+
"""Suppress a single address.
|
|
33
|
+
|
|
34
|
+
The address maps to the wire field ``email_address``.
|
|
35
|
+
"""
|
|
36
|
+
return self._http.request(
|
|
37
|
+
"POST",
|
|
38
|
+
"/v1/suppressions",
|
|
39
|
+
{"email_address": email, "reason": reason},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def bulk_upload(
|
|
43
|
+
self,
|
|
44
|
+
file_bytes: bytes,
|
|
45
|
+
filename: str = "suppressions.txt",
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
"""Bulk-import suppressions from a file (one email per line).
|
|
48
|
+
|
|
49
|
+
Sent as ``multipart/form-data`` under the field ``file``.
|
|
50
|
+
"""
|
|
51
|
+
return self._http.upload("/v1/suppressions/bulk", file_bytes, filename)
|
|
52
|
+
|
|
53
|
+
def remove(self, suppression_id: str) -> None:
|
|
54
|
+
"""Remove an address from the suppression list."""
|
|
55
|
+
return self._http.request("DELETE", f"/v1/suppressions/{suppression_id}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The ``templates`` resource: reusable email templates. Starter plan and up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .._http import HttpTransport
|
|
8
|
+
from .._serialize import prune
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Templates:
|
|
12
|
+
"""Accessed as ``axene.templates``."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
18
|
+
"""List all templates, most recently updated first."""
|
|
19
|
+
return self._http.request("GET", "/v1/templates/")
|
|
20
|
+
|
|
21
|
+
def create(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
subject: Optional[str] = None,
|
|
25
|
+
html: Optional[str] = None,
|
|
26
|
+
text: Optional[str] = None,
|
|
27
|
+
blocks_json: Optional[Dict[str, Any]] = None,
|
|
28
|
+
) -> Dict[str, Any]:
|
|
29
|
+
"""Create a template.
|
|
30
|
+
|
|
31
|
+
``html`` maps to ``html_body`` and ``text`` to ``text_body`` on the
|
|
32
|
+
wire. ``variables`` are derived server-side from ``{{name}}``
|
|
33
|
+
placeholders, so you do not pass them.
|
|
34
|
+
"""
|
|
35
|
+
body = prune(
|
|
36
|
+
{
|
|
37
|
+
"name": name,
|
|
38
|
+
"subject": subject,
|
|
39
|
+
"html_body": html,
|
|
40
|
+
"text_body": text,
|
|
41
|
+
"blocks_json": blocks_json,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
return self._http.request("POST", "/v1/templates/", body)
|
|
45
|
+
|
|
46
|
+
def get(self, template_id: str) -> Dict[str, Any]:
|
|
47
|
+
"""Fetch a single template."""
|
|
48
|
+
return self._http.request("GET", f"/v1/templates/{template_id}")
|
|
49
|
+
|
|
50
|
+
def update(
|
|
51
|
+
self,
|
|
52
|
+
template_id: str,
|
|
53
|
+
name: Optional[str] = None,
|
|
54
|
+
subject: Optional[str] = None,
|
|
55
|
+
html: Optional[str] = None,
|
|
56
|
+
text: Optional[str] = None,
|
|
57
|
+
blocks_json: Optional[Dict[str, Any]] = None,
|
|
58
|
+
) -> Dict[str, Any]:
|
|
59
|
+
"""Update a template (partial). ``html``/``text`` map to
|
|
60
|
+
``html_body``/``text_body``.
|
|
61
|
+
"""
|
|
62
|
+
body = prune(
|
|
63
|
+
{
|
|
64
|
+
"name": name,
|
|
65
|
+
"subject": subject,
|
|
66
|
+
"html_body": html,
|
|
67
|
+
"text_body": text,
|
|
68
|
+
"blocks_json": blocks_json,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return self._http.request("PATCH", f"/v1/templates/{template_id}", body)
|
|
72
|
+
|
|
73
|
+
def delete(self, template_id: str) -> None:
|
|
74
|
+
"""Delete a template."""
|
|
75
|
+
return self._http.request("DELETE", f"/v1/templates/{template_id}")
|
|
76
|
+
|
|
77
|
+
def duplicate(self, template_id: str) -> Dict[str, Any]:
|
|
78
|
+
"""Duplicate a template (the copy's ``blocks_json`` is not carried over)."""
|
|
79
|
+
return self._http.request("POST", f"/v1/templates/{template_id}/duplicate")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The ``webhooks`` resource: manage event subscriptions and inspect
|
|
2
|
+
deliveries."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .._http import HttpTransport
|
|
9
|
+
from .._serialize import prune, query
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Webhooks:
|
|
13
|
+
"""Accessed as ``axene.webhooks``."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, http: HttpTransport) -> None:
|
|
16
|
+
self._http = http
|
|
17
|
+
|
|
18
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
19
|
+
"""List your active webhooks."""
|
|
20
|
+
return self._http.request("GET", "/v1/webhooks/")
|
|
21
|
+
|
|
22
|
+
def create(self, url: str, events: List[str]) -> Dict[str, Any]:
|
|
23
|
+
"""Create a webhook. The signing ``secret`` is generated and returned."""
|
|
24
|
+
return self._http.request("POST", "/v1/webhooks/", {"url": url, "events": events})
|
|
25
|
+
|
|
26
|
+
def update(
|
|
27
|
+
self,
|
|
28
|
+
webhook_id: str,
|
|
29
|
+
url: Optional[str] = None,
|
|
30
|
+
events: Optional[List[str]] = None,
|
|
31
|
+
is_active: Optional[bool] = None,
|
|
32
|
+
) -> Dict[str, Any]:
|
|
33
|
+
"""Update a webhook's url, events, or active state (partial).
|
|
34
|
+
|
|
35
|
+
``is_active`` maps to the wire field ``is_active``.
|
|
36
|
+
"""
|
|
37
|
+
body = prune({"url": url, "events": events, "is_active": is_active})
|
|
38
|
+
return self._http.request("PATCH", f"/v1/webhooks/{webhook_id}", body)
|
|
39
|
+
|
|
40
|
+
def delete(self, webhook_id: str) -> None:
|
|
41
|
+
"""Delete a webhook."""
|
|
42
|
+
return self._http.request("DELETE", f"/v1/webhooks/{webhook_id}")
|
|
43
|
+
|
|
44
|
+
def test(self, webhook_id: str) -> Dict[str, Any]:
|
|
45
|
+
"""Queue a sample ``email.delivered`` delivery to test the endpoint."""
|
|
46
|
+
return self._http.request("POST", f"/v1/webhooks/{webhook_id}/test")
|
|
47
|
+
|
|
48
|
+
def list_deliveries(
|
|
49
|
+
self,
|
|
50
|
+
webhook_id: str,
|
|
51
|
+
page: int = 0,
|
|
52
|
+
limit: int = 20,
|
|
53
|
+
status: Optional[str] = None,
|
|
54
|
+
) -> Dict[str, Any]:
|
|
55
|
+
"""List delivery attempts for a webhook (paginated envelope)."""
|
|
56
|
+
params = {"page": page, "limit": limit, "status": status}
|
|
57
|
+
return self._http.request("GET", f"/v1/webhooks/{webhook_id}/deliveries{query(params)}")
|
|
58
|
+
|
|
59
|
+
def get_delivery(self, webhook_id: str, delivery_id: str) -> Dict[str, Any]:
|
|
60
|
+
"""Fetch one delivery with its full payload and the endpoint's response."""
|
|
61
|
+
return self._http.request("GET", f"/v1/webhooks/{webhook_id}/deliveries/{delivery_id}")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Integration-style tests against a local HTTP server (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
|
|
8
|
+
from axene_mailer import Axene, AxeneError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
12
|
+
def _handle(self):
|
|
13
|
+
srv = self.server
|
|
14
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
15
|
+
raw = self.rfile.read(length) if length else b""
|
|
16
|
+
try:
|
|
17
|
+
body = raw.decode()
|
|
18
|
+
except UnicodeDecodeError:
|
|
19
|
+
body = ""
|
|
20
|
+
srv.requests.append({
|
|
21
|
+
"method": self.command,
|
|
22
|
+
"path": self.path,
|
|
23
|
+
"auth": self.headers.get("Authorization"),
|
|
24
|
+
"content_type": self.headers.get("Content-Type"),
|
|
25
|
+
"body": body,
|
|
26
|
+
"raw": raw,
|
|
27
|
+
})
|
|
28
|
+
i = min(srv.call, len(srv.statuses) - 1)
|
|
29
|
+
srv.call += 1
|
|
30
|
+
status = srv.statuses[i]
|
|
31
|
+
payload = (srv.response if 200 <= status < 300
|
|
32
|
+
else '{"detail":{"code":"invalid","message":"bad from"}}').encode()
|
|
33
|
+
self.send_response(status)
|
|
34
|
+
self.send_header("Content-Type", "application/json")
|
|
35
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(payload)
|
|
38
|
+
|
|
39
|
+
do_GET = _handle
|
|
40
|
+
do_POST = _handle
|
|
41
|
+
do_PUT = _handle
|
|
42
|
+
do_PATCH = _handle
|
|
43
|
+
do_DELETE = _handle
|
|
44
|
+
|
|
45
|
+
def log_message(self, *args): # silence the test server
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ClientTest(unittest.TestCase):
|
|
50
|
+
def setUp(self):
|
|
51
|
+
self.server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
52
|
+
self.server.requests = []
|
|
53
|
+
self.server.statuses = [202]
|
|
54
|
+
self.server.response = '{"id":"em_1","status":"queued"}'
|
|
55
|
+
self.server.call = 0
|
|
56
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
57
|
+
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
|
|
58
|
+
|
|
59
|
+
def tearDown(self):
|
|
60
|
+
self.server.shutdown()
|
|
61
|
+
self.server.server_close()
|
|
62
|
+
|
|
63
|
+
def client(self):
|
|
64
|
+
return Axene(api_key="axm_k_test", base_url=self.base, max_retries=3)
|
|
65
|
+
|
|
66
|
+
def test_send_maps_from_and_sets_bearer(self):
|
|
67
|
+
res = self.client().emails.send({
|
|
68
|
+
"from": {"email": "hello@shop.co", "name": "Shop"},
|
|
69
|
+
"to": "a@example.com",
|
|
70
|
+
"subject": "Hi",
|
|
71
|
+
"html": "<p>x</p>",
|
|
72
|
+
})
|
|
73
|
+
self.assertEqual(res["id"], "em_1")
|
|
74
|
+
req = self.server.requests[0]
|
|
75
|
+
self.assertEqual(req["path"], "/v1/emails/")
|
|
76
|
+
self.assertEqual(req["auth"], "Bearer axm_k_test")
|
|
77
|
+
body = json.loads(req["body"])
|
|
78
|
+
self.assertEqual(body["from_"], {"email": "hello@shop.co", "name": "Shop"})
|
|
79
|
+
self.assertNotIn("from", body)
|
|
80
|
+
self.assertEqual(body["to"], [{"email": "a@example.com"}])
|
|
81
|
+
self.assertNotIn("text", body) # nulls pruned
|
|
82
|
+
|
|
83
|
+
def test_non_2xx_raises(self):
|
|
84
|
+
self.server.statuses = [422]
|
|
85
|
+
with self.assertRaises(AxeneError) as cm:
|
|
86
|
+
self.client().emails.send({"from": "f@x.co", "to": "a@x.co", "subject": "s"})
|
|
87
|
+
self.assertEqual(cm.exception.status, 422)
|
|
88
|
+
self.assertEqual(cm.exception.code, "invalid")
|
|
89
|
+
|
|
90
|
+
def test_retries_5xx_then_succeeds(self):
|
|
91
|
+
self.server.statuses = [503, 503, 202]
|
|
92
|
+
res = self.client().emails.send({"from": "f@x.co", "to": "a@x.co", "subject": "s"})
|
|
93
|
+
self.assertEqual(res["id"], "em_1")
|
|
94
|
+
self.assertEqual(len(self.server.requests), 3)
|
|
95
|
+
|
|
96
|
+
def test_send_batch_posts_bare_array(self):
|
|
97
|
+
self.server.response = '{"total":1,"sent":1,"failed":0,"results":[{"id":"a","status":"queued"}]}'
|
|
98
|
+
res = self.client().emails.send_batch([{"from": "f@x.co", "to": "a@x.co", "subject": "s"}])
|
|
99
|
+
self.assertEqual(res["total"], 1)
|
|
100
|
+
body = json.loads(self.server.requests[0]["body"])
|
|
101
|
+
self.assertIsInstance(body, list) # bare array, not {"emails": [...]}
|
|
102
|
+
self.assertEqual(body[0]["from_"], {"email": "f@x.co"})
|
|
103
|
+
|
|
104
|
+
def test_validate_posts_full_message(self):
|
|
105
|
+
self.server.response = '{"valid":true,"can_send":true,"issues":[],"plan":"free","usage":{}}'
|
|
106
|
+
res = self.client().emails.validate({"from": "f@x.co", "to": "a@x.co", "subject": "s"})
|
|
107
|
+
self.assertTrue(res["can_send"])
|
|
108
|
+
self.assertEqual(self.server.requests[0]["path"], "/v1/emails/validate")
|
|
109
|
+
body = json.loads(self.server.requests[0]["body"])
|
|
110
|
+
self.assertEqual(body["from_"], {"email": "f@x.co"}) # full send body
|
|
111
|
+
|
|
112
|
+
def test_list_domains(self):
|
|
113
|
+
self.server.response = '[{"id":"d1","name":"shop.co","status":"verified"}]'
|
|
114
|
+
self.server.statuses = [200]
|
|
115
|
+
domains = self.client().domains.list()
|
|
116
|
+
self.assertEqual(domains[0]["name"], "shop.co")
|
|
117
|
+
|
|
118
|
+
def test_contacts_upload_csv_multipart(self):
|
|
119
|
+
self.server.response = '{"imported":2,"skipped":0,"errors":[]}'
|
|
120
|
+
self.server.statuses = [200]
|
|
121
|
+
res = self.client().contacts.upload_csv("lst_1", b"email\na@x.co\n", "people.csv")
|
|
122
|
+
self.assertEqual(res["imported"], 2)
|
|
123
|
+
req = self.server.requests[0]
|
|
124
|
+
self.assertEqual(req["path"], "/v1/contacts/lst_1/upload")
|
|
125
|
+
self.assertIn("multipart/form-data", req["content_type"])
|
|
126
|
+
self.assertIn("boundary=", req["content_type"])
|
|
127
|
+
# exactly one part named "file"
|
|
128
|
+
self.assertEqual(req["raw"].count(b"Content-Disposition"), 1)
|
|
129
|
+
self.assertIn(b'name="file"', req["raw"])
|
|
130
|
+
self.assertIn(b'filename="people.csv"', req["raw"])
|
|
131
|
+
self.assertIn(b"email\na@x.co\n", req["raw"])
|
|
132
|
+
|
|
133
|
+
def test_suppressions_list_envelope(self):
|
|
134
|
+
self.server.response = (
|
|
135
|
+
'{"items":[{"id":"s1","email_address":"a@x.co","reason":"manual",'
|
|
136
|
+
'"created_at":null}],"total":1,"page":0,"limit":50}'
|
|
137
|
+
)
|
|
138
|
+
self.server.statuses = [200]
|
|
139
|
+
page = self.client().suppressions.list()
|
|
140
|
+
self.assertEqual(page["total"], 1)
|
|
141
|
+
self.assertEqual(page["items"][0]["email_address"], "a@x.co")
|
|
142
|
+
|
|
143
|
+
def test_suppressions_add_maps_email_address(self):
|
|
144
|
+
self.server.response = '{"id":"s1","email_address":"a@x.co","reason":"manual"}'
|
|
145
|
+
self.server.statuses = [201]
|
|
146
|
+
self.client().suppressions.add("a@x.co")
|
|
147
|
+
body = json.loads(self.server.requests[0]["body"])
|
|
148
|
+
self.assertEqual(body["email_address"], "a@x.co")
|
|
149
|
+
self.assertNotIn("email", body)
|
|
150
|
+
self.assertEqual(body["reason"], "manual")
|
|
151
|
+
|
|
152
|
+
def test_templates_create_maps_html_body(self):
|
|
153
|
+
self.server.response = '{"id":"tpl_1","name":"Welcome","html_body":"<p>hi</p>"}'
|
|
154
|
+
self.server.statuses = [201]
|
|
155
|
+
self.client().templates.create(name="Welcome", html="<p>hi</p>", text="hi")
|
|
156
|
+
body = json.loads(self.server.requests[0]["body"])
|
|
157
|
+
self.assertEqual(body["html_body"], "<p>hi</p>")
|
|
158
|
+
self.assertEqual(body["text_body"], "hi")
|
|
159
|
+
self.assertNotIn("html", body)
|
|
160
|
+
self.assertNotIn("text", body)
|
|
161
|
+
|
|
162
|
+
def test_webhooks_update_maps_is_active(self):
|
|
163
|
+
self.server.response = '{"id":"wh_1","url":"https://x.co/h","events":[],"is_active":false}'
|
|
164
|
+
self.server.statuses = [200]
|
|
165
|
+
self.client().webhooks.update("wh_1", is_active=False)
|
|
166
|
+
req = self.server.requests[0]
|
|
167
|
+
self.assertEqual(req["method"], "PATCH")
|
|
168
|
+
body = json.loads(req["body"])
|
|
169
|
+
self.assertEqual(body["is_active"], False)
|
|
170
|
+
self.assertNotIn("isActive", body)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
unittest.main()
|