mailfloss 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.
- mailfloss/__init__.py +22 -0
- mailfloss/_transport.py +38 -0
- mailfloss/client.py +264 -0
- mailfloss/errors.py +39 -0
- mailfloss/py.typed +0 -0
- mailfloss/resources.py +380 -0
- mailfloss/types.py +599 -0
- mailfloss-0.1.0.dist-info/METADATA +185 -0
- mailfloss-0.1.0.dist-info/RECORD +12 -0
- mailfloss-0.1.0.dist-info/WHEEL +5 -0
- mailfloss-0.1.0.dist-info/licenses/LICENSE +21 -0
- mailfloss-0.1.0.dist-info/top_level.txt +1 -0
mailfloss/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Official Mailfloss Python SDK.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from mailfloss import Mailfloss
|
|
6
|
+
|
|
7
|
+
client = Mailfloss(api_key="mf_rk_...") # or set MAILFLOSS_API_KEY
|
|
8
|
+
result = client.verify("jane@example.com")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import DEFAULT_BASE_URL, Mailfloss
|
|
12
|
+
from .errors import MailflossConfigError, MailflossError
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Mailfloss",
|
|
18
|
+
"MailflossError",
|
|
19
|
+
"MailflossConfigError",
|
|
20
|
+
"DEFAULT_BASE_URL",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
mailfloss/_transport.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Default HTTP transport built on the standard library only.
|
|
2
|
+
|
|
3
|
+
A transport is any callable with the signature::
|
|
4
|
+
|
|
5
|
+
transport(method, url, headers, body, timeout) -> (status, headers, body)
|
|
6
|
+
|
|
7
|
+
where ``body`` (request) is ``bytes`` or ``None``, the returned ``status`` is
|
|
8
|
+
an ``int``, the returned ``headers`` is a ``dict`` (case as sent by the
|
|
9
|
+
server), and the returned ``body`` is ``bytes``. HTTP-level error statuses
|
|
10
|
+
(4xx/5xx) must be RETURNED, not raised; only network/connection failures may
|
|
11
|
+
raise (``OSError`` and subclasses).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Dict, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
__all__ = ["urllib_transport"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def urllib_transport(
|
|
24
|
+
method: str,
|
|
25
|
+
url: str,
|
|
26
|
+
headers: Dict[str, str],
|
|
27
|
+
body: Optional[bytes],
|
|
28
|
+
timeout: float,
|
|
29
|
+
) -> Tuple[int, Dict[str, str], bytes]:
|
|
30
|
+
"""Perform an HTTP request with ``urllib.request``."""
|
|
31
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
34
|
+
return response.status, dict(response.headers.items()), response.read()
|
|
35
|
+
except urllib.error.HTTPError as exc:
|
|
36
|
+
# Non-2xx responses: surface as data, not as an exception.
|
|
37
|
+
payload = exc.read() if exc.fp is not None else b""
|
|
38
|
+
return exc.code, dict(exc.headers.items()), payload
|
mailfloss/client.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""The Mailfloss client: configuration, auth, retries, and the HTTP core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from ._transport import urllib_transport
|
|
13
|
+
from .errors import MailflossConfigError, MailflossError
|
|
14
|
+
from .resources import (
|
|
15
|
+
AccountResource,
|
|
16
|
+
BatchVerifyResource,
|
|
17
|
+
ErasuresResource,
|
|
18
|
+
IntegrationsResource,
|
|
19
|
+
JobsResource,
|
|
20
|
+
OrganizationResource,
|
|
21
|
+
ReportsResource,
|
|
22
|
+
UsersResource,
|
|
23
|
+
)
|
|
24
|
+
from .types import CheckKeyResult, VerifyResult
|
|
25
|
+
|
|
26
|
+
__all__ = ["Mailfloss", "DEFAULT_BASE_URL"]
|
|
27
|
+
|
|
28
|
+
VERSION = "0.1.0"
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.mailfloss.com/v1"
|
|
30
|
+
DEFAULT_TIMEOUT = 30.0
|
|
31
|
+
DEFAULT_MAX_RETRIES = 3
|
|
32
|
+
|
|
33
|
+
_BACKOFF_BASE = 0.5
|
|
34
|
+
_BACKOFF_FACTOR = 2.0
|
|
35
|
+
_BACKOFF_CAP = 8.0
|
|
36
|
+
|
|
37
|
+
Transport = Callable[
|
|
38
|
+
[str, str, Dict[str, str], Optional[bytes], float],
|
|
39
|
+
Tuple[int, Dict[str, str], bytes],
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_ENV_API_KEY = "MAILFLOSS_API_KEY"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _urlencode(params: Mapping[str, Any]) -> str:
|
|
46
|
+
from urllib.parse import quote
|
|
47
|
+
|
|
48
|
+
parts = []
|
|
49
|
+
for key, value in params.items():
|
|
50
|
+
if value is None:
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(value, bool):
|
|
53
|
+
value = "true" if value else "false"
|
|
54
|
+
parts.append(f"{quote(str(key), safe='')}={quote(str(value), safe='')}")
|
|
55
|
+
return "&".join(parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Mailfloss:
|
|
59
|
+
"""Client for the Mailfloss v1 API.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
api_key: Your Mailfloss API key. Falls back to the
|
|
63
|
+
``MAILFLOSS_API_KEY`` environment variable when omitted.
|
|
64
|
+
base_url: API base URL (defaults to ``https://api.mailfloss.com/v1``).
|
|
65
|
+
max_retries: How many times to retry a request that failed with 429,
|
|
66
|
+
a 5xx status, or a connection error (default 3).
|
|
67
|
+
timeout: Per-request socket timeout in seconds (default 30).
|
|
68
|
+
transport: Optional low-level transport callable
|
|
69
|
+
``(method, url, headers, body, timeout) -> (status, headers, body)``
|
|
70
|
+
— injectable for testing. Defaults to a ``urllib``-based transport.
|
|
71
|
+
sleep: Optional sleep function (defaults to ``time.sleep``) —
|
|
72
|
+
injectable so retry tests run instantly.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: Optional[str] = None,
|
|
78
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
79
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
80
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
81
|
+
transport: Optional[Transport] = None,
|
|
82
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
resolved_key = api_key or os.environ.get(_ENV_API_KEY)
|
|
85
|
+
if not resolved_key:
|
|
86
|
+
raise MailflossConfigError(
|
|
87
|
+
"No API key provided. Pass api_key=... to Mailfloss(), or set "
|
|
88
|
+
f"the {_ENV_API_KEY} environment variable."
|
|
89
|
+
)
|
|
90
|
+
self.api_key = resolved_key
|
|
91
|
+
self.base_url = base_url.rstrip("/")
|
|
92
|
+
self.max_retries = max_retries
|
|
93
|
+
self.timeout = timeout
|
|
94
|
+
self._transport: Transport = transport or urllib_transport
|
|
95
|
+
self._sleep: Callable[[float], None] = sleep or time.sleep
|
|
96
|
+
|
|
97
|
+
# Resource namespaces
|
|
98
|
+
self.batch_verify = BatchVerifyResource(self)
|
|
99
|
+
self.jobs = JobsResource(self)
|
|
100
|
+
self.users = UsersResource(self)
|
|
101
|
+
self.reports = ReportsResource(self)
|
|
102
|
+
self.account = AccountResource(self)
|
|
103
|
+
self.organization = OrganizationResource(self)
|
|
104
|
+
self.integrations = IntegrationsResource(self)
|
|
105
|
+
self.erasures = ErasuresResource(self)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Top-level endpoints
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def verify(
|
|
112
|
+
self,
|
|
113
|
+
email: str,
|
|
114
|
+
timeout: Optional[Union[int, float]] = None,
|
|
115
|
+
) -> VerifyResult:
|
|
116
|
+
"""Verify a single email address (GET /verify).
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
email: The email address to verify.
|
|
120
|
+
timeout: Optional per-request verification timeout, in seconds
|
|
121
|
+
(sent to the API — distinct from the client socket timeout).
|
|
122
|
+
"""
|
|
123
|
+
return self.request(
|
|
124
|
+
"GET", "/verify", query={"email": email, "timeout": timeout}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def check_key(
|
|
128
|
+
self,
|
|
129
|
+
api_key: Optional[str] = None,
|
|
130
|
+
public_key: Optional[str] = None,
|
|
131
|
+
check_plan: Optional[str] = None,
|
|
132
|
+
get_token: Optional[str] = None,
|
|
133
|
+
check_credits: Optional[str] = None,
|
|
134
|
+
) -> CheckKeyResult:
|
|
135
|
+
"""Validate an API key and return its account info (GET /check-key)."""
|
|
136
|
+
return self.request(
|
|
137
|
+
"GET",
|
|
138
|
+
"/check-key",
|
|
139
|
+
query={
|
|
140
|
+
"api_key": api_key,
|
|
141
|
+
"public_key": public_key,
|
|
142
|
+
"check_plan": check_plan,
|
|
143
|
+
"get_token": get_token,
|
|
144
|
+
"check_credits": check_credits,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# HTTP core
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def request(
|
|
153
|
+
self,
|
|
154
|
+
method: str,
|
|
155
|
+
path: str,
|
|
156
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
157
|
+
json_body: Optional[Any] = None,
|
|
158
|
+
idempotency_key: Optional[str] = None,
|
|
159
|
+
) -> Any:
|
|
160
|
+
"""Send an authenticated request and return the decoded JSON body.
|
|
161
|
+
|
|
162
|
+
Retries on 429 / 5xx (honoring ``Retry-After``) and on connection
|
|
163
|
+
errors, up to ``max_retries`` times. Raises :class:`MailflossError`
|
|
164
|
+
for any other non-2xx response (or once retries are exhausted).
|
|
165
|
+
"""
|
|
166
|
+
method = method.upper()
|
|
167
|
+
url = self.base_url + path
|
|
168
|
+
if query:
|
|
169
|
+
encoded = _urlencode(query)
|
|
170
|
+
if encoded:
|
|
171
|
+
url = f"{url}?{encoded}"
|
|
172
|
+
|
|
173
|
+
headers: Dict[str, str] = {
|
|
174
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
175
|
+
"Accept": "application/json",
|
|
176
|
+
"User-Agent": f"mailfloss-python/{VERSION}",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
body: Optional[bytes] = None
|
|
180
|
+
if json_body is not None:
|
|
181
|
+
body = json.dumps(json_body).encode("utf-8")
|
|
182
|
+
headers["Content-Type"] = "application/json"
|
|
183
|
+
|
|
184
|
+
if method == "POST":
|
|
185
|
+
# Generated once, so every retry replays the SAME key and the
|
|
186
|
+
# server can deduplicate.
|
|
187
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
188
|
+
|
|
189
|
+
attempt = 0
|
|
190
|
+
while True:
|
|
191
|
+
try:
|
|
192
|
+
status, resp_headers, resp_body = self._transport(
|
|
193
|
+
method, url, headers, body, self.timeout
|
|
194
|
+
)
|
|
195
|
+
except OSError:
|
|
196
|
+
# Connection-level failure (urllib.error.URLError is an
|
|
197
|
+
# OSError subclass). Retry with backoff if budget remains.
|
|
198
|
+
if attempt >= self.max_retries:
|
|
199
|
+
raise
|
|
200
|
+
self._sleep(self._backoff_delay(attempt, None))
|
|
201
|
+
attempt += 1
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if 200 <= status < 300:
|
|
205
|
+
return self._decode_success(resp_body)
|
|
206
|
+
|
|
207
|
+
if (status == 429 or status >= 500) and attempt < self.max_retries:
|
|
208
|
+
self._sleep(self._backoff_delay(attempt, resp_headers))
|
|
209
|
+
attempt += 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
raise self._build_error(status, resp_body)
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# Helpers
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _decode_success(body: bytes) -> Any:
|
|
220
|
+
if not body:
|
|
221
|
+
return None
|
|
222
|
+
try:
|
|
223
|
+
return json.loads(body.decode("utf-8"))
|
|
224
|
+
except (ValueError, UnicodeDecodeError):
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _build_error(status: int, body: bytes) -> MailflossError:
|
|
229
|
+
code = "unknown_error"
|
|
230
|
+
message = ""
|
|
231
|
+
error_type: Optional[str] = None
|
|
232
|
+
request_id: Optional[str] = None
|
|
233
|
+
try:
|
|
234
|
+
payload = json.loads(body.decode("utf-8"))
|
|
235
|
+
error = payload["error"]
|
|
236
|
+
code = error.get("code") or "unknown_error"
|
|
237
|
+
message = error.get("message") or ""
|
|
238
|
+
error_type = error.get("type")
|
|
239
|
+
request_id = error.get("request_id")
|
|
240
|
+
except (ValueError, UnicodeDecodeError, KeyError, TypeError, AttributeError):
|
|
241
|
+
message = body.decode("utf-8", errors="replace").strip() or f"HTTP {status}"
|
|
242
|
+
return MailflossError(
|
|
243
|
+
status=status,
|
|
244
|
+
code=code,
|
|
245
|
+
message=message,
|
|
246
|
+
type=error_type,
|
|
247
|
+
request_id=request_id,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _backoff_delay(attempt: int, headers: Optional[Mapping[str, str]]) -> float:
|
|
252
|
+
if headers:
|
|
253
|
+
retry_after = None
|
|
254
|
+
for key, value in headers.items():
|
|
255
|
+
if key.lower() == "retry-after":
|
|
256
|
+
retry_after = value
|
|
257
|
+
break
|
|
258
|
+
if retry_after is not None:
|
|
259
|
+
try:
|
|
260
|
+
return max(0.0, float(int(retry_after.strip())))
|
|
261
|
+
except (ValueError, AttributeError):
|
|
262
|
+
pass # Non-integer Retry-After → fall back to backoff.
|
|
263
|
+
ceiling = min(_BACKOFF_CAP, _BACKOFF_BASE * (_BACKOFF_FACTOR ** attempt))
|
|
264
|
+
return random.uniform(0.0, ceiling) # Full jitter.
|
mailfloss/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exception types for the Mailfloss SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
__all__ = ["MailflossError", "MailflossConfigError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MailflossConfigError(Exception):
|
|
11
|
+
"""Raised when the client is misconfigured (e.g. no API key available)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MailflossError(Exception):
|
|
15
|
+
"""Raised when the Mailfloss API returns a non-2xx response.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
status: HTTP status code of the response.
|
|
19
|
+
code: Stable machine-readable error code (``unknown_error`` when the
|
|
20
|
+
body could not be parsed as the standard error envelope).
|
|
21
|
+
message: Human-readable error message.
|
|
22
|
+
type: Coarse error category (e.g. ``invalid_request_error``).
|
|
23
|
+
request_id: The ``X-Request-Id`` echoed in the error envelope, if any.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
status: int,
|
|
29
|
+
code: str,
|
|
30
|
+
message: str,
|
|
31
|
+
type: Optional[str] = None, # noqa: A002 - mirrors the API field name
|
|
32
|
+
request_id: Optional[str] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(f"[{status}] {code}: {message}")
|
|
35
|
+
self.status = status
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
self.type = type
|
|
39
|
+
self.request_id = request_id
|
mailfloss/py.typed
ADDED
|
File without changes
|