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 +40 -0
- smtpfast/_client.py +63 -0
- smtpfast/_core.py +246 -0
- smtpfast/_errors.py +94 -0
- smtpfast/_resources.py +1654 -0
- smtpfast/_version.py +1 -0
- smtpfast/py.typed +0 -0
- smtpfast/types.py +3109 -0
- smtpfast/webhooks.py +42 -0
- smtpfast-0.1.0.dist-info/METADATA +131 -0
- smtpfast-0.1.0.dist-info/RECORD +13 -0
- smtpfast-0.1.0.dist-info/WHEEL +4 -0
- smtpfast-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|