mailflat 0.4.0__tar.gz → 0.5.0__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.4.0
3
+ Version: 0.5.0
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'
@@ -105,9 +111,14 @@ for self-hosted / BYOD deployments. Supports use as a context manager (`with Mai
105
111
 
106
112
  - `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
107
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.
108
116
  - `.spam` — `{score, required, is_spam, rules, scanner}`, or `None` when never scanned
109
- (which is not the same as a score of 0).
110
- - `.headers` — raw headers; `Message-ID` lives here (threading, dedup).
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`.
111
122
  - `.mark_read()` / `.delete()` — act on this message directly.
112
123
 
113
124
  ## Errors
@@ -76,9 +76,14 @@ for self-hosted / BYOD deployments. Supports use as a context manager (`with Mai
76
76
 
77
77
  - `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
78
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.
79
81
  - `.spam` — `{score, required, is_spam, rules, scanner}`, or `None` when never scanned
80
- (which is not the same as a score of 0).
81
- - `.headers` — raw headers; `Message-ID` lives here (threading, dedup).
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`.
82
87
  - `.mark_read()` / `.delete()` — act on this message directly.
83
88
 
84
89
  ## Errors
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mailflat"
7
+ # The version comes from ONE source: src/mailflat/__init__.py::__version__ (see
8
+ # [tool.hatch.version]). Hardcoding a `version = ...` here brings B-052 back: 0.3.1 was on
9
+ # PyPI while the package itself reported `mailflat.__version__ == "0.3.0"`. Two sources drift.
10
+ dynamic = ["version"]
11
+ description = "Official Python SDK for MailFlat — disposable, automation-friendly email inboxes with one-line OTP retrieval."
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ license = { text = "MIT" }
15
+ authors = [{ name = "MailFlat", email = "support@mailflat.net" }]
16
+ keywords = ["email", "disposable-email", "otp", "automation", "testing", "ai-agents", "temporary-email"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ # BOTH ENDS of the range were actually run (3.11 + 3.14, 53 tests green). No separate
24
+ # claim is made about the versions in between: we use no version-specific API and every
25
+ # file carries `from __future__ import annotations`. Floor moved 3.8 → 3.9 because 3.8 is
26
+ # EOL and was NEVER tested, and claiming an untested version misleads people on PyPI.
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",
33
+ "Topic :: Communications :: Email",
34
+ "Topic :: Software Development :: Testing",
35
+ "Typing :: Typed",
36
+ ]
37
+ # UPPER BOUND: httpx is still pre-1.0 and 1.0 is a declared breaking threshold. Leaving it
38
+ # unbounded invites a repeat of B-047 (mcp>=1.2 → mcp 2.0.0 removed the module → uvx
39
+ # mailflat-mcp crashed). Fine on 0.28.1 today; the bound is raised deliberately when 1.0 ships.
40
+ dependencies = ["httpx>=0.24,<1"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://mailflat.net"
44
+ # /docs is disabled in production (the FastAPI Swagger surface; see app/main.py).
45
+ # This URL moves to the public documentation site once that page is live.
46
+ Documentation = "https://mailflat.net"
47
+ Source = "https://github.com/onderyentar/mailflat"
48
+
49
+ [project.optional-dependencies]
50
+ langchain = ["langchain-core>=0.2"]
51
+ dev = ["pytest>=7", "respx>=0.20", "langchain-core>=0.2"]
52
+
53
+ [tool.hatch.version]
54
+ path = "src/mailflat/__init__.py"
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["src/mailflat"]
58
+
59
+ [tool.hatch.build.targets.sdist]
60
+ include = ["src/mailflat", "README.md", "LICENSE"]
@@ -2,8 +2,6 @@
2
2
 
3
3
  `pip install mailflat` → `from mailflat import MailFlat`.
4
4
 
5
- NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
6
-
7
5
  Connected to:
8
6
  - imports from: mailflat.client, mailflat.inbox, mailflat.errors
9
7
  - imported by: user code, mailflat-mcp, mailflat.langchain
@@ -32,9 +30,9 @@ from .errors import (
32
30
  from .inbox import Attachment, Inbox, Message
33
31
  from .redact import redact_secrets
34
32
 
35
- # Paketin TEK sürüm kaynağı — pyproject.toml bunu [tool.hatch.version] ile okur.
36
- # Yayın = burayı yükselt, başka hiçbir yeri değil.
37
- __version__ = "0.4.0"
33
+ # The package's ONLY version source — pyproject.toml reads it via [tool.hatch.version].
34
+ # Releasing = bump this line and nothing else.
35
+ __version__ = "0.5.0"
38
36
 
39
37
  __all__ = [
40
38
  "MailFlat",
@@ -3,8 +3,6 @@
3
3
  One account API key (X-API-Key) creates inboxes, lists them, reads mail/OTP and sends mail.
4
4
  Every higher layer (MCP, LangChain) calls this client, so behaviour lives in one place.
5
5
 
6
- NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
7
-
8
6
  Connected to:
9
7
  - imports from: mailflat.inbox, mailflat.errors (+ httpx)
10
8
  - imported by: mailflat.__init__
@@ -30,9 +28,10 @@ from .inbox import Inbox
30
28
  DEFAULT_BASE_URL = "https://mailflat.net"
31
29
  _RETRY_STATUSES = {429, 502, 503, 504}
32
30
 
33
- # Yalnız yan etkisi olmayan (idempotent) metotlar tekrar denenir. POST bilerek YOK:
34
- # /send'de MTA maili kabul edip ağ geçidi 504 dönerse, retry AYNI MAİLİ İKİNCİ KEZ yollar
35
- # ve kullanıcı bunu asla fark etmez. Idempotency-Key desteği gelene kadar POST tek atış.
31
+ # Only side-effect-free (idempotent) methods are retried. POST is deliberately excluded:
32
+ # if the MTA accepts a /send and the gateway then returns 504, a retry delivers THE SAME
33
+ # MAIL TWICE and the user never finds out. POST stays single-shot until we support
34
+ # Idempotency-Key.
36
35
  _IDEMPOTENT_METHODS = {"GET", "HEAD", "DELETE"}
37
36
 
38
37
  _MAX_BACKOFF = 8.0
@@ -41,18 +40,16 @@ logger = logging.getLogger("mailflat")
41
40
 
42
41
 
43
42
  def _retry_after_seconds(resp: httpx.Response) -> float | None:
44
- """Parse the `Retry-After` header (seconds form). Returns None when absent/unparsable.
45
-
46
- Bir sunucu "12 saniye bekle" diyorsa 1 saniyede geri dönmek, hem 429'u tekrar yemek
47
- hem de rate limit penceresini uzatmak demek.
48
- """
43
+ """Parse the `Retry-After` header (seconds form). Returns None when absent/unparsable."""
44
+ # Coming back after 1s when the server said "wait 12s" both earns another 429 and
45
+ # stretches the rate-limit window.
49
46
  raw = resp.headers.get("Retry-After")
50
47
  if not raw:
51
48
  return None
52
49
  try:
53
50
  return max(0.0, float(raw.strip()))
54
51
  except (TypeError, ValueError):
55
- # HTTP-date biçimi de geçerli ama nadir; tahmin etmektense kendi backoff'umuza düşeriz.
52
+ # The HTTP-date form is valid but rare; rather than guess, fall back to our backoff.
56
53
  return None
57
54
 
58
55
 
@@ -84,7 +81,7 @@ class MailFlat:
84
81
  self.api_key = api_key
85
82
  self.base_url = base_url.rstrip("/")
86
83
  self.max_retries = max(0, max_retries)
87
- # http_client enjeksiyonu testleri (httpx.MockTransport) kolaylaştırır.
84
+ # Injecting http_client keeps tests simple (httpx.MockTransport).
88
85
  self._http = http_client or httpx.Client(
89
86
  base_url=self.base_url,
90
87
  headers={"X-API-Key": api_key, "User-Agent": self.user_agent()},
@@ -93,11 +90,11 @@ class MailFlat:
93
90
 
94
91
  @staticmethod
95
92
  def user_agent() -> str:
96
- """Return the `User-Agent` this client sends, e.g. `mailflat-python/0.4.0`.
93
+ """Return the `User-Agent` this client sends, e.g. `mailflat-python/<version>`.
97
94
 
98
- Sunucu tarafında hangi SDK sürümünün sorun çıkardığını görebilmek için; sürüm
99
- döngüsel import'a girmeden okunur.
95
+ Sending the version lets the server tell which SDK build is misbehaving.
100
96
  """
97
+ # Read the version here so the module level stays free of a circular import.
101
98
  from . import __version__
102
99
 
103
100
  return f"mailflat-python/{__version__}"
@@ -112,19 +109,26 @@ class MailFlat:
112
109
  def close(self) -> None:
113
110
  self._http.close()
114
111
 
115
- # --------------------------------------------------------------- HTTP iç
116
- def _request(self, method: str, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
112
+ # -------------------------------------------------------------- HTTP internals
113
+ def _request(self, method: str, path: str, json: dict[str, Any] | None = None,
114
+ *, idempotent: bool | None = None) -> dict[str, Any]:
115
+ """`idempotent=True` marks a call as safe to repeat regardless of its method.
116
+
117
+ mark_read/burn are POSTs but repeating them is harmless (same end state). It is
118
+ never passed for send: a repeated send delivers the same mail twice.
119
+ """
117
120
  attempt = 0
118
121
  while True:
119
122
  try:
120
123
  resp = self._http.request(method, path, json=json)
121
- except httpx.HTTPError as exc: # ağ/timeout
124
+ except httpx.HTTPError as exc: # network / timeout
122
125
  raise MailFlatError(f"Network error: {exc}") from exc
123
126
 
124
127
  retry_after = _retry_after_seconds(resp)
128
+ can_repeat = method.upper() in _IDEMPOTENT_METHODS if idempotent is None else idempotent
125
129
  retriable = (
126
130
  resp.status_code in _RETRY_STATUSES
127
- and method.upper() in _IDEMPOTENT_METHODS
131
+ and can_repeat
128
132
  and attempt < self.max_retries
129
133
  )
130
134
  if retriable:
@@ -148,25 +152,41 @@ class MailFlat:
148
152
  logger.debug("%s %s failed with %s: %s", method, path, resp.status_code, detail)
149
153
  raise_for_status(resp.status_code, detail, retry_after=retry_after)
150
154
 
151
- if isinstance(data, dict) and data.get("error"): # güvenlik: 200 + error
155
+ if isinstance(data, dict) and data.get("error"): # safety net: 200 + error
152
156
  raise MailFlatError(data["error"], status_code=resp.status_code)
153
157
  return data if isinstance(data, dict) else {}
154
158
 
155
159
  def _get(self, path: str) -> dict[str, Any]:
156
160
  return self._request("GET", path)
157
161
 
158
- def _post(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
159
- return self._request("POST", path, json=json or {})
162
+ def _post(self, path: str, json: dict[str, Any] | None = None,
163
+ *, idempotent: bool = False) -> dict[str, Any]:
164
+ return self._request("POST", path, json=json or {}, idempotent=idempotent)
160
165
 
161
166
  def _delete(self, path: str) -> dict[str, Any]:
162
167
  return self._request("DELETE", path)
163
168
 
164
169
  def _get_bytes(self, path: str) -> httpx.Response:
165
- """Raw response for binary endpoints (attachment download)."""
166
- try:
167
- resp = self._http.request("GET", path)
168
- except httpx.HTTPError as exc:
169
- raise MailFlatError(f"Network error: {exc}") from exc
170
+ """Raw response for binary endpoints (attachment download).
171
+
172
+ Retries on the same statuses as any other GET: this path bypassed the retry loop
173
+ entirely until 0.4.1, so an attachment download gave up on the first 503 while
174
+ every other read retried.
175
+ """
176
+ attempt = 0
177
+ while True:
178
+ try:
179
+ resp = self._http.request("GET", path)
180
+ except httpx.HTTPError as exc:
181
+ raise MailFlatError(f"Network error: {exc}") from exc
182
+ retry_after = _retry_after_seconds(resp)
183
+ if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries:
184
+ attempt += 1
185
+ delay = retry_after if retry_after is not None else min(2 ** attempt * 0.5, _MAX_BACKOFF)
186
+ logger.debug("retrying GET %s after %s (status %s)", path, delay, resp.status_code)
187
+ time.sleep(min(delay, _MAX_BACKOFF))
188
+ continue
189
+ break
170
190
  if resp.status_code >= 400:
171
191
  detail = ""
172
192
  try:
@@ -210,7 +230,7 @@ class MailFlat:
210
230
  res = self._post("/api/v1/inboxes", payload)
211
231
  return Inbox(self, res["address"], **{k: v for k, v in res.items() if k != "address"})
212
232
 
213
- # Landing snippet'leriyle birebir uyum için alias.
233
+ # Alias so the landing-page snippets match the SDK exactly.
214
234
  create_inbox = create
215
235
 
216
236
  def list(self) -> list[Inbox]:
@@ -1,8 +1,5 @@
1
1
  """MailFlat SDK error types — maps HTTP status codes to meaningful Python exceptions.
2
2
 
3
- NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
4
- Burada docstring bir yorum değil, kullanıcının `help()` ve IDE tooltip'inde gördüğü arayüzdür.
5
-
6
3
  Connected to:
7
4
  - imported by: mailflat.client, mailflat.inbox, mailflat.__init__
8
5
 
@@ -36,10 +33,10 @@ class MailFlatPermissionError(MailFlatError):
36
33
  """403 — this API key does not own that inbox."""
37
34
 
38
35
 
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.
36
+ # Backwards compatibility: up to 0.3.3 this class was named `PermissionError` and SHADOWED
37
+ # the built-in. Someone writing `except PermissionError` in a module that never imported the
38
+ # SDK would catch the built-in and miss ours (or the reverse) — a silent, hard-to-diagnose
39
+ # bug. The old name stays as an alias so existing code keeps working.
43
40
  PermissionError = MailFlatPermissionError # noqa: A001 - deprecated alias, kept for compatibility
44
41
 
45
42
 
@@ -3,8 +3,6 @@
3
3
  `Inbox` is returned by the `MailFlat` client and carries the read, wait, send and delete
4
4
  methods. `Message` is the typed form of an /api/v1 email payload.
5
5
 
6
- NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
7
-
8
6
  Connected to:
9
7
  - imports from: mailflat.errors
10
8
  - imported by: mailflat.client, mailflat.__init__
@@ -23,11 +21,11 @@ from typing import TYPE_CHECKING, Any
23
21
 
24
22
  from .errors import EncryptedInboxError, MailFlatError, OTPTimeoutError
25
23
 
26
- if TYPE_CHECKING: # döngüsel import'tan kaçın (sadece tip ipucu)
24
+ if TYPE_CHECKING: # avoid a circular import (type hint only)
27
25
  from .client import MailFlat
28
26
 
29
- # Sunucunun kabul ettiği yön filtreleri. "in" varsayılan: bir ajan için "mesaj geldi mi"
30
- # sorusuna kendi gönderdiği mail cevap olamaz.
27
+ # Direction filters (server contract). "in" is the default: for an agent, "did a message
28
+ # arrive?" cannot be answered by mail the agent itself sent.
31
29
  DIRECTIONS = ("in", "out", "all")
32
30
 
33
31
 
@@ -41,7 +39,8 @@ def _check_direction(direction: str) -> str:
41
39
  class Attachment:
42
40
  """Attachment metadata. The bytes are fetched on demand with `.download()`.
43
41
 
44
- İçerik listede GELMEZ: tek ekli 5 MB'lık bir mail, mesaj listesini kullanılamaz yapardı.
42
+ Contents are never included in a message listing: a single 5 MB attachment would make
43
+ the list unusable, so the bytes live behind a separate request.
45
44
  """
46
45
 
47
46
  id: int | None = None
@@ -104,14 +103,18 @@ class Message:
104
103
  # read this instead of parsing the body themselves.
105
104
  links: list[str] = field(default_factory=list)
106
105
  attachments: list[Attachment] = field(default_factory=list)
107
- # {score, required, is_spam, rules, scanner} — None means NOT SCANNED, which is not
108
- # the same as a score of 0.0.
106
+ # {score, required, is_spam, rules, scanner}, or None when the message was NOT SCANNED
107
+ # (not the same as a score of 0.0).
108
+ # NOTE: spam scanning is currently disabled on mailflat.net, so in practice this is
109
+ # None for every message today. The field is wired end to end and will start carrying
110
+ # data as soon as scanning is switched back on.
109
111
  spam: dict[str, Any] | None = None
110
112
  # Raw message headers (Message-ID lives here — needed for threading and dedup).
111
113
  # None on encrypted inboxes: headers carry the Subject, so they go inside the envelope.
112
114
  headers: dict[str, Any] | None = None
113
115
  raw: dict[str, Any] = field(default_factory=dict, repr=False)
114
- # Bu mesajı döndüren Inbox (msg.delete() şekeri için). Inbox.messages()/latest()/wait_* doldurur.
116
+ # The Inbox this message came from (powers msg.delete()). Filled in by
117
+ # Inbox.messages() / latest() / wait_*.
115
118
  _inbox: "Inbox | None" = field(default=None, repr=False, compare=False)
116
119
 
117
120
  def delete(self) -> dict[str, Any]:
@@ -123,6 +126,26 @@ class Message:
123
126
  )
124
127
  return self._inbox.delete_message(self.id)
125
128
 
129
+ def header(self, name: str, default: Any = None) -> Any:
130
+ """Look up a header case-insensitively, e.g. `msg.header("message-id")`.
131
+
132
+ Header names are case-insensitive per RFC 5322, but `headers` is a plain dict whose
133
+ keys come straight off the wire — the real key is `Message-ID`, so
134
+ `headers["Message-Id"]` raises KeyError. Use this instead of indexing.
135
+ """
136
+ if not self.headers:
137
+ return default
138
+ target = name.replace("_", "-").lower()
139
+ for key, value in self.headers.items():
140
+ if str(key).lower() == target:
141
+ return value
142
+ return default
143
+
144
+ @property
145
+ def message_id(self) -> str | None:
146
+ """This message's `Message-ID`, or None when headers were not stored."""
147
+ return self.header("message-id")
148
+
126
149
  def mark_read(self) -> dict[str, Any]:
127
150
  """Mark this message as read, so the next poll can skip it."""
128
151
  if self._inbox is None or self.id is None:
@@ -170,14 +193,14 @@ class Inbox:
170
193
  def __init__(self, client: "MailFlat", address: str, **meta: Any) -> None:
171
194
  self._client = client
172
195
  self.address: str = address
173
- # create/list yanıtından gelen ek alanlar (varsa)
196
+ # Extra fields carried by the create/list response, when present.
174
197
  self.name: str | None = meta.get("name")
175
198
  self.api_key: str | None = meta.get("api_key") # per-inbox key (mf_sk_...)
176
199
  self.retention_hours: int | None = meta.get("retention_hours")
177
200
  self.encrypted: bool = bool(meta.get("encrypted"))
178
201
  self.via_api: bool | None = meta.get("via_api")
179
202
  self.created_at: str | None = meta.get("created_at")
180
- # backend yanıtının tamamı (Message.raw ile simetrik) — MCP/agent katmanı için.
203
+ # The full backend payload (symmetric with Message.raw) — used by the MCP/agent layer.
181
204
  self.raw: dict[str, Any] = {"address": address, **meta}
182
205
 
183
206
  def __repr__(self) -> str:
@@ -185,7 +208,7 @@ class Inbox:
185
208
 
186
209
  # ----------------------------------------------------------------- okuma
187
210
  def _wrap(self, data: dict[str, Any]) -> Message:
188
- """API mail dict'ini Message'a çevirir + bu inbox ref'ini iliştirir (msg.delete() için)."""
211
+ """Convert an API payload into a Message and attach this inbox (for msg.delete())."""
189
212
  m = Message.from_dict(data)
190
213
  m._inbox = self
191
214
  return m
@@ -276,7 +299,8 @@ class Inbox:
276
299
  )
277
300
  ctype = resp.headers.get("content-type", "")
278
301
  if ctype.startswith("application/json"):
279
- # Şifreli kutuda sunucu çözemez → zarf JSON'u döner, baytları değil.
302
+ # On an encrypted inbox the server cannot decrypt: it returns the envelope
303
+ # JSON rather than the bytes.
280
304
  raise EncryptedInboxError(
281
305
  "This inbox is end-to-end encrypted; the server cannot decrypt this attachment."
282
306
  )
@@ -302,14 +326,14 @@ class Inbox:
302
326
  tracking state on its own.
303
327
  """
304
328
  return self._client._post(
305
- f"/api/v1/inboxes/{self.address}/messages/{message_id}/read")
329
+ f"/api/v1/inboxes/{self.address}/messages/{message_id}/read", idempotent=True)
306
330
 
307
331
  def burn(self) -> dict[str, Any]:
308
332
  """Delete every message in this inbox and keep the address.
309
333
 
310
334
  Useful between test scenarios: the address stays registered wherever you used it.
311
335
  """
312
- return self._client._post(f"/api/v1/inboxes/{self.address}/burn")
336
+ return self._client._post(f"/api/v1/inboxes/{self.address}/burn", idempotent=True)
313
337
 
314
338
  def delete(self) -> dict[str, Any]:
315
339
  """Delete this inbox and all of its messages. Cannot be undone."""
@@ -3,17 +3,15 @@
3
3
  A thin LangChain shell over the `mailflat` SDK; all HTTP and behaviour live in the client.
4
4
  `langchain-core` is an optional dependency → `pip install mailflat[langchain]`.
5
5
 
6
- NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
7
-
8
6
  Usage:
9
7
  from mailflat.langchain import MailFlatToolkit
10
8
  toolkit = MailFlatToolkit(api_key=env("MAILFLAT_KEY"))
11
9
  tools = toolkit.get_tools() # create_inbox, list_inboxes, read_messages, wait_for_otp,
12
10
  # wait_for_message, send_email, mark_read, burn_inbox,
13
- # delete_inbox (delete_message bilerek YOK)
11
+ # delete_inbox (delete_message is deliberately absent)
14
12
 
15
- ⚠️ Araç çıktıları `redact_secrets()`'ten geçer: per-inbox `api_key` modelin bağlamına
16
- (ve oradan LangSmith trace'lerine) girmemeli — bkz. B-055.
13
+ ⚠️ Tool output goes through `redact_secrets()`: a per-inbox `api_key` must never reach the
14
+ model's context (and from there LangSmith traces) — see B-055.
17
15
 
18
16
  Connected to:
19
17
  - imports from: mailflat.client (MailFlat), mailflat.redact, langchain_core.tools
@@ -28,7 +26,7 @@ from typing import TYPE_CHECKING, Any
28
26
 
29
27
  try:
30
28
  from langchain_core.tools import BaseTool, StructuredTool
31
- except ImportError as exc: # langchain-core kurulu değil → açıklayıcı hata
29
+ except ImportError as exc: # langchain-core is not installed → explain how to get it
32
30
  raise ImportError(
33
31
  "MailFlat's LangChain integration requires 'langchain-core'. "
34
32
  "Install it with: pip install mailflat[langchain]"
@@ -57,7 +55,7 @@ class MailFlatToolkit:
57
55
  base_url: str = DEFAULT_BASE_URL,
58
56
  client: MailFlat | None = None,
59
57
  ) -> None:
60
- # client enjeksiyonu testleri kolaylaştırır; yoksa api_key ile kurulur.
58
+ # Injecting a client keeps tests simple; otherwise one is built from api_key.
61
59
  self._client = client or MailFlat(api_key=api_key, base_url=base_url)
62
60
 
63
61
  # ------------------------------------------------------------------ tools
@@ -102,7 +100,7 @@ class MailFlatToolkit:
102
100
  try:
103
101
  msgs = client.inbox(address).messages(direction=direction)
104
102
  return redact_secrets({"ok": True, "emails": [m.raw for m in msgs]})
105
- except ValueError as e: # geçersiz direction — ağa çıkmadan
103
+ except ValueError as e: # invalid direction — caught before any network call
106
104
  return {"error": str(e)}
107
105
  except MailFlatError as e:
108
106
  return {"error": str(e)}
@@ -211,6 +209,6 @@ class MailFlatToolkit:
211
209
  burn_inbox,
212
210
  delete_inbox,
213
211
  ]
214
- # from_function tip ipuçları + docstring'den args_schema'yı kendisi çıkarır
215
- # (pydantic v1/v2 sürüm farkı bu yolla atlanır).
212
+ # from_function derives args_schema from the type hints and docstring itself,
213
+ # which side-steps the pydantic v1/v2 difference.
216
214
  return [StructuredTool.from_function(func=f) for f in funcs]
@@ -0,0 +1,58 @@
1
+ """Strips secret fields from agent tool output (B-055).
2
+
3
+ Why it exists: `create_inbox` and `list_inboxes` used to return the backend payload as-is,
4
+ and that payload carried a per-inbox `api_key` (`mf_sk_...`). Tool output goes into the
5
+ MODEL's context, and from there into prompt logs, tracing tools (LangSmith, AI SDK
6
+ telemetry), error reports and the model's own later answers. `list_inboxes` leaked every
7
+ inbox key on the account in a single call.
8
+
9
+ The split is deliberate:
10
+ - **SDK = code surface.** `Inbox.api_key` stays; code a human wrote may use it.
11
+ - **Tool = model surface.** Everything passing through here is redacted.
12
+
13
+ Redaction is **name-based, not value-based**: scanning message bodies for strings starting
14
+ with `mf_` would corrupt a legitimate email that happens to discuss a test key. Matching on
15
+ the field name is both exact and harmless.
16
+
17
+ Connected to:
18
+ - imports from: —
19
+ - imported by: mailflat.langchain and this package's tests. Within the repo,
20
+ `packages/mcp` (mailflat-mcp, a separate PyPI package) also imports it — that package
21
+ is not bundled here, so the reference is repo-level.
22
+
23
+ Key exports:
24
+ - `redact_secrets(value)` — copy of the input with secret fields removed
25
+ - `SECRET_NAME_PARTS` — which field names count as secrets
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from typing import Any
30
+
31
+ #: A field is dropped when its normalised name CONTAINS one of these.
32
+ #: Kept deliberately narrow: `token` is absent on purpose — fields like a domain
33
+ #: `verify_token` are harmless and sometimes useful, and `otp` is the very thing the agent
34
+ #: is after. None of the three below is ever useful to a model.
35
+ SECRET_NAME_PARTS = ("apikey", "secret", "password")
36
+
37
+
38
+ def _normalise(key: Any) -> str:
39
+ return str(key).replace("_", "").replace("-", "").lower()
40
+
41
+
42
+ def _is_secret(key: Any) -> bool:
43
+ name = _normalise(key)
44
+ return any(part in name for part in SECRET_NAME_PARTS)
45
+
46
+
47
+ def redact_secrets(value: Any) -> Any:
48
+ """Return a copy of `value` with secret fields removed. The input is not modified.
49
+
50
+ dict → secret keys dropped, remaining values cleaned recursively
51
+ list/tuple → every element cleaned (returned as a list; JSON carries a list anyway)
52
+ anything else → returned unchanged
53
+ """
54
+ if isinstance(value, dict):
55
+ return {k: redact_secrets(v) for k, v in value.items() if not _is_secret(k)}
56
+ if isinstance(value, (list, tuple)):
57
+ return [redact_secrets(v) for v in value]
58
+ return value
@@ -1,50 +0,0 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "mailflat"
7
- # Sürüm TEK KAYNAKTAN okunur: src/mailflat/__init__.py::__version__ (bkz. [tool.hatch.version]).
8
- # Buraya elle bir `version = ...` yazmak B-052'yi geri getirir: 0.3.1 yayımlanmışken paketin
9
- # kendisi `mailflat.__version__ == "0.3.0"` diyordu. İki kaynak = eninde sonunda ayrışma.
10
- dynamic = ["version"]
11
- description = "Official Python SDK for MailFlat — disposable, automation-friendly email inboxes with one-line OTP retrieval."
12
- readme = "README.md"
13
- requires-python = ">=3.8"
14
- license = { text = "MIT" }
15
- authors = [{ name = "MailFlat", email = "support@mailflat.net" }]
16
- keywords = ["email", "disposable-email", "otp", "automation", "testing", "ai-agents", "temporary-email"]
17
- classifiers = [
18
- "Development Status :: 4 - Beta",
19
- "Intended Audience :: Developers",
20
- "License :: OSI Approved :: MIT License",
21
- "Programming Language :: Python :: 3",
22
- "Programming Language :: Python :: 3 :: Only",
23
- "Topic :: Communications :: Email",
24
- "Topic :: Software Development :: Testing",
25
- "Typing :: Typed",
26
- ]
27
- # ÜST SINIR: httpx henüz 1.0 ÖNCESİ ve 1.0 ilan edilmiş bir kırılma eşiği. Sınırsız bırakmak
28
- # B-047'nin (mcp>=1.2 → mcp 2.0.0 modülü kaldırdı → uvx mailflat-mcp çöktü) aynısına davetiye.
29
- # Bugün 0.28.1 ile sorunsuz; sınır 1.0 çıkınca bilinçli olarak yükseltilecek.
30
- dependencies = ["httpx>=0.24,<1"]
31
-
32
- [project.urls]
33
- Homepage = "https://mailflat.net"
34
- # /docs prod'da kapalı (FastAPI Swagger yüzeyi, bkz. app/main.py::_openapi_urls).
35
- # Public doküman sayfası açılınca burası oraya güncellenecek.
36
- Documentation = "https://mailflat.net"
37
- Source = "https://github.com/onderyentar/mailflat"
38
-
39
- [project.optional-dependencies]
40
- langchain = ["langchain-core>=0.2"]
41
- dev = ["pytest>=7", "respx>=0.20", "langchain-core>=0.2"]
42
-
43
- [tool.hatch.version]
44
- path = "src/mailflat/__init__.py"
45
-
46
- [tool.hatch.build.targets.wheel]
47
- packages = ["src/mailflat"]
48
-
49
- [tool.hatch.build.targets.sdist]
50
- include = ["src/mailflat", "README.md", "LICENSE"]
@@ -1,60 +0,0 @@
1
- """Strips secret fields from agent tool output (B-055).
2
-
3
- Neden var: `create_inbox` ve `list_inboxes` backend yanıtını olduğu gibi döndürüyordu ve o
4
- yanıtta per-inbox `api_key` (`mf_sk_…`) vardı. Araç çıktısı **model bağlamına** giriyor;
5
- oradan prompt loglarına, trace araçlarına (LangSmith, AI SDK telemetry), hata raporlarına ve
6
- modelin sonraki cevaplarına geçiyor. `list_inboxes` tek çağrıda hesabın **tüm** inbox
7
- anahtarlarını döküyordu.
8
-
9
- Ayrım bilinçli:
10
- - **SDK = kod yüzeyi.** `Inbox.api_key` duruyor; insanın yazdığı kod onu kullanabilir.
11
- - **Tool = model yüzeyi.** Buradan geçen her şey redakte edilir.
12
-
13
- Yöntem **isim bazlı**, değer bazlı değil: mesaj gövdesini tarayıp `mf_` ile başlayan
14
- string'leri silmek, kullanıcının kendi test anahtarını konu ettiği bir maili bozardı. Alan
15
- adına bakmak hem kesin hem zararsız.
16
-
17
- NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
18
-
19
- Connected to:
20
- - imports from: —
21
- - imported by: mailflat.langchain, mailflat's own tests. AYRICA repo içinde
22
- `packages/mcp` (mailflat-mcp, ayrı PyPI paketi) buradan import eder — bu paketin
23
- kendi içinde MCP yoktur, o yüzden referans repo düzeyindedir.
24
-
25
- Key exports:
26
- - `redact_secrets(value)` — copy of the input with secret fields removed
27
- - `SECRET_NAME_PARTS` — which field names count as secrets
28
- """
29
- from __future__ import annotations
30
-
31
- from typing import Any
32
-
33
- #: Normalize edilmiş alan adı bunlardan birini İÇERİYORSA alan atılır.
34
- #: Dar tutuldu: `token` bilerek YOK — domain `verify_token` gibi alanlar zararsız ve
35
- #: gerektiğinde işe yarar, `otp` ise ajanın asıl istediği şey. Buradaki üçünün hiçbiri
36
- #: modele bir fayda sağlamıyor.
37
- SECRET_NAME_PARTS = ("apikey", "secret", "password")
38
-
39
-
40
- def _normalise(key: Any) -> str:
41
- return str(key).replace("_", "").replace("-", "").lower()
42
-
43
-
44
- def _is_secret(key: Any) -> bool:
45
- name = _normalise(key)
46
- return any(part in name for part in SECRET_NAME_PARTS)
47
-
48
-
49
- def redact_secrets(value: Any) -> Any:
50
- """Return a copy of `value` with secret fields removed. The input is not modified.
51
-
52
- dict → secret keys dropped, remaining values cleaned recursively
53
- list/tuple → every element cleaned (returned as a list; JSON carries a list anyway)
54
- anything else → returned unchanged
55
- """
56
- if isinstance(value, dict):
57
- return {k: redact_secrets(v) for k, v in value.items() if not _is_secret(k)}
58
- if isinstance(value, (list, tuple)):
59
- return [redact_secrets(v) for v in value]
60
- return value
File without changes
File without changes
File without changes