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.
@@ -0,0 +1,20 @@
1
+ # node
2
+ node_modules/
3
+ dist/
4
+ *.tsbuildinfo
5
+ # dotnet
6
+ bin/
7
+ obj/
8
+ *.nupkg
9
+ *.snupkg
10
+ # python
11
+ __pycache__/
12
+ *.egg-info/
13
+ .venv/
14
+ # misc
15
+ .DS_Store
16
+ .idea/
17
+ .vscode/
18
+ # gradle
19
+ .gradle/
20
+ build/
@@ -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()