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.
- supersendtx-0.8.2/.gitignore +5 -0
- supersendtx-0.8.2/PKG-INFO +48 -0
- supersendtx-0.8.2/README.md +24 -0
- supersendtx-0.8.2/pyproject.toml +39 -0
- supersendtx-0.8.2/supersendtx/__init__.py +7 -0
- supersendtx-0.8.2/supersendtx/client.py +30 -0
- supersendtx-0.8.2/supersendtx/errors.py +41 -0
- supersendtx-0.8.2/supersendtx/http.py +56 -0
- supersendtx-0.8.2/supersendtx/resources.py +199 -0
- supersendtx-0.8.2/tests/test_client.py +64 -0
|
@@ -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,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"
|