supersendtx 0.8.2__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,5 @@
1
+ /dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ /build/
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: supersendtx
3
+ Version: 0.8.2
4
+ Summary: SuperSend TX transactional email API client for Python
5
+ Project-URL: Homepage, https://supersendtx.com
6
+ Project-URL: Documentation, https://docs.supersendtx.com/sdks/python
7
+ Project-URL: Repository, https://github.com/Super-Send/supersendtx-sdks
8
+ Project-URL: Issues, https://github.com/Super-Send/supersendtx-sdks/issues
9
+ Author: SuperSend TX
10
+ License-Expression: MIT
11
+ Keywords: email,supersend,supersendtx,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: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.10
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # SuperSend TX Python SDK
26
+
27
+ Official Python client for the [SuperSend TX](https://supersendtx.com) transactional email API.
28
+
29
+ ```bash
30
+ pip install supersendtx
31
+ ```
32
+
33
+ ```python
34
+ from supersendtx import SuperSendTX
35
+
36
+ tx = SuperSendTX("stx_your_key_here")
37
+
38
+ result = tx.emails.send(
39
+ from_="you@yourdomain.com",
40
+ to="user@example.com",
41
+ subject="Hello",
42
+ html="<p>It works.</p>",
43
+ )
44
+
45
+ print(result["id"], result["status"])
46
+ ```
47
+
48
+ Docs: https://docs.supersendtx.com/sdks/python
@@ -0,0 +1,24 @@
1
+ # SuperSend TX Python SDK
2
+
3
+ Official Python client for the [SuperSend TX](https://supersendtx.com) transactional email API.
4
+
5
+ ```bash
6
+ pip install supersendtx
7
+ ```
8
+
9
+ ```python
10
+ from supersendtx import SuperSendTX
11
+
12
+ tx = SuperSendTX("stx_your_key_here")
13
+
14
+ result = tx.emails.send(
15
+ from_="you@yourdomain.com",
16
+ to="user@example.com",
17
+ subject="Hello",
18
+ html="<p>It works.</p>",
19
+ )
20
+
21
+ print(result["id"], result["status"])
22
+ ```
23
+
24
+ Docs: https://docs.supersendtx.com/sdks/python
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "supersendtx"
7
+ version = "0.8.2"
8
+ description = "SuperSend TX transactional email API client for Python"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "SuperSend TX" }]
13
+ keywords = ["email", "transactional", "supersend", "supersendtx"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://supersendtx.com"
28
+ Documentation = "https://docs.supersendtx.com/sdks/python"
29
+ Repository = "https://github.com/Super-Send/supersendtx-sdks"
30
+ Issues = "https://github.com/Super-Send/supersendtx-sdks/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["supersendtx"]
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=8.0"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ """SuperSend TX Python SDK — transactional email API client."""
2
+
3
+ from supersendtx.client import SuperSendTX
4
+ from supersendtx.errors import SuperSendTXError
5
+
6
+ __all__ = ["SuperSendTX", "SuperSendTXError"]
7
+ DEFAULT_API_BASE_URL = "https://api.supersendtx.com"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from supersendtx.errors import SuperSendTXError
6
+ from supersendtx.http import HttpClient
7
+ from supersendtx.resources import (
8
+ DomainsResource,
9
+ EmailsResource,
10
+ SuppressionsResource,
11
+ TemplatesResource,
12
+ WebhooksResource,
13
+ )
14
+
15
+ DEFAULT_API_BASE_URL = "https://api.supersendtx.com"
16
+
17
+
18
+ class SuperSendTX:
19
+ """Thin HTTP client for the SuperSend TX REST API."""
20
+
21
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_API_BASE_URL) -> None:
22
+ self._http = HttpClient(api_key, base_url=base_url)
23
+ self.emails = EmailsResource(self._http)
24
+ self.domains = DomainsResource(self._http)
25
+ self.webhooks = WebhooksResource(self._http)
26
+ self.templates = TemplatesResource(self._http)
27
+ self.suppressions = SuppressionsResource(self._http)
28
+
29
+ def request(self, method: str, path: str, *, body: Any = None, headers: dict[str, str] | None = None) -> Any:
30
+ return self._http.request(method, path, body=body, headers=headers)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SuperSendTXError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ status: int,
11
+ *,
12
+ details: Any = None,
13
+ code: str | None = None,
14
+ upgrade_url: str | None = None,
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.message = message
18
+ self.status = status
19
+ self.details = details
20
+ self.code = code
21
+ self.upgrade_url = upgrade_url
22
+
23
+ @classmethod
24
+ def from_response(cls, status: int, body: Any) -> SuperSendTXError:
25
+ err = body.get("error") if isinstance(body, dict) else None
26
+ if isinstance(err, str):
27
+ message = err
28
+ details = None
29
+ code = None
30
+ upgrade_url = None
31
+ elif isinstance(err, dict):
32
+ message = str(err.get("message") or f"Request failed with status {status}")
33
+ details = err.get("details")
34
+ code = str(err["code"]) if err.get("code") is not None else None
35
+ upgrade_url = str(err["upgrade_url"]) if err.get("upgrade_url") is not None else None
36
+ else:
37
+ message = f"Request failed with status {status}"
38
+ details = None
39
+ code = None
40
+ upgrade_url = None
41
+ return cls(message, status, details=details, code=code, upgrade_url=upgrade_url)
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+ from supersendtx.errors import SuperSendTXError
10
+
11
+
12
+ class HttpClient:
13
+ def __init__(self, api_key: str, *, base_url: str = "https://api.supersendtx.com") -> None:
14
+ if not api_key.startswith("stx_"):
15
+ raise ValueError("SuperSend TX API key must start with stx_")
16
+ self.api_key = api_key
17
+ self.base_url = base_url.rstrip("/")
18
+
19
+ def request(
20
+ self,
21
+ method: str,
22
+ path: str,
23
+ *,
24
+ body: Any = None,
25
+ headers: dict[str, str] | None = None,
26
+ ) -> Any:
27
+ url = f"{self.base_url}{path}"
28
+ payload = None
29
+ req_headers = {
30
+ "Authorization": f"Bearer {self.api_key}",
31
+ "Content-Type": "application/json",
32
+ **(headers or {}),
33
+ }
34
+ if body is not None:
35
+ payload = json.dumps(body).encode("utf-8")
36
+ request = urllib.request.Request(url, data=payload, headers=req_headers, method=method)
37
+ try:
38
+ with urllib.request.urlopen(request) as response:
39
+ raw = response.read().decode("utf-8")
40
+ if not raw:
41
+ return {}
42
+ return json.loads(raw)
43
+ except urllib.error.HTTPError as error:
44
+ raw = error.read().decode("utf-8")
45
+ try:
46
+ parsed = json.loads(raw)
47
+ except json.JSONDecodeError:
48
+ parsed = {}
49
+ raise SuperSendTXError.from_response(error.code, parsed) from error
50
+
51
+ @staticmethod
52
+ def query(params: dict[str, Any]) -> str:
53
+ filtered = {key: value for key, value in params.items() if value is not None}
54
+ if not filtered:
55
+ return ""
56
+ return "?" + urllib.parse.urlencode(filtered)
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from supersendtx.http import HttpClient
6
+
7
+
8
+ class EmailsResource:
9
+ def __init__(self, http: HttpClient) -> None:
10
+ self._http = http
11
+
12
+ def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
13
+ return self._http.request("GET", f"/emails{self._http.query({'limit': limit, 'cursor': cursor})}")
14
+
15
+ def get(self, email_id: str) -> dict[str, Any]:
16
+ return self._http.request("GET", f"/emails/{email_id}")
17
+
18
+ def send(
19
+ self,
20
+ *,
21
+ from_: str | None = None,
22
+ to: str | None = None,
23
+ **params: Any,
24
+ ) -> dict[str, Any]:
25
+ if from_ is not None:
26
+ params["from"] = from_
27
+ if to is not None:
28
+ params["to"] = to
29
+ body = _serialize_send_params(params)
30
+ headers: dict[str, str] = {}
31
+ idempotency_key = params.get("idempotency_key") or params.get("idempotencyKey")
32
+ if idempotency_key:
33
+ headers["Idempotency-Key"] = str(idempotency_key)
34
+ return self._http.request("POST", "/emails", body=body, headers=headers)
35
+
36
+ def batch(self, emails: list[dict[str, Any]]) -> dict[str, Any]:
37
+ serialized = [_serialize_send_params(email) for email in emails]
38
+ return self._http.request("POST", "/emails/batch", body={"emails": serialized})
39
+
40
+ def cancel(self, email_id: str) -> dict[str, Any]:
41
+ return self._http.request("PATCH", f"/emails/{email_id}", body={"cancel": True})
42
+
43
+ def resend(self, email_id: str) -> dict[str, Any]:
44
+ return self._http.request("POST", f"/emails/{email_id}/resend")
45
+
46
+ def test_webhook(self, **params: Any) -> dict[str, Any]:
47
+ return self._http.request("POST", "/emails/test", body=params)
48
+
49
+ def insights(self, *, window: str = "30d") -> dict[str, Any]:
50
+ return self._http.request("GET", f"/deliverability{self._http.query({'window': window})}")
51
+
52
+
53
+ class DomainsResource:
54
+ def __init__(self, http: HttpClient) -> None:
55
+ self._http = http
56
+
57
+ def list(
58
+ self,
59
+ *,
60
+ limit: int | None = None,
61
+ cursor: str | None = None,
62
+ inbound_enabled: bool | None = None,
63
+ ) -> dict[str, Any]:
64
+ return self._http.request(
65
+ "GET",
66
+ f"/domains{self._http.query({'limit': limit, 'cursor': cursor, 'inbound_enabled': inbound_enabled})}",
67
+ )
68
+
69
+ def get(self, id_or_name: str) -> dict[str, Any]:
70
+ return self._http.request("GET", f"/domains/{id_or_name}")
71
+
72
+ def create(self, name: str, *, inbound_enabled: bool | None = None) -> dict[str, Any]:
73
+ body: dict[str, Any] = {"name": name}
74
+ if inbound_enabled is not None:
75
+ body["inbound_enabled"] = inbound_enabled
76
+ return self._http.request("POST", "/domains", body=body)
77
+
78
+ def verify(self, id_or_name: str) -> dict[str, Any]:
79
+ return self._http.request("POST", f"/domains/{id_or_name}", body={"action": "verify"})
80
+
81
+ def apply(
82
+ self,
83
+ id_or_name: str,
84
+ *,
85
+ provider: str = "cloudflare",
86
+ credentials: dict[str, Any] | None = None,
87
+ ) -> dict[str, Any]:
88
+ body: dict[str, Any] = {"action": "apply", "provider": provider}
89
+ if credentials:
90
+ body["credentials"] = credentials
91
+ return self._http.request("POST", f"/domains/{id_or_name}", body=body)
92
+
93
+ def update(self, id_or_name: str, **params: Any) -> dict[str, Any]:
94
+ return self._http.request("PATCH", f"/domains/{id_or_name}", body=params)
95
+
96
+ def delete(self, id_or_name: str) -> dict[str, Any]:
97
+ return self._http.request("DELETE", f"/domains/{id_or_name}")
98
+
99
+
100
+ class WebhooksResource:
101
+ def __init__(self, http: HttpClient) -> None:
102
+ self._http = http
103
+
104
+ def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
105
+ return self._http.request(
106
+ "GET",
107
+ f"/webhooks{self._http.query({'limit': limit, 'cursor': cursor})}",
108
+ )
109
+
110
+ def get(self, webhook_id: str) -> dict[str, Any]:
111
+ return self._http.request("GET", f"/webhooks/{webhook_id}")
112
+
113
+ def create(self, **params: Any) -> dict[str, Any]:
114
+ return self._http.request("POST", "/webhooks", body=params)
115
+
116
+ def update(self, webhook_id: str, **params: Any) -> dict[str, Any]:
117
+ return self._http.request("PATCH", f"/webhooks/{webhook_id}", body=params)
118
+
119
+ def delete(self, webhook_id: str) -> dict[str, Any]:
120
+ return self._http.request("DELETE", f"/webhooks/{webhook_id}")
121
+
122
+
123
+ class TemplatesResource:
124
+ def __init__(self, http: HttpClient) -> None:
125
+ self._http = http
126
+
127
+ def list(
128
+ self,
129
+ *,
130
+ limit: int | None = None,
131
+ cursor: str | None = None,
132
+ status: str | None = None,
133
+ ) -> dict[str, Any]:
134
+ return self._http.request(
135
+ "GET",
136
+ f"/templates{self._http.query({'limit': limit, 'cursor': cursor, 'status': status})}",
137
+ )
138
+
139
+ def get(self, id_or_alias: str) -> dict[str, Any]:
140
+ return self._http.request("GET", f"/templates/{id_or_alias}")
141
+
142
+ def create(self, **params: Any) -> dict[str, Any]:
143
+ return self._http.request("POST", "/templates", body=params)
144
+
145
+ def update(self, id_or_alias: str, **params: Any) -> dict[str, Any]:
146
+ return self._http.request("PATCH", f"/templates/{id_or_alias}", body=params)
147
+
148
+ def delete(self, id_or_alias: str) -> dict[str, Any]:
149
+ return self._http.request("DELETE", f"/templates/{id_or_alias}")
150
+
151
+ def publish(self, id_or_alias: str) -> dict[str, Any]:
152
+ return self._http.request("POST", f"/templates/{id_or_alias}", body={"action": "publish"})
153
+
154
+
155
+ class SuppressionsResource:
156
+ def __init__(self, http: HttpClient) -> None:
157
+ self._http = http
158
+
159
+ def list(
160
+ self,
161
+ *,
162
+ limit: int | None = None,
163
+ cursor: str | None = None,
164
+ email: str | None = None,
165
+ ) -> dict[str, Any]:
166
+ return self._http.request(
167
+ "GET",
168
+ f"/suppressions{self._http.query({'limit': limit, 'cursor': cursor, 'email': email})}",
169
+ )
170
+
171
+ def create(self, **params: Any) -> dict[str, Any]:
172
+ return self._http.request("POST", "/suppressions", body=params)
173
+
174
+ def remove(self, id_or_email: str) -> dict[str, Any]:
175
+ if "@" in id_or_email:
176
+ return self._http.request("DELETE", f"/suppressions{self._http.query({'email': id_or_email})}")
177
+ return self._http.request("DELETE", f"/suppressions/{id_or_email}")
178
+
179
+
180
+ def _serialize_send_params(params: dict[str, Any]) -> dict[str, Any]:
181
+ body: dict[str, Any] = {
182
+ "from": params["from"],
183
+ "to": params["to"],
184
+ }
185
+ for key in ("subject", "html", "text", "reply_to", "replyTo", "cc", "bcc", "tags", "headers", "tag", "template"):
186
+ if key in params and params[key] is not None:
187
+ mapped = "reply_to" if key == "replyTo" else key
188
+ body[mapped] = params[key]
189
+ if params.get("htmlBody") is not None:
190
+ body["html"] = params["htmlBody"]
191
+ if params.get("textBody") is not None:
192
+ body["text"] = params["textBody"]
193
+ if params.get("scheduled_at") is not None:
194
+ body["scheduled_at"] = params["scheduled_at"]
195
+ if params.get("scheduledAt") is not None:
196
+ body["scheduled_at"] = params["scheduledAt"]
197
+ if params.get("unsubscribe") is not None:
198
+ body["unsubscribe"] = params["unsubscribe"]
199
+ return body
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from supersendtx import SuperSendTX
9
+ from supersendtx.errors import SuperSendTXError
10
+
11
+
12
+ @pytest.fixture
13
+ def client() -> SuperSendTX:
14
+ return SuperSendTX("stx_test_key", base_url="https://api.example.com")
15
+
16
+
17
+ def test_requires_stx_prefix() -> None:
18
+ with pytest.raises(ValueError, match="stx_"):
19
+ SuperSendTX("bad")
20
+
21
+
22
+ def test_emails_send(client: SuperSendTX) -> None:
23
+ response = MagicMock()
24
+ response.read.return_value = json.dumps({"id": "msg_1", "status": "sent"}).encode()
25
+ response.__enter__.return_value = response
26
+
27
+ with patch("urllib.request.urlopen", return_value=response) as urlopen:
28
+ result = client.emails.send(
29
+ from_="a@example.com",
30
+ to="b@example.com",
31
+ subject="Hi",
32
+ html="<p>Hi</p>",
33
+ )
34
+
35
+ assert result == {"id": "msg_1", "status": "sent"}
36
+ request = urlopen.call_args.args[0]
37
+ assert request.full_url == "https://api.example.com/emails"
38
+ assert request.method == "POST"
39
+ assert request.get_header("Authorization") == "Bearer stx_test_key"
40
+
41
+
42
+ def test_http_error_raises_super_send_tx_error(client: SuperSendTX) -> None:
43
+ import urllib.error
44
+
45
+ payload = json.dumps({"error": {"message": "Invalid API key"}}).encode()
46
+ http_error = urllib.error.HTTPError(
47
+ url="https://api.example.com/emails",
48
+ code=401,
49
+ msg="Unauthorized",
50
+ hdrs=None,
51
+ fp=MagicMock(read=MagicMock(return_value=payload)),
52
+ )
53
+
54
+ with patch("urllib.request.urlopen", side_effect=http_error):
55
+ with pytest.raises(SuperSendTXError) as exc:
56
+ client.emails.send(
57
+ from_="a@example.com",
58
+ to="b@example.com",
59
+ subject="Hi",
60
+ html="<p>Hi</p>",
61
+ )
62
+
63
+ assert exc.value.status == 401
64
+ assert exc.value.message == "Invalid API key"