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 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
+ ]
@@ -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