sendery 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sendery/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Server-side Sendery template API client."""
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from urllib.parse import urlparse, quote
|
|
11
|
+
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SenderyError(Exception):
|
|
16
|
+
def __init__(self, status, code="request_error", errors=None, retry_after=None):
|
|
17
|
+
super().__init__(f"Sendery API error: {code}")
|
|
18
|
+
self.status, self.code, self.errors, self.retry_after = status, code, errors or {}, retry_after
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def retryable(self):
|
|
22
|
+
return self.status in (0, 500, 502, 503, 504) or (self.status == 429 and self.code == "rate_limited")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
26
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PendingEmail:
|
|
31
|
+
def __init__(self, client, payload, key):
|
|
32
|
+
if not re.fullmatch(r"[a-zA-Z0-9_.:-]{1,128}", key):
|
|
33
|
+
raise ValueError("Invalid idempotency key.")
|
|
34
|
+
self.client, self._key, self._body, self._retries = client, key, json.dumps(payload, allow_nan=False).encode(), 0
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def idempotency_key(self):
|
|
38
|
+
return self._key
|
|
39
|
+
|
|
40
|
+
def retry(self, retries=3):
|
|
41
|
+
if type(retries) is not int or not 0 <= retries <= 5:
|
|
42
|
+
raise ValueError("Choose 0 to 5 retries.")
|
|
43
|
+
self._retries = retries
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def send(self):
|
|
47
|
+
for attempt in range(self._retries + 1):
|
|
48
|
+
try:
|
|
49
|
+
return self.client._request("POST", "/api/v1/emails", self._body, self._key)
|
|
50
|
+
except SenderyError as error:
|
|
51
|
+
if attempt == self._retries or not error.retryable:
|
|
52
|
+
raise
|
|
53
|
+
delay = error.retry_after if error.retry_after is not None else min(8, .25 * 2 ** attempt + random.random() * .1)
|
|
54
|
+
if delay > 30:
|
|
55
|
+
raise
|
|
56
|
+
time.sleep(delay)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Sendery:
|
|
60
|
+
def __init__(self, api_key, base_url="https://sendery.co"):
|
|
61
|
+
url = urlparse(base_url)
|
|
62
|
+
if not api_key or not url.hostname or url.username or url.password or url.query or url.fragment or (url.scheme != "https" and not (url.scheme == "http" and url.hostname in ("localhost", "127.0.0.1", "::1"))):
|
|
63
|
+
raise ValueError("Provide an API key and an HTTPS URL (HTTP allowed only on loopback).")
|
|
64
|
+
self._api_key, self._base_url, self._opener = api_key, base_url.rstrip("/"), build_opener(_NoRedirect)
|
|
65
|
+
|
|
66
|
+
def prepare(self, *, to, template, data, locale=None, idempotency_key=None):
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
raise ValueError("data must be a dictionary.")
|
|
69
|
+
payload = {"to": to, "template": template, "data": data}
|
|
70
|
+
if locale is not None:
|
|
71
|
+
payload["locale"] = locale
|
|
72
|
+
return PendingEmail(self, payload, idempotency_key if idempotency_key is not None else str(uuid.uuid4()))
|
|
73
|
+
|
|
74
|
+
def send(self, **kwargs):
|
|
75
|
+
return self.prepare(**kwargs).send()
|
|
76
|
+
|
|
77
|
+
def get(self, message_id):
|
|
78
|
+
return self._request("GET", "/api/v1/emails/" + quote(message_id, safe=""))
|
|
79
|
+
|
|
80
|
+
def _request(self, method, path, body=None, key=None):
|
|
81
|
+
headers = {"Authorization": "Bearer " + self._api_key, "Content-Type": "application/json", "Accept": "application/json"}
|
|
82
|
+
if key is not None:
|
|
83
|
+
headers["Idempotency-Key"] = key
|
|
84
|
+
try:
|
|
85
|
+
with self._opener.open(Request(self._base_url + path, body, headers, method=method), timeout=10) as response:
|
|
86
|
+
try:
|
|
87
|
+
result = json.load(response)
|
|
88
|
+
except (ValueError, UnicodeError):
|
|
89
|
+
raise SenderyError(0, "invalid_response") from None
|
|
90
|
+
if not isinstance(result, dict) or not isinstance(result.get("id"), str) or not isinstance(result.get("status"), str):
|
|
91
|
+
raise SenderyError(0, "invalid_response")
|
|
92
|
+
return result
|
|
93
|
+
except HTTPError as error:
|
|
94
|
+
try:
|
|
95
|
+
details = json.loads(error.read())
|
|
96
|
+
if not isinstance(details, dict):
|
|
97
|
+
details = {}
|
|
98
|
+
except (ValueError, UnicodeError):
|
|
99
|
+
details = {}
|
|
100
|
+
retry_after = error.headers.get("Retry-After")
|
|
101
|
+
try:
|
|
102
|
+
delay = max(0, float(retry_after)) if retry_after is not None else None
|
|
103
|
+
except ValueError:
|
|
104
|
+
try:
|
|
105
|
+
delay = max(0, (parsedate_to_datetime(retry_after) - datetime.now(timezone.utc)).total_seconds())
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
delay = None
|
|
108
|
+
if delay is not None and not math.isfinite(delay):
|
|
109
|
+
delay = None
|
|
110
|
+
raise SenderyError(error.code, details.get("code", "request_error"), details.get("errors"), delay) from None
|
|
111
|
+
except (URLError, TimeoutError, OSError):
|
|
112
|
+
raise SenderyError(0, "connection_error") from None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sendery
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sendery template email client
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://sendery.co/en/docs/python
|
|
7
|
+
Project-URL: Repository, https://github.com/sendery-co/sendery-python
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Sendery for Python
|
|
14
|
+
|
|
15
|
+
Send published Sendery templates from Python.
|
|
16
|
+
|
|
17
|
+
[Documentation](https://sendery.co/en/docs/python) · [API reference](https://sendery.co/en/docs/send-email) · [Changelog](CHANGELOG.md)
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
Python 3.10+.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install sendery
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Set up
|
|
30
|
+
|
|
31
|
+
Publish a `welcome` template with `name` and `action_url` variables, and create a [project API key](https://sendery.co/en/docs/authentication). Store it as `SENDERY_API_KEY` on your server.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export SENDERY_API_KEY="your_project_api_key"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Send an email
|
|
38
|
+
|
|
39
|
+
The response contains the accepted email’s `id` and `status`. Calls are synchronous; use `asyncio.to_thread()` when sending from async code.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from sendery import Sendery
|
|
44
|
+
|
|
45
|
+
sendery = Sendery(os.environ["SENDERY_API_KEY"])
|
|
46
|
+
receipt = sendery.send(
|
|
47
|
+
to="alex@example.com",
|
|
48
|
+
template="welcome",
|
|
49
|
+
data={"name": "Alex", "action_url": "https://example.com/start"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
print(receipt["id"])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Retrieve an email
|
|
56
|
+
|
|
57
|
+
Use the returned ID to [check delivery status](https://sendery.co/en/docs/get-email).
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
message = sendery.get(receipt["id"])
|
|
61
|
+
print(message["status"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Retry a send
|
|
65
|
+
|
|
66
|
+
Use a key such as `welcome-123` for one email, and [keep the payload unchanged on retries](https://sendery.co/en/docs/idempotency). `retry(3)` allows up to three additional attempts for temporary failures; `send()` alone makes one attempt.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
email = sendery.prepare(
|
|
70
|
+
to="alex@example.com",
|
|
71
|
+
template="welcome",
|
|
72
|
+
data={"name": "Alex", "action_url": "https://example.com/start"},
|
|
73
|
+
idempotency_key="welcome-123",
|
|
74
|
+
)
|
|
75
|
+
receipt = email.retry(3).send()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Handle errors
|
|
79
|
+
|
|
80
|
+
Catch the SDK exception to inspect the [status and code](https://sendery.co/en/docs/errors). Retry delays are in seconds. The example uses the prepared `email` from the [retry example above](#retry-a-send).
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from sendery import SenderyError
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
receipt = email.retry(3).send()
|
|
87
|
+
except SenderyError as error:
|
|
88
|
+
print(error.status, error.code, error.errors)
|
|
89
|
+
# error.retry_after is a delay in seconds, when provided.
|
|
90
|
+
raise
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## More
|
|
94
|
+
|
|
95
|
+
See [idempotency and retries](https://sendery.co/en/docs/idempotency) for retry conditions, delays, and reusing a key across attempts.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
sendery/__init__.py,sha256=cojnWnAlCiudgOEfZFZt5ntVkIbFRH6nHPbw7zMhHO4,5022
|
|
2
|
+
sendery-0.1.0.dist-info/licenses/LICENSE,sha256=d5HiJStdXYwRXhCo_KDE-pVg2xK18jHouZ_t36Cbqwk,1077
|
|
3
|
+
sendery-0.1.0.dist-info/METADATA,sha256=pEfW4Eg7J-QBnjyt11Gack1yifkGQLDhwHDShtU4DdA,2650
|
|
4
|
+
sendery-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
sendery-0.1.0.dist-info/top_level.txt,sha256=DO99vDP8BeBS1zmyU_Ow8r2JSCFvEimaflG9KJcv9wc,8
|
|
6
|
+
sendery-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sendery contributors
|
|
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 @@
|
|
|
1
|
+
sendery
|