sendery 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.
sendery-0.1.0/LICENSE ADDED
@@ -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.
sendery-0.1.0/PKG-INFO ADDED
@@ -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,87 @@
1
+ # Sendery for Python
2
+
3
+ Send published Sendery templates from Python.
4
+
5
+ [Documentation](https://sendery.co/en/docs/python) · [API reference](https://sendery.co/en/docs/send-email) · [Changelog](CHANGELOG.md)
6
+
7
+ ## Requirements
8
+
9
+ Python 3.10+.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install sendery
15
+ ```
16
+
17
+ ## Set up
18
+
19
+ 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.
20
+
21
+ ```bash
22
+ export SENDERY_API_KEY="your_project_api_key"
23
+ ```
24
+
25
+ ## Send an email
26
+
27
+ The response contains the accepted email’s `id` and `status`. Calls are synchronous; use `asyncio.to_thread()` when sending from async code.
28
+
29
+ ```python
30
+ import os
31
+ from sendery import Sendery
32
+
33
+ sendery = Sendery(os.environ["SENDERY_API_KEY"])
34
+ receipt = sendery.send(
35
+ to="alex@example.com",
36
+ template="welcome",
37
+ data={"name": "Alex", "action_url": "https://example.com/start"},
38
+ )
39
+
40
+ print(receipt["id"])
41
+ ```
42
+
43
+ ## Retrieve an email
44
+
45
+ Use the returned ID to [check delivery status](https://sendery.co/en/docs/get-email).
46
+
47
+ ```python
48
+ message = sendery.get(receipt["id"])
49
+ print(message["status"])
50
+ ```
51
+
52
+ ## Retry a send
53
+
54
+ 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.
55
+
56
+ ```python
57
+ email = sendery.prepare(
58
+ to="alex@example.com",
59
+ template="welcome",
60
+ data={"name": "Alex", "action_url": "https://example.com/start"},
61
+ idempotency_key="welcome-123",
62
+ )
63
+ receipt = email.retry(3).send()
64
+ ```
65
+
66
+ ## Handle errors
67
+
68
+ 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).
69
+
70
+ ```python
71
+ from sendery import SenderyError
72
+
73
+ try:
74
+ receipt = email.retry(3).send()
75
+ except SenderyError as error:
76
+ print(error.status, error.code, error.errors)
77
+ # error.retry_after is a delay in seconds, when provided.
78
+ raise
79
+ ```
80
+
81
+ ## More
82
+
83
+ See [idempotency and retries](https://sendery.co/en/docs/idempotency) for retry conditions, delays, and reusing a key across attempts.
84
+
85
+ ## License
86
+
87
+ [MIT](LICENSE).
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sendery"
7
+ version = "0.1.0"
8
+ description = "Sendery template email client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
17
+
18
+ [project.urls]
19
+ Documentation = "https://sendery.co/en/docs/python"
20
+ Repository = "https://github.com/sendery-co/sendery-python"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sendery/__init__.py
5
+ src/sendery.egg-info/PKG-INFO
6
+ src/sendery.egg-info/SOURCES.txt
7
+ src/sendery.egg-info/dependency_links.txt
8
+ src/sendery.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ sendery
@@ -0,0 +1,36 @@
1
+ import json
2
+ import unittest
3
+ from unittest.mock import patch
4
+ from sendery import Sendery, SenderyError
5
+
6
+
7
+ class ClientTests(unittest.TestCase):
8
+ def test_retry_preserves_key_and_original_data(self):
9
+ client = Sendery("test")
10
+ data = {"name": "Original"}
11
+ email = client.prepare(to="alex@example.com", template="welcome", data=data)
12
+ data["name"] = "Changed"
13
+ with patch.object(client, "_request", side_effect=[SenderyError(503), {"id": "one", "status": "queued"}]) as request, patch("sendery.time.sleep"):
14
+ email.retry().send()
15
+ self.assertEqual(request.call_args_list[0], request.call_args_list[1])
16
+ self.assertEqual("Original", json.loads(request.call_args.args[2])["data"]["name"])
17
+ self.assertEqual(email.idempotency_key, request.call_args.args[3])
18
+
19
+ def test_capacity_is_not_retried(self):
20
+ client = Sendery("test")
21
+ with patch.object(client, "_request", side_effect=SenderyError(429, "email_capacity_exceeded")) as request:
22
+ with self.assertRaises(SenderyError):
23
+ client.prepare(to="a@example.com", template="welcome", data={}).retry().send()
24
+ self.assertEqual(1, request.call_count)
25
+
26
+ def test_explicit_key_and_get(self):
27
+ client = Sendery("test")
28
+ self.assertEqual("event-12", client.prepare(to="a@example.com", template="welcome", data={}, idempotency_key="event-12").idempotency_key)
29
+ with patch.object(client, "_request") as request:
30
+ client.get("message/12")
31
+ request.assert_called_once_with("GET", "/api/v1/emails/message%2F12")
32
+
33
+ def test_remote_http_and_credential_urls_rejected(self):
34
+ for url in ["http://example.com", "https://user:pass@example.com"]:
35
+ with self.assertRaises(ValueError):
36
+ Sendery("test", url)