smtpfast 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.
smtpfast/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """The official Python SDK for the SMTPfast API."""
2
+ from . import types, webhooks
3
+ from ._client import AsyncSMTPfast, SMTPfast
4
+ from ._core import RequestOptions
5
+ from ._errors import (
6
+ APIConnectionError,
7
+ APIError,
8
+ APITimeoutError,
9
+ AuthenticationError,
10
+ BadRequestError,
11
+ ConflictError,
12
+ InternalServerError,
13
+ NotFoundError,
14
+ PermissionDeniedError,
15
+ RateLimitError,
16
+ SMTPfastError,
17
+ UnprocessableEntityError,
18
+ )
19
+ from ._version import __version__
20
+
21
+ __all__ = [
22
+ "APIConnectionError",
23
+ "APIError",
24
+ "APITimeoutError",
25
+ "AsyncSMTPfast",
26
+ "AuthenticationError",
27
+ "BadRequestError",
28
+ "ConflictError",
29
+ "InternalServerError",
30
+ "NotFoundError",
31
+ "PermissionDeniedError",
32
+ "RateLimitError",
33
+ "RequestOptions",
34
+ "SMTPfast",
35
+ "SMTPfastError",
36
+ "UnprocessableEntityError",
37
+ "__version__",
38
+ "types",
39
+ "webhooks",
40
+ ]
smtpfast/_client.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ import httpx
6
+ from typing_extensions import Self
7
+
8
+ from ._core import AsyncCore, SyncCore
9
+ from ._resources import AsyncResource, Resource
10
+
11
+
12
+ class SMTPfast(Resource):
13
+ """The SMTPfast API client.
14
+
15
+ >>> client = SMTPfast() # reads SMTPFAST_API_KEY
16
+ >>> client.emails.send({"from": "you@yourdomain.com", "to": "them@example.org", "subject": "Hi", "text": "Hello"})
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: str | None = None,
22
+ *,
23
+ base_url: str | None = None,
24
+ max_retries: int = 2,
25
+ timeout: float = 60.0,
26
+ headers: Mapping[str, str] | None = None,
27
+ http_client: httpx.Client | None = None,
28
+ ) -> None:
29
+ super().__init__(SyncCore(api_key, base_url, max_retries, timeout, headers, http_client=http_client))
30
+
31
+ def close(self) -> None:
32
+ self._core.close()
33
+
34
+ def __enter__(self) -> Self:
35
+ return self
36
+
37
+ def __exit__(self, *exc: object) -> None:
38
+ self.close()
39
+
40
+
41
+ class AsyncSMTPfast(AsyncResource):
42
+ """The SMTPfast API client for asyncio. Every method is a coroutine."""
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str | None = None,
47
+ *,
48
+ base_url: str | None = None,
49
+ max_retries: int = 2,
50
+ timeout: float = 60.0,
51
+ headers: Mapping[str, str] | None = None,
52
+ http_client: httpx.AsyncClient | None = None,
53
+ ) -> None:
54
+ super().__init__(AsyncCore(api_key, base_url, max_retries, timeout, headers, http_client=http_client))
55
+
56
+ async def aclose(self) -> None:
57
+ await self._core.aclose()
58
+
59
+ async def __aenter__(self) -> Self:
60
+ return self
61
+
62
+ async def __aexit__(self, *exc: object) -> None:
63
+ await self.aclose()
smtpfast/_core.py ADDED
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import email.utils
5
+ import math
6
+ import os
7
+ import random
8
+ import time
9
+ import uuid
10
+ from collections.abc import AsyncIterator, Iterator, Mapping
11
+ from typing import Any
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+ from typing_extensions import TypedDict
16
+
17
+ from ._errors import APIConnectionError, APIError, APITimeoutError, SMTPfastError
18
+ from ._version import __version__
19
+
20
+ DEFAULT_BASE_URL = "https://smtpfa.st/api"
21
+ RETRY_STATUS = {408, 429, 500, 502, 503, 504}
22
+ SAFE_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}
23
+ MAX_RETRY_AFTER = 60.0
24
+ # Query parameters list_all sets or cannot combine with walking forward by `after`.
25
+ PAGINATION_MANAGED = {"before", "page", "latest"}
26
+
27
+
28
+ class RequestOptions(TypedDict, total=False):
29
+ """Per-call settings: `timeout` in seconds, `max_retries`, and extra `headers`."""
30
+
31
+ timeout: float
32
+ max_retries: int
33
+ headers: Mapping[str, str]
34
+
35
+
36
+ class _Core:
37
+ def __init__(
38
+ self,
39
+ api_key: str | None,
40
+ base_url: str | None,
41
+ max_retries: int,
42
+ timeout: float,
43
+ headers: Mapping[str, str] | None,
44
+ ) -> None:
45
+ key = (api_key or os.environ.get("SMTPFAST_API_KEY") or "").strip()
46
+ if not key:
47
+ raise SMTPfastError("No API key: pass api_key= or set the SMTPFAST_API_KEY environment variable.")
48
+ self.base_url = (base_url or os.environ.get("SMTPFAST_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
49
+ self.max_retries = max_retries
50
+ self.timeout = timeout
51
+ self.headers = {
52
+ "Authorization": f"Bearer {key}",
53
+ "User-Agent": f"smtpfast-python/{__version__}",
54
+ "Accept": "application/json",
55
+ **(headers or {}),
56
+ }
57
+
58
+ def _build(self, http_method, route, path_params, query, body, idempotent, idempotency_key, options):
59
+ options = options or {}
60
+ max_retries = options.get("max_retries", self.max_retries)
61
+ timeout = options.get("timeout", self.timeout)
62
+ headers = httpx.Headers(self.headers)
63
+ headers.update(options.get("headers") or {})
64
+ if idempotency_key:
65
+ headers["Idempotency-Key"] = idempotency_key
66
+ elif idempotent and "Idempotency-Key" not in headers and max_retries > 0:
67
+ headers["Idempotency-Key"] = str(uuid.uuid4())
68
+ # A POST or PATCH is retried only when its operation dedupes on the key.
69
+ retryable = http_method in SAFE_METHODS or (idempotent and bool(headers.get("Idempotency-Key")))
70
+ params = {k: _query_value(v) for k, v in (query or {}).items() if v is not None}
71
+ request = httpx.Request(
72
+ http_method,
73
+ self.base_url + _fill(route, path_params),
74
+ params=params,
75
+ json=body,
76
+ headers=headers,
77
+ extensions={"timeout": httpx.Timeout(timeout).as_dict()},
78
+ )
79
+ return request, max_retries, timeout, retryable
80
+
81
+
82
+ class SyncCore(_Core):
83
+ def __init__(self, *args: Any, http_client: httpx.Client | None = None) -> None:
84
+ super().__init__(*args)
85
+ self.http = http_client or httpx.Client()
86
+ self._owns_http = http_client is None
87
+
88
+ def request(self, http_method, route, *, path_params=None, query=None, body=None, idempotent=False, idempotency_key=None, options=None):
89
+ request, max_retries, timeout, retryable = self._build(http_method, route, path_params, query, body, idempotent, idempotency_key, options)
90
+ attempt = 0
91
+ while True:
92
+ try:
93
+ response = self.http.send(request)
94
+ except httpx.TimeoutException as err:
95
+ if not retryable or attempt >= max_retries:
96
+ raise APITimeoutError(timeout) from err
97
+ time.sleep(_backoff(attempt))
98
+ attempt += 1
99
+ continue
100
+ except httpx.TransportError as err:
101
+ if not retryable or attempt >= max_retries:
102
+ raise APIConnectionError() from err
103
+ time.sleep(_backoff(attempt))
104
+ attempt += 1
105
+ continue
106
+ wait = _retry_delay(response, attempt, retryable)
107
+ if wait is None or attempt >= max_retries:
108
+ response.read()
109
+ return _result(response)
110
+ response.close()
111
+ time.sleep(wait)
112
+ attempt += 1
113
+
114
+ def paginate(self, http_method, route, *, path_params=None, query=None, body=None, options=None) -> Iterator[Any]:
115
+ query = {k: v for k, v in (query or {}).items() if k not in PAGINATION_MANAGED}
116
+ while True:
117
+ page = self.request(http_method, route, path_params=path_params, query=query, options=options) or {}
118
+ items = page.get("data") or []
119
+ yield from items
120
+ last = items[-1] if items else None
121
+ if not page.get("has_more") or not isinstance(last, dict) or not last.get("id"):
122
+ return
123
+ query["after"] = last["id"]
124
+
125
+ def close(self) -> None:
126
+ if self._owns_http:
127
+ self.http.close()
128
+
129
+
130
+ class AsyncCore(_Core):
131
+ def __init__(self, *args: Any, http_client: httpx.AsyncClient | None = None) -> None:
132
+ super().__init__(*args)
133
+ self.http = http_client or httpx.AsyncClient()
134
+ self._owns_http = http_client is None
135
+
136
+ async def request(self, http_method, route, *, path_params=None, query=None, body=None, idempotent=False, idempotency_key=None, options=None):
137
+ request, max_retries, timeout, retryable = self._build(http_method, route, path_params, query, body, idempotent, idempotency_key, options)
138
+ attempt = 0
139
+ while True:
140
+ try:
141
+ response = await self.http.send(request)
142
+ except httpx.TimeoutException as err:
143
+ if not retryable or attempt >= max_retries:
144
+ raise APITimeoutError(timeout) from err
145
+ await asyncio.sleep(_backoff(attempt))
146
+ attempt += 1
147
+ continue
148
+ except httpx.TransportError as err:
149
+ if not retryable or attempt >= max_retries:
150
+ raise APIConnectionError() from err
151
+ await asyncio.sleep(_backoff(attempt))
152
+ attempt += 1
153
+ continue
154
+ wait = _retry_delay(response, attempt, retryable)
155
+ if wait is None or attempt >= max_retries:
156
+ await response.aread()
157
+ return _result(response)
158
+ await response.aclose()
159
+ await asyncio.sleep(wait)
160
+ attempt += 1
161
+
162
+ async def paginate(self, http_method, route, *, path_params=None, query=None, body=None, options=None) -> AsyncIterator[Any]:
163
+ query = {k: v for k, v in (query or {}).items() if k not in PAGINATION_MANAGED}
164
+ while True:
165
+ page = await self.request(http_method, route, path_params=path_params, query=query, options=options) or {}
166
+ items = page.get("data") or []
167
+ for item in items:
168
+ yield item
169
+ last = items[-1] if items else None
170
+ if not page.get("has_more") or not isinstance(last, dict) or not last.get("id"):
171
+ return
172
+ query["after"] = last["id"]
173
+
174
+ async def aclose(self) -> None:
175
+ if self._owns_http:
176
+ await self.http.aclose()
177
+
178
+
179
+ class _SyncResource:
180
+ def __init__(self, core: SyncCore) -> None:
181
+ self._core = core
182
+
183
+
184
+ class _AsyncResource:
185
+ def __init__(self, core: AsyncCore) -> None:
186
+ self._core = core
187
+
188
+
189
+ def _fill(route: str, values: Mapping[str, str] | None) -> str:
190
+ for name, value in (values or {}).items():
191
+ route = route.replace("{" + name + "}", quote(str(value), safe=""))
192
+ return route
193
+
194
+
195
+ def _query_value(v: Any) -> Any:
196
+ if isinstance(v, bool):
197
+ return "true" if v else "false"
198
+ return v
199
+
200
+
201
+ def _retry_delay(response: httpx.Response, attempt: int, retryable: bool) -> float | None:
202
+ """Seconds to wait before retrying this response, or None for no retry."""
203
+ if response.status_code not in RETRY_STATUS:
204
+ return None
205
+ after = _retry_after(response.headers.get("Retry-After"))
206
+ if response.status_code == 429:
207
+ # A per-second limit sends Retry-After and rejects before any work is
208
+ # done, so any method may retry. Without the header (an hourly or
209
+ # daily limit), retrying soon cannot succeed.
210
+ if after is None or after > MAX_RETRY_AFTER:
211
+ return None
212
+ return after
213
+ if not retryable:
214
+ return None
215
+ if after is not None:
216
+ return after if after <= MAX_RETRY_AFTER else None
217
+ return _backoff(attempt)
218
+
219
+
220
+ def _retry_after(header: str | None) -> float | None:
221
+ if not header:
222
+ return None
223
+ try:
224
+ seconds = float(header)
225
+ return max(seconds, 0.0) if math.isfinite(seconds) else None
226
+ except ValueError:
227
+ try:
228
+ parsed = email.utils.parsedate_to_datetime(header)
229
+ except (TypeError, ValueError):
230
+ return None
231
+ return max(float(parsed.timestamp() - time.time()), 0.0)
232
+
233
+
234
+ def _backoff(attempt: int) -> float:
235
+ return float(min(0.5 * 2**attempt, 8.0)) * (0.75 + random.random() * 0.5)
236
+
237
+
238
+ def _result(response: httpx.Response) -> Any:
239
+ if not 200 <= response.status_code < 300:
240
+ raise APIError.from_response(response)
241
+ if response.status_code == 204 or not response.content:
242
+ return None
243
+ try:
244
+ return response.json()
245
+ except ValueError:
246
+ return response.text
smtpfast/_errors.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+
8
+ class SMTPfastError(Exception):
9
+ """Every error the SDK raises is an SMTPfastError."""
10
+
11
+
12
+ class APIError(SMTPfastError):
13
+ """The API answered with a status outside 2xx."""
14
+
15
+ def __init__(self, status: int, body: Any, headers: httpx.Headers) -> None:
16
+ self.status = status
17
+ self.body = body
18
+ self.headers = headers
19
+ super().__init__(_message(status, body))
20
+
21
+ @staticmethod
22
+ def from_response(response: httpx.Response) -> APIError:
23
+ try:
24
+ body: Any = response.json()
25
+ except ValueError:
26
+ body = response.text
27
+ status = response.status_code
28
+ kind = _BY_STATUS.get(status) or (InternalServerError if status >= 500 else APIError)
29
+ return kind(status, body, response.headers)
30
+
31
+
32
+ class BadRequestError(APIError):
33
+ pass
34
+
35
+
36
+ class AuthenticationError(APIError):
37
+ pass
38
+
39
+
40
+ class PermissionDeniedError(APIError):
41
+ pass
42
+
43
+
44
+ class NotFoundError(APIError):
45
+ pass
46
+
47
+
48
+ class ConflictError(APIError):
49
+ pass
50
+
51
+
52
+ class UnprocessableEntityError(APIError):
53
+ pass
54
+
55
+
56
+ class RateLimitError(APIError):
57
+ pass
58
+
59
+
60
+ class InternalServerError(APIError):
61
+ pass
62
+
63
+
64
+ class APIConnectionError(SMTPfastError):
65
+ """The request did not get an answer: DNS, TLS, a reset connection."""
66
+
67
+ def __init__(self, message: str = "Could not connect to the SMTPfast API") -> None:
68
+ super().__init__(message)
69
+
70
+
71
+ class APITimeoutError(APIConnectionError):
72
+ """No answer arrived within the timeout."""
73
+
74
+ def __init__(self, timeout: float) -> None:
75
+ super().__init__(f"The SMTPfast API did not answer within {timeout:g} s")
76
+
77
+
78
+ _BY_STATUS: dict[int, type[APIError]] = {
79
+ 400: BadRequestError,
80
+ 401: AuthenticationError,
81
+ 403: PermissionDeniedError,
82
+ 404: NotFoundError,
83
+ 409: ConflictError,
84
+ 422: UnprocessableEntityError,
85
+ 429: RateLimitError,
86
+ }
87
+
88
+
89
+ def _message(status: int, body: Any) -> str:
90
+ if isinstance(body, dict) and isinstance(body.get("error"), str):
91
+ return f"{status}: {body['error']}"
92
+ if isinstance(body, str) and body.strip():
93
+ return f"{status}: {body.strip()[:300]}"
94
+ return f"{status}: the SMTPfast API answered with an error"