mailflat 0.3.3__tar.gz → 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailflat
3
- Version: 0.3.3
3
+ Version: 0.4.1
4
4
  Summary: Official Python SDK for MailFlat — disposable, automation-friendly email inboxes with one-line OTP retrieval.
5
5
  Project-URL: Homepage, https://mailflat.net
6
6
  Project-URL: Documentation, https://mailflat.net
@@ -14,10 +14,16 @@ Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
17
23
  Classifier: Topic :: Communications :: Email
18
24
  Classifier: Topic :: Software Development :: Testing
19
25
  Classifier: Typing :: Typed
20
- Requires-Python: >=3.8
26
+ Requires-Python: >=3.9
21
27
  Requires-Dist: httpx<1,>=0.24
22
28
  Provides-Extra: dev
23
29
  Requires-Dist: langchain-core>=0.2; extra == 'dev'
@@ -85,20 +91,42 @@ for self-hosted / BYOD deployments. Supports use as a context manager (`with Mai
85
91
 
86
92
  ### `Inbox`
87
93
  - `.address` — the email address.
88
- - `.messages() -> list[Message]` — all messages, newest first.
89
- - `.latest() -> Message | None` — most recent message.
94
+ - `.messages(*, direction="in") -> list[Message]` — messages, newest first.
95
+ - `.latest(*, direction="in") -> Message | None` — most recent message.
90
96
  - `.wait_for_otp(*, timeout=30, poll_interval=1.0) -> str` — poll until an OTP arrives; returns the code.
91
- - `.wait_for_message(*, timeout=30, poll_interval=1.0) -> Message` — poll until any message arrives.
97
+ - `.wait_for_message(*, timeout=30, poll_interval=1.0, direction="in") -> Message` — poll until a message arrives.
92
98
  - `.send(to, *, subject="", body="", html=None) -> dict` — send a DKIM-signed email from this inbox.
99
+ - `.mark_read(message_id) -> dict` — mark a message read so later polls can skip it.
100
+ - `.burn() -> dict` — delete every message but keep the address.
101
+ - `.download_attachment(message_id, attachment_id) -> bytes` — fetch an attachment's bytes.
93
102
  - `.delete() -> dict` — delete the inbox and all its messages.
94
103
 
104
+ > **Reads return received mail by default.** `direction="out"` returns mail you sent from
105
+ > this address, `"all"` returns both. This matters for agent-to-agent flows: without it,
106
+ > `send()` followed by `wait_for_message()` returns your own outgoing message.
107
+
95
108
  ### `Message`
96
- `.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`, `.raw`.
109
+ `.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`,
110
+ `.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.raw`.
111
+
112
+ - `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
113
+ - `.attachments` — metadata; `msg.attachments[0].download()` fetches the bytes.
114
+ ⚠️ `send()` cannot attach files yet, so you cannot produce an attachment from the SDK
115
+ alone — send one from a normal mail client to test this path.
116
+ - `.spam` — `{score, required, is_spam, rules, scanner}`, or `None` when never scanned
117
+ (which is not the same as a score of 0). ⚠️ Spam scanning is currently **disabled** on
118
+ mailflat.net, so today this is `None` for every message.
119
+ - `.headers` — raw headers, exactly as they arrived. Names are case-insensitive per
120
+ RFC 5322 but this is a plain dict, so **do not index it**: the real key is `Message-ID`,
121
+ and `headers["Message-Id"]` raises `KeyError`. Use `.header("message-id")` or `.message_id`.
122
+ - `.mark_read()` / `.delete()` — act on this message directly.
97
123
 
98
124
  ## Errors
99
125
 
100
- All errors subclass `MailFlatError`: `AuthenticationError` (401), `PermissionError` (403),
101
- `NotFoundError` (404), `RateLimitError` (429), `APIError` (other), `OTPTimeoutError`
126
+ All errors subclass `MailFlatError`: `AuthenticationError` (401), `MailFlatPermissionError` (403,
127
+ still exported as `PermissionError` for compatibility — the new name no longer shadows the built-in),
128
+ `NotFoundError` (404), `RateLimitError` (429, carries `.retry_after` when the server sent one),
129
+ `APIError` (other), `OTPTimeoutError`
102
130
  (no OTP before timeout), `EncryptedInboxError` (the inbox is end-to-end encrypted, so the
103
131
  server cannot read its contents — use a non-encrypted inbox for agent automation).
104
132
 
@@ -56,20 +56,42 @@ for self-hosted / BYOD deployments. Supports use as a context manager (`with Mai
56
56
 
57
57
  ### `Inbox`
58
58
  - `.address` — the email address.
59
- - `.messages() -> list[Message]` — all messages, newest first.
60
- - `.latest() -> Message | None` — most recent message.
59
+ - `.messages(*, direction="in") -> list[Message]` — messages, newest first.
60
+ - `.latest(*, direction="in") -> Message | None` — most recent message.
61
61
  - `.wait_for_otp(*, timeout=30, poll_interval=1.0) -> str` — poll until an OTP arrives; returns the code.
62
- - `.wait_for_message(*, timeout=30, poll_interval=1.0) -> Message` — poll until any message arrives.
62
+ - `.wait_for_message(*, timeout=30, poll_interval=1.0, direction="in") -> Message` — poll until a message arrives.
63
63
  - `.send(to, *, subject="", body="", html=None) -> dict` — send a DKIM-signed email from this inbox.
64
+ - `.mark_read(message_id) -> dict` — mark a message read so later polls can skip it.
65
+ - `.burn() -> dict` — delete every message but keep the address.
66
+ - `.download_attachment(message_id, attachment_id) -> bytes` — fetch an attachment's bytes.
64
67
  - `.delete() -> dict` — delete the inbox and all its messages.
65
68
 
69
+ > **Reads return received mail by default.** `direction="out"` returns mail you sent from
70
+ > this address, `"all"` returns both. This matters for agent-to-agent flows: without it,
71
+ > `send()` followed by `wait_for_message()` returns your own outgoing message.
72
+
66
73
  ### `Message`
67
- `.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`, `.raw`.
74
+ `.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`,
75
+ `.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.raw`.
76
+
77
+ - `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
78
+ - `.attachments` — metadata; `msg.attachments[0].download()` fetches the bytes.
79
+ ⚠️ `send()` cannot attach files yet, so you cannot produce an attachment from the SDK
80
+ alone — send one from a normal mail client to test this path.
81
+ - `.spam` — `{score, required, is_spam, rules, scanner}`, or `None` when never scanned
82
+ (which is not the same as a score of 0). ⚠️ Spam scanning is currently **disabled** on
83
+ mailflat.net, so today this is `None` for every message.
84
+ - `.headers` — raw headers, exactly as they arrived. Names are case-insensitive per
85
+ RFC 5322 but this is a plain dict, so **do not index it**: the real key is `Message-ID`,
86
+ and `headers["Message-Id"]` raises `KeyError`. Use `.header("message-id")` or `.message_id`.
87
+ - `.mark_read()` / `.delete()` — act on this message directly.
68
88
 
69
89
  ## Errors
70
90
 
71
- All errors subclass `MailFlatError`: `AuthenticationError` (401), `PermissionError` (403),
72
- `NotFoundError` (404), `RateLimitError` (429), `APIError` (other), `OTPTimeoutError`
91
+ All errors subclass `MailFlatError`: `AuthenticationError` (401), `MailFlatPermissionError` (403,
92
+ still exported as `PermissionError` for compatibility — the new name no longer shadows the built-in),
93
+ `NotFoundError` (404), `RateLimitError` (429, carries `.retry_after` when the server sent one),
94
+ `APIError` (other), `OTPTimeoutError`
73
95
  (no OTP before timeout), `EncryptedInboxError` (the inbox is end-to-end encrypted, so the
74
96
  server cannot read its contents — use a non-encrypted inbox for agent automation).
75
97
 
@@ -10,7 +10,7 @@ name = "mailflat"
10
10
  dynamic = ["version"]
11
11
  description = "Official Python SDK for MailFlat — disposable, automation-friendly email inboxes with one-line OTP retrieval."
12
12
  readme = "README.md"
13
- requires-python = ">=3.8"
13
+ requires-python = ">=3.9"
14
14
  license = { text = "MIT" }
15
15
  authors = [{ name = "MailFlat", email = "support@mailflat.net" }]
16
16
  keywords = ["email", "disposable-email", "otp", "automation", "testing", "ai-agents", "temporary-email"]
@@ -20,6 +20,16 @@ classifiers = [
20
20
  "License :: OSI Approved :: MIT License",
21
21
  "Programming Language :: Python :: 3",
22
22
  "Programming Language :: Python :: 3 :: Only",
23
+ # Aralığın İKİ UCU gerçekten koşuldu (3.11 + 3.14, 53 test yeşil). Aradaki sürümler için
24
+ # ayrı bir iddia yok: sürüme özgü API kullanmıyoruz ve `from __future__ import annotations`
25
+ # her dosyada var. Alt sınır 3.8 → 3.9: 3.8 EOL ve HİÇ test edilmedi; test edilmemiş bir
26
+ # sürümü desteklediğini söylemek, PyPI'da kullanıcıyı yanlış filtrelemeye götürür.
27
+ "Programming Language :: Python :: 3.9",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Programming Language :: Python :: 3.14",
23
33
  "Topic :: Communications :: Email",
24
34
  "Topic :: Software Development :: Testing",
25
35
  "Typing :: Typed",
@@ -0,0 +1,59 @@
1
+ """MailFlat Python SDK — the official client for automation-friendly email inboxes.
2
+
3
+ `pip install mailflat` → `from mailflat import MailFlat`.
4
+
5
+ Connected to:
6
+ - imports from: mailflat.client, mailflat.inbox, mailflat.errors
7
+ - imported by: user code, mailflat-mcp, mailflat.langchain
8
+
9
+ Key exports:
10
+ - `MailFlat` — client
11
+ - `Inbox`, `Message`, `Attachment` — domain types
12
+ - errors: `MailFlatError`, `AuthenticationError`, `MailFlatPermissionError`,
13
+ `NotFoundError`, `RateLimitError`, `APIError`, `OTPTimeoutError`, `EncryptedInboxError`
14
+ - `redact_secrets()` — strips secret fields from agent tool output (used by MCP/LangChain)
15
+ """
16
+ # NOT: `packages/` YAYIN yüzeyi → docstring'ler İngilizce (CLAUDE.md §3).
17
+ # Bu satırın kendisi bilerek YORUM: aynı notu docstring'e koymak tam da kaçındığımız
18
+ # şeydi — `help()` docstring'i basar, yorumu basmaz.
19
+ # NOT: `packages/` YAYIN yüzeyi → docstring'ler İngilizce (CLAUDE.md §3).
20
+ # Bu satır bilerek YORUM: docstring'e Türkçe not koymak, tam da kaçındığımız şeydi —
21
+ # `help()` docstring'i basar, yorumu basmaz.
22
+ from __future__ import annotations
23
+
24
+ from .client import MailFlat
25
+ from .errors import (
26
+ APIError,
27
+ AuthenticationError,
28
+ EncryptedInboxError,
29
+ MailFlatError,
30
+ MailFlatPermissionError,
31
+ NotFoundError,
32
+ OTPTimeoutError,
33
+ PermissionError,
34
+ RateLimitError,
35
+ )
36
+ from .inbox import Attachment, Inbox, Message
37
+ from .redact import redact_secrets
38
+
39
+ # Paketin TEK sürüm kaynağı — pyproject.toml bunu [tool.hatch.version] ile okur.
40
+ # Yayın = burayı yükselt, başka hiçbir yeri değil.
41
+ __version__ = "0.4.1"
42
+
43
+ __all__ = [
44
+ "MailFlat",
45
+ "Inbox",
46
+ "Message",
47
+ "Attachment",
48
+ "MailFlatError",
49
+ "AuthenticationError",
50
+ "MailFlatPermissionError",
51
+ "PermissionError", # deprecated alias of MailFlatPermissionError
52
+ "NotFoundError",
53
+ "RateLimitError",
54
+ "APIError",
55
+ "OTPTimeoutError",
56
+ "EncryptedInboxError",
57
+ "redact_secrets",
58
+ "__version__",
59
+ ]
@@ -0,0 +1,251 @@
1
+ """MailFlat client — a thin, typed Python wrapper around the /api/v1 automation API.
2
+
3
+ One account API key (X-API-Key) creates inboxes, lists them, reads mail/OTP and sends mail.
4
+ Every higher layer (MCP, LangChain) calls this client, so behaviour lives in one place.
5
+
6
+ Connected to:
7
+ - imports from: mailflat.inbox, mailflat.errors (+ httpx)
8
+ - imported by: mailflat.__init__
9
+
10
+ Key exports:
11
+ - `MailFlat(api_key=..., base_url=...)` — client
12
+ - `.create(...) / .create_inbox(...)` — new inbox → `Inbox`
13
+ - `.list()` — list of `Inbox`
14
+ - `.inbox(address)` — attach to an existing address
15
+ """
16
+ # NOT: `packages/` YAYIN yüzeyi → docstring'ler İngilizce (CLAUDE.md §3).
17
+ # Bu satırın kendisi bilerek YORUM: aynı notu docstring'e koymak tam da kaçındığımız
18
+ # şeydi — `help()` docstring'i basar, yorumu basmaz.
19
+ # NOT: `packages/` YAYIN yüzeyi → docstring'ler İngilizce (CLAUDE.md §3).
20
+ # Bu satır bilerek YORUM: docstring'e Türkçe not koymak, tam da kaçındığımız şeydi —
21
+ # `help()` docstring'i basar, yorumu basmaz.
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ import time
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ from .errors import MailFlatError, raise_for_status
32
+ from .inbox import Inbox
33
+
34
+ DEFAULT_BASE_URL = "https://mailflat.net"
35
+ _RETRY_STATUSES = {429, 502, 503, 504}
36
+
37
+ # Yalnız yan etkisi olmayan (idempotent) metotlar tekrar denenir. POST bilerek YOK:
38
+ # /send'de MTA maili kabul edip ağ geçidi 504 dönerse, retry AYNI MAİLİ İKİNCİ KEZ yollar
39
+ # ve kullanıcı bunu asla fark etmez. Idempotency-Key desteği gelene kadar POST tek atış.
40
+ _IDEMPOTENT_METHODS = {"GET", "HEAD", "DELETE"}
41
+
42
+ _MAX_BACKOFF = 8.0
43
+
44
+ logger = logging.getLogger("mailflat")
45
+
46
+
47
+ def _retry_after_seconds(resp: httpx.Response) -> float | None:
48
+ """Parse the `Retry-After` header (seconds form). Returns None when absent/unparsable."""
49
+ # Sunucu "12 saniye bekle" derken 1 saniyede dönmek hem 429'u tekrar yemek hem de
50
+ # rate limit penceresini uzatmak demekti.
51
+ raw = resp.headers.get("Retry-After")
52
+ if not raw:
53
+ return None
54
+ try:
55
+ return max(0.0, float(raw.strip()))
56
+ except (TypeError, ValueError):
57
+ # HTTP-date biçimi de geçerli ama nadir; tahmin etmektense kendi backoff'umuza düşeriz.
58
+ return None
59
+
60
+
61
+ class MailFlat:
62
+ """MailFlat automation API client.
63
+
64
+ >>> from mailflat import MailFlat
65
+ >>> mf = MailFlat(api_key="mf_live_...")
66
+ >>> inbox = mf.create(label="signup-test")
67
+ >>> inbox.address
68
+ 'signup-test-...@mailflat.net'
69
+ >>> otp = inbox.wait_for_otp(timeout=30)
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: str | None = None,
75
+ *,
76
+ base_url: str = DEFAULT_BASE_URL,
77
+ timeout: float = 30.0,
78
+ max_retries: int = 2,
79
+ http_client: httpx.Client | None = None,
80
+ ) -> None:
81
+ api_key = api_key or os.environ.get("MAILFLAT_API_KEY")
82
+ if not api_key:
83
+ raise MailFlatError(
84
+ "An API key is required. Pass MailFlat(api_key=...) or set MAILFLAT_API_KEY."
85
+ )
86
+ self.api_key = api_key
87
+ self.base_url = base_url.rstrip("/")
88
+ self.max_retries = max(0, max_retries)
89
+ # http_client enjeksiyonu testleri (httpx.MockTransport) kolaylaştırır.
90
+ self._http = http_client or httpx.Client(
91
+ base_url=self.base_url,
92
+ headers={"X-API-Key": api_key, "User-Agent": self.user_agent()},
93
+ timeout=timeout,
94
+ )
95
+
96
+ @staticmethod
97
+ def user_agent() -> str:
98
+ """Return the `User-Agent` this client sends, e.g. `mailflat-python/0.4.0`.
99
+
100
+ Sending the version lets the server tell which SDK build is misbehaving.
101
+ """
102
+ # Sürüm burada okunuyor ki modül seviyesinde döngüsel import oluşmasın.
103
+ from . import __version__
104
+
105
+ return f"mailflat-python/{__version__}"
106
+
107
+ # --------------------------------------------------------------- context
108
+ def __enter__(self) -> "MailFlat":
109
+ return self
110
+
111
+ def __exit__(self, *exc: object) -> None:
112
+ self.close()
113
+
114
+ def close(self) -> None:
115
+ self._http.close()
116
+
117
+ # --------------------------------------------------------------- HTTP iç
118
+ def _request(self, method: str, path: str, json: dict[str, Any] | None = None,
119
+ *, idempotent: bool | None = None) -> dict[str, Any]:
120
+ """`idempotent=True` → metodu ne olursa olsun retry edilebilir sayılır.
121
+
122
+ mark_read/burn POST ama tekrarı zararsız (aynı sonuç). send için ASLA verilmez:
123
+ tekrarlanan bir send aynı maili ikinci kez teslim eder (B-058).
124
+ """
125
+ attempt = 0
126
+ while True:
127
+ try:
128
+ resp = self._http.request(method, path, json=json)
129
+ except httpx.HTTPError as exc: # ağ/timeout
130
+ raise MailFlatError(f"Network error: {exc}") from exc
131
+
132
+ retry_after = _retry_after_seconds(resp)
133
+ can_repeat = method.upper() in _IDEMPOTENT_METHODS if idempotent is None else idempotent
134
+ retriable = (
135
+ resp.status_code in _RETRY_STATUSES
136
+ and can_repeat
137
+ and attempt < self.max_retries
138
+ )
139
+ if retriable:
140
+ attempt += 1
141
+ delay = retry_after if retry_after is not None else min(2 ** attempt * 0.5, _MAX_BACKOFF)
142
+ delay = min(delay, _MAX_BACKOFF)
143
+ logger.debug("retrying %s %s after %s (status %s, attempt %s/%s)",
144
+ method, path, delay, resp.status_code, attempt, self.max_retries)
145
+ time.sleep(delay)
146
+ continue
147
+
148
+ try:
149
+ data = resp.json()
150
+ except ValueError:
151
+ data = {}
152
+
153
+ if resp.status_code >= 400:
154
+ detail = ""
155
+ if isinstance(data, dict):
156
+ detail = data.get("detail") or data.get("error") or ""
157
+ logger.debug("%s %s failed with %s: %s", method, path, resp.status_code, detail)
158
+ raise_for_status(resp.status_code, detail, retry_after=retry_after)
159
+
160
+ if isinstance(data, dict) and data.get("error"): # güvenlik: 200 + error
161
+ raise MailFlatError(data["error"], status_code=resp.status_code)
162
+ return data if isinstance(data, dict) else {}
163
+
164
+ def _get(self, path: str) -> dict[str, Any]:
165
+ return self._request("GET", path)
166
+
167
+ def _post(self, path: str, json: dict[str, Any] | None = None,
168
+ *, idempotent: bool = False) -> dict[str, Any]:
169
+ return self._request("POST", path, json=json or {}, idempotent=idempotent)
170
+
171
+ def _delete(self, path: str) -> dict[str, Any]:
172
+ return self._request("DELETE", path)
173
+
174
+ def _get_bytes(self, path: str) -> httpx.Response:
175
+ """Raw response for binary endpoints (attachment download).
176
+
177
+ Retries on the same statuses as any other GET: this path bypassed the retry loop
178
+ entirely until 0.4.1, so an attachment download gave up on the first 503 while
179
+ every other read retried.
180
+ """
181
+ attempt = 0
182
+ while True:
183
+ try:
184
+ resp = self._http.request("GET", path)
185
+ except httpx.HTTPError as exc:
186
+ raise MailFlatError(f"Network error: {exc}") from exc
187
+ retry_after = _retry_after_seconds(resp)
188
+ if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries:
189
+ attempt += 1
190
+ delay = retry_after if retry_after is not None else min(2 ** attempt * 0.5, _MAX_BACKOFF)
191
+ logger.debug("retrying GET %s after %s (status %s)", path, delay, resp.status_code)
192
+ time.sleep(min(delay, _MAX_BACKOFF))
193
+ continue
194
+ break
195
+ if resp.status_code >= 400:
196
+ detail = ""
197
+ try:
198
+ body = resp.json()
199
+ if isinstance(body, dict):
200
+ detail = body.get("detail") or body.get("error") or ""
201
+ except ValueError:
202
+ pass
203
+ raise_for_status(resp.status_code, detail, retry_after=_retry_after_seconds(resp))
204
+ return resp
205
+
206
+ # --------------------------------------------------------------- public
207
+ def create(
208
+ self,
209
+ *,
210
+ prefix: str | None = None,
211
+ label: str | None = None,
212
+ subdomain: str | None = None,
213
+ domain: str | None = None,
214
+ retention_hours: int | None = None,
215
+ ) -> Inbox:
216
+ """Create a new inbox and return it as an `Inbox`.
217
+
218
+ - prefix: local part of the address (random `agent-...` when omitted)
219
+ - label: display name shown in the dashboard
220
+ - subdomain: mailflat.net subdomain (random when omitted)
221
+ - domain: one of your own verified BYOD domains, e.g. `acme.com`
222
+ - retention_hours: how long messages are kept (defaults to your plan, capped by it)
223
+ """
224
+ payload: dict[str, Any] = {}
225
+ if prefix is not None:
226
+ payload["prefix"] = prefix
227
+ if label is not None:
228
+ payload["label"] = label
229
+ if subdomain is not None:
230
+ payload["subdomain"] = subdomain
231
+ if domain is not None:
232
+ payload["domain"] = domain
233
+ if retention_hours is not None:
234
+ payload["retention_hours"] = retention_hours
235
+ res = self._post("/api/v1/inboxes", payload)
236
+ return Inbox(self, res["address"], **{k: v for k, v in res.items() if k != "address"})
237
+
238
+ # Landing snippet'leriyle birebir uyum için alias.
239
+ create_inbox = create
240
+
241
+ def list(self) -> list[Inbox]:
242
+ """Return the inboxes created with this API key."""
243
+ res = self._get("/api/v1/inboxes")
244
+ return [
245
+ Inbox(self, i["address"], **{k: v for k, v in i.items() if k != "address"})
246
+ for i in (res.get("inboxes") or [])
247
+ ]
248
+
249
+ def inbox(self, address: str) -> Inbox:
250
+ """Attach to an existing address without making an API call."""
251
+ return Inbox(self, address)
@@ -0,0 +1,87 @@
1
+ """MailFlat SDK error types — maps HTTP status codes to meaningful Python exceptions.
2
+
3
+ Connected to:
4
+ - imported by: mailflat.client, mailflat.inbox, mailflat.__init__
5
+
6
+ Key exports:
7
+ - `MailFlatError` — base class for every SDK error
8
+ - `AuthenticationError` — 401 (missing/invalid API key)
9
+ - `MailFlatPermissionError` — 403 (key does not own this inbox)
10
+ - `NotFoundError` — 404
11
+ - `RateLimitError` — 429 (carries `retry_after` when the server sent it)
12
+ - `APIError` — any other 4xx/5xx
13
+ - `OTPTimeoutError` — wait_for_otp / wait_for_message timed out
14
+ - `EncryptedInboxError` — end-to-end encrypted inbox cannot be read through the API
15
+ """
16
+ # NOT: `packages/` YAYIN yüzeyi → docstring'ler İngilizce (CLAUDE.md §3).
17
+ # Bu satırın kendisi bilerek YORUM: aynı notu docstring'e koymak tam da kaçındığımız
18
+ # şeydi — `help()` docstring'i basar, yorumu basmaz.
19
+ from __future__ import annotations
20
+
21
+
22
+ class MailFlatError(Exception):
23
+ """Base class for every MailFlat SDK error."""
24
+
25
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status_code = status_code
29
+
30
+
31
+ class AuthenticationError(MailFlatError):
32
+ """401 — the API key is missing or invalid."""
33
+
34
+
35
+ class MailFlatPermissionError(MailFlatError):
36
+ """403 — this API key does not own that inbox."""
37
+
38
+
39
+ # Geriye dönük uyumluluk: 0.3.3'e kadar bu sınıfın adı `PermissionError` idi ve built-in'i
40
+ # GÖLGELİYORDU. `except PermissionError` yazan kullanıcı, SDK'yı import etmemiş bir modülde
41
+ # built-in'i yakalayıp bizimkini kaçırabilir (ya da tersi) — sessiz ve teşhisi zor bir hata.
42
+ # Eski adı alias olarak bırakıyoruz ki mevcut kod kırılmasın.
43
+ PermissionError = MailFlatPermissionError # noqa: A001 - deprecated alias, kept for compatibility
44
+
45
+
46
+ class NotFoundError(MailFlatError):
47
+ """404 — the resource does not exist."""
48
+
49
+
50
+ class RateLimitError(MailFlatError):
51
+ """429 — rate limit exceeded.
52
+
53
+ `retry_after` holds the server's `Retry-After` value in seconds when it sent one,
54
+ so callers can wait exactly as long as the server asked instead of guessing.
55
+ """
56
+
57
+ def __init__(self, message: str, *, status_code: int | None = None,
58
+ retry_after: float | None = None) -> None:
59
+ super().__init__(message, status_code=status_code)
60
+ self.retry_after = retry_after
61
+
62
+
63
+ class APIError(MailFlatError):
64
+ """An unexpected 4xx/5xx response."""
65
+
66
+
67
+ class OTPTimeoutError(MailFlatError):
68
+ """No OTP (or message) arrived before the timeout elapsed."""
69
+
70
+
71
+ class EncryptedInboxError(MailFlatError):
72
+ """The inbox is end-to-end encrypted, so the server cannot read its contents."""
73
+
74
+
75
+ def raise_for_status(status_code: int, detail: str, *, retry_after: float | None = None) -> None:
76
+ """Raise the matching `MailFlatError` subclass for an HTTP status code."""
77
+ if status_code == 401:
78
+ raise AuthenticationError(detail or "Invalid or missing API key", status_code=status_code)
79
+ if status_code == 403:
80
+ raise MailFlatPermissionError(
81
+ detail or "This API key does not own that inbox", status_code=status_code)
82
+ if status_code == 404:
83
+ raise NotFoundError(detail or "Not found", status_code=status_code)
84
+ if status_code == 429:
85
+ raise RateLimitError(detail or "Rate limit exceeded", status_code=status_code,
86
+ retry_after=retry_after)
87
+ raise APIError(detail or f"Request failed with status {status_code}", status_code=status_code)