mailflat 0.3.1__tar.gz → 0.4.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.3.1
3
+ Version: 0.4.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
@@ -85,20 +85,37 @@ for self-hosted / BYOD deployments. Supports use as a context manager (`with Mai
85
85
 
86
86
  ### `Inbox`
87
87
  - `.address` — the email address.
88
- - `.messages() -> list[Message]` — all messages, newest first.
89
- - `.latest() -> Message | None` — most recent message.
88
+ - `.messages(*, direction="in") -> list[Message]` — messages, newest first.
89
+ - `.latest(*, direction="in") -> Message | None` — most recent message.
90
90
  - `.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.
91
+ - `.wait_for_message(*, timeout=30, poll_interval=1.0, direction="in") -> Message` — poll until a message arrives.
92
92
  - `.send(to, *, subject="", body="", html=None) -> dict` — send a DKIM-signed email from this inbox.
93
+ - `.mark_read(message_id) -> dict` — mark a message read so later polls can skip it.
94
+ - `.burn() -> dict` — delete every message but keep the address.
95
+ - `.download_attachment(message_id, attachment_id) -> bytes` — fetch an attachment's bytes.
93
96
  - `.delete() -> dict` — delete the inbox and all its messages.
94
97
 
98
+ > **Reads return received mail by default.** `direction="out"` returns mail you sent from
99
+ > this address, `"all"` returns both. This matters for agent-to-agent flows: without it,
100
+ > `send()` followed by `wait_for_message()` returns your own outgoing message.
101
+
95
102
  ### `Message`
96
- `.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`, `.raw`.
103
+ `.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`,
104
+ `.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.raw`.
105
+
106
+ - `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
107
+ - `.attachments` — metadata; `msg.attachments[0].download()` fetches the bytes.
108
+ - `.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).
111
+ - `.mark_read()` / `.delete()` — act on this message directly.
97
112
 
98
113
  ## Errors
99
114
 
100
- All errors subclass `MailFlatError`: `AuthenticationError` (401), `PermissionError` (403),
101
- `NotFoundError` (404), `RateLimitError` (429), `APIError` (other), `OTPTimeoutError`
115
+ All errors subclass `MailFlatError`: `AuthenticationError` (401), `MailFlatPermissionError` (403,
116
+ still exported as `PermissionError` for compatibility — the new name no longer shadows the built-in),
117
+ `NotFoundError` (404), `RateLimitError` (429, carries `.retry_after` when the server sent one),
118
+ `APIError` (other), `OTPTimeoutError`
102
119
  (no OTP before timeout), `EncryptedInboxError` (the inbox is end-to-end encrypted, so the
103
120
  server cannot read its contents — use a non-encrypted inbox for agent automation).
104
121
 
@@ -56,20 +56,37 @@ 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
+ - `.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
+ - `.mark_read()` / `.delete()` — act on this message directly.
68
83
 
69
84
  ## Errors
70
85
 
71
- All errors subclass `MailFlatError`: `AuthenticationError` (401), `PermissionError` (403),
72
- `NotFoundError` (404), `RateLimitError` (429), `APIError` (other), `OTPTimeoutError`
86
+ All errors subclass `MailFlatError`: `AuthenticationError` (401), `MailFlatPermissionError` (403,
87
+ still exported as `PermissionError` for compatibility — the new name no longer shadows the built-in),
88
+ `NotFoundError` (404), `RateLimitError` (429, carries `.retry_after` when the server sent one),
89
+ `APIError` (other), `OTPTimeoutError`
73
90
  (no OTP before timeout), `EncryptedInboxError` (the inbox is end-to-end encrypted, so the
74
91
  server cannot read its contents — use a non-encrypted inbox for agent automation).
75
92
 
@@ -4,7 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mailflat"
7
- version = "0.3.1"
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"]
8
11
  description = "Official Python SDK for MailFlat — disposable, automation-friendly email inboxes with one-line OTP retrieval."
9
12
  readme = "README.md"
10
13
  requires-python = ">=3.8"
@@ -37,6 +40,9 @@ Source = "https://github.com/onderyentar/mailflat"
37
40
  langchain = ["langchain-core>=0.2"]
38
41
  dev = ["pytest>=7", "respx>=0.20", "langchain-core>=0.2"]
39
42
 
43
+ [tool.hatch.version]
44
+ path = "src/mailflat/__init__.py"
45
+
40
46
  [tool.hatch.build.targets.wheel]
41
47
  packages = ["src/mailflat"]
42
48
 
@@ -0,0 +1,55 @@
1
+ """MailFlat Python SDK — the official client for automation-friendly email inboxes.
2
+
3
+ `pip install mailflat` → `from mailflat import MailFlat`.
4
+
5
+ NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
6
+
7
+ Connected to:
8
+ - imports from: mailflat.client, mailflat.inbox, mailflat.errors
9
+ - imported by: user code, mailflat-mcp, mailflat.langchain
10
+
11
+ Key exports:
12
+ - `MailFlat` — client
13
+ - `Inbox`, `Message`, `Attachment` — domain types
14
+ - errors: `MailFlatError`, `AuthenticationError`, `MailFlatPermissionError`,
15
+ `NotFoundError`, `RateLimitError`, `APIError`, `OTPTimeoutError`, `EncryptedInboxError`
16
+ - `redact_secrets()` — strips secret fields from agent tool output (used by MCP/LangChain)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from .client import MailFlat
21
+ from .errors import (
22
+ APIError,
23
+ AuthenticationError,
24
+ EncryptedInboxError,
25
+ MailFlatError,
26
+ MailFlatPermissionError,
27
+ NotFoundError,
28
+ OTPTimeoutError,
29
+ PermissionError,
30
+ RateLimitError,
31
+ )
32
+ from .inbox import Attachment, Inbox, Message
33
+ from .redact import redact_secrets
34
+
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"
38
+
39
+ __all__ = [
40
+ "MailFlat",
41
+ "Inbox",
42
+ "Message",
43
+ "Attachment",
44
+ "MailFlatError",
45
+ "AuthenticationError",
46
+ "MailFlatPermissionError",
47
+ "PermissionError", # deprecated alias of MailFlatPermissionError
48
+ "NotFoundError",
49
+ "RateLimitError",
50
+ "APIError",
51
+ "OTPTimeoutError",
52
+ "EncryptedInboxError",
53
+ "redact_secrets",
54
+ "__version__",
55
+ ]
@@ -1,7 +1,9 @@
1
- """MailFlat client — /api/v1 otomasyon API'sinin ince, tiplenmiş Python sarmalayıcısı.
1
+ """MailFlat client — a thin, typed Python wrapper around the /api/v1 automation API.
2
2
 
3
- Tek hesap API key'i (X-API-Key) ile inbox açar, listeler, mail/OTP okur ve mail gönderir.
4
- Tüm üst katmanlar (MCP, LangChain) bu client'ı çağırır → tek kaynak.
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
+ NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
5
7
 
6
8
  Connected to:
7
9
  - imports from: mailflat.inbox, mailflat.errors (+ httpx)
@@ -9,12 +11,13 @@ Connected to:
9
11
 
10
12
  Key exports:
11
13
  - `MailFlat(api_key=..., base_url=...)` — client
12
- - `.create(...) / .create_inbox(...)` — yeni inbox → `Inbox`
13
- - `.list()` — `Inbox` listesi
14
- - `.inbox(address)` — mevcut adrese bağlan
14
+ - `.create(...) / .create_inbox(...)` — new inbox → `Inbox`
15
+ - `.list()` — list of `Inbox`
16
+ - `.inbox(address)` — attach to an existing address
15
17
  """
16
18
  from __future__ import annotations
17
19
 
20
+ import logging
18
21
  import os
19
22
  import time
20
23
  from typing import Any
@@ -27,9 +30,34 @@ from .inbox import Inbox
27
30
  DEFAULT_BASE_URL = "https://mailflat.net"
28
31
  _RETRY_STATUSES = {429, 502, 503, 504}
29
32
 
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ış.
36
+ _IDEMPOTENT_METHODS = {"GET", "HEAD", "DELETE"}
37
+
38
+ _MAX_BACKOFF = 8.0
39
+
40
+ logger = logging.getLogger("mailflat")
41
+
42
+
43
+ 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
+ """
49
+ raw = resp.headers.get("Retry-After")
50
+ if not raw:
51
+ return None
52
+ try:
53
+ return max(0.0, float(raw.strip()))
54
+ except (TypeError, ValueError):
55
+ # HTTP-date biçimi de geçerli ama nadir; tahmin etmektense kendi backoff'umuza düşeriz.
56
+ return None
57
+
30
58
 
31
59
  class MailFlat:
32
- """MailFlat otomasyon API client'ı.
60
+ """MailFlat automation API client.
33
61
 
34
62
  >>> from mailflat import MailFlat
35
63
  >>> mf = MailFlat(api_key="mf_live_...")
@@ -59,10 +87,21 @@ class MailFlat:
59
87
  # http_client enjeksiyonu testleri (httpx.MockTransport) kolaylaştırır.
60
88
  self._http = http_client or httpx.Client(
61
89
  base_url=self.base_url,
62
- headers={"X-API-Key": api_key},
90
+ headers={"X-API-Key": api_key, "User-Agent": self.user_agent()},
63
91
  timeout=timeout,
64
92
  )
65
93
 
94
+ @staticmethod
95
+ def user_agent() -> str:
96
+ """Return the `User-Agent` this client sends, e.g. `mailflat-python/0.4.0`.
97
+
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.
100
+ """
101
+ from . import __version__
102
+
103
+ return f"mailflat-python/{__version__}"
104
+
66
105
  # --------------------------------------------------------------- context
67
106
  def __enter__(self) -> "MailFlat":
68
107
  return self
@@ -82,9 +121,19 @@ class MailFlat:
82
121
  except httpx.HTTPError as exc: # ağ/timeout
83
122
  raise MailFlatError(f"Network error: {exc}") from exc
84
123
 
85
- if resp.status_code in _RETRY_STATUSES and attempt < self.max_retries:
124
+ retry_after = _retry_after_seconds(resp)
125
+ retriable = (
126
+ resp.status_code in _RETRY_STATUSES
127
+ and method.upper() in _IDEMPOTENT_METHODS
128
+ and attempt < self.max_retries
129
+ )
130
+ if retriable:
86
131
  attempt += 1
87
- time.sleep(min(2 ** attempt * 0.5, 4.0)) # 1s, 2s, ... backoff
132
+ delay = retry_after if retry_after is not None else min(2 ** attempt * 0.5, _MAX_BACKOFF)
133
+ delay = min(delay, _MAX_BACKOFF)
134
+ logger.debug("retrying %s %s after %s (status %s, attempt %s/%s)",
135
+ method, path, delay, resp.status_code, attempt, self.max_retries)
136
+ time.sleep(delay)
88
137
  continue
89
138
 
90
139
  try:
@@ -96,7 +145,8 @@ class MailFlat:
96
145
  detail = ""
97
146
  if isinstance(data, dict):
98
147
  detail = data.get("detail") or data.get("error") or ""
99
- raise_for_status(resp.status_code, detail)
148
+ logger.debug("%s %s failed with %s: %s", method, path, resp.status_code, detail)
149
+ raise_for_status(resp.status_code, detail, retry_after=retry_after)
100
150
 
101
151
  if isinstance(data, dict) and data.get("error"): # güvenlik: 200 + error
102
152
  raise MailFlatError(data["error"], status_code=resp.status_code)
@@ -105,12 +155,29 @@ class MailFlat:
105
155
  def _get(self, path: str) -> dict[str, Any]:
106
156
  return self._request("GET", path)
107
157
 
108
- def _post(self, path: str, json: dict[str, Any]) -> dict[str, Any]:
109
- return self._request("POST", path, json=json)
158
+ def _post(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
159
+ return self._request("POST", path, json=json or {})
110
160
 
111
161
  def _delete(self, path: str) -> dict[str, Any]:
112
162
  return self._request("DELETE", path)
113
163
 
164
+ 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
+ if resp.status_code >= 400:
171
+ detail = ""
172
+ try:
173
+ body = resp.json()
174
+ if isinstance(body, dict):
175
+ detail = body.get("detail") or body.get("error") or ""
176
+ except ValueError:
177
+ pass
178
+ raise_for_status(resp.status_code, detail, retry_after=_retry_after_seconds(resp))
179
+ return resp
180
+
114
181
  # --------------------------------------------------------------- public
115
182
  def create(
116
183
  self,
@@ -121,13 +188,13 @@ class MailFlat:
121
188
  domain: str | None = None,
122
189
  retention_hours: int | None = None,
123
190
  ) -> Inbox:
124
- """Yeni bir disposable inbox açar ve `Inbox` döndürür.
191
+ """Create a new inbox and return it as an `Inbox`.
125
192
 
126
- - prefix: adresin local kısmı (boşsa rastgele `agent-...`)
127
- - label: inbox'a verilen isim (UI'da görünür; bazı planlarda yok sayılır)
128
- - subdomain: mailflat.net alt alanı (boşsa rastgele)
129
- - domain: BYOD doğrulanmış kendi domain'in (örn. acme.com)
130
- - retention_hours: mesaj saklama süresi (boş → plan varsayılanı; plan max'ı ile sınırlı)
193
+ - prefix: local part of the address (random `agent-...` when omitted)
194
+ - label: display name shown in the dashboard
195
+ - subdomain: mailflat.net subdomain (random when omitted)
196
+ - domain: one of your own verified BYOD domains, e.g. `acme.com`
197
+ - retention_hours: how long messages are kept (defaults to your plan, capped by it)
131
198
  """
132
199
  payload: dict[str, Any] = {}
133
200
  if prefix is not None:
@@ -147,7 +214,7 @@ class MailFlat:
147
214
  create_inbox = create
148
215
 
149
216
  def list(self) -> list[Inbox]:
150
- """Bu API key ile açılmış (agent) inbox'ları döndürür."""
217
+ """Return the inboxes created with this API key."""
151
218
  res = self._get("/api/v1/inboxes")
152
219
  return [
153
220
  Inbox(self, i["address"], **{k: v for k, v in i.items() if k != "address"})
@@ -155,5 +222,5 @@ class MailFlat:
155
222
  ]
156
223
 
157
224
  def inbox(self, address: str) -> Inbox:
158
- """Var olan bir adrese (API çağrısı yapmadan) bağlan."""
225
+ """Attach to an existing address without making an API call."""
159
226
  return Inbox(self, address)
@@ -0,0 +1,87 @@
1
+ """MailFlat SDK error types — maps HTTP status codes to meaningful Python exceptions.
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
+ Connected to:
7
+ - imported by: mailflat.client, mailflat.inbox, mailflat.__init__
8
+
9
+ Key exports:
10
+ - `MailFlatError` — base class for every SDK error
11
+ - `AuthenticationError` — 401 (missing/invalid API key)
12
+ - `MailFlatPermissionError` — 403 (key does not own this inbox)
13
+ - `NotFoundError` — 404
14
+ - `RateLimitError` — 429 (carries `retry_after` when the server sent it)
15
+ - `APIError` — any other 4xx/5xx
16
+ - `OTPTimeoutError` — wait_for_otp / wait_for_message timed out
17
+ - `EncryptedInboxError` — end-to-end encrypted inbox cannot be read through the API
18
+ """
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)
@@ -0,0 +1,321 @@
1
+ """Inbox + Message — high-level operations on one MailFlat inbox and its messages.
2
+
3
+ `Inbox` is returned by the `MailFlat` client and carries the read, wait, send and delete
4
+ methods. `Message` is the typed form of an /api/v1 email payload.
5
+
6
+ NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
7
+
8
+ Connected to:
9
+ - imports from: mailflat.errors
10
+ - imported by: mailflat.client, mailflat.__init__
11
+
12
+ Key exports:
13
+ - `Inbox` — `.address`, `.messages()`, `.latest()`, `.wait_for_message()`,
14
+ `.wait_for_otp()`, `.send()`, `.mark_read()`, `.burn()`, `.download_attachment()`
15
+ - `Message` — `.otp`, `.subject`, `.text`, `.html`, `.links`, `.attachments`, `.spam`, ...
16
+ - `Attachment` — `.filename`, `.size_bytes`, `.download()`
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ from .errors import EncryptedInboxError, MailFlatError, OTPTimeoutError
25
+
26
+ if TYPE_CHECKING: # döngüsel import'tan kaçın (sadece tip ipucu)
27
+ from .client import MailFlat
28
+
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.
31
+ DIRECTIONS = ("in", "out", "all")
32
+
33
+
34
+ def _check_direction(direction: str) -> str:
35
+ if direction not in DIRECTIONS:
36
+ raise ValueError(f"direction must be one of {DIRECTIONS!r}, got {direction!r}")
37
+ return direction
38
+
39
+
40
+ @dataclass
41
+ class Attachment:
42
+ """Attachment metadata. The bytes are fetched on demand with `.download()`.
43
+
44
+ İçerik listede GELMEZ: tek ekli 5 MB'lık bir mail, mesaj listesini kullanılamaz yapardı.
45
+ """
46
+
47
+ id: int | None = None
48
+ filename: str | None = None
49
+ content_type: str | None = None
50
+ size_bytes: int | None = None
51
+ is_encrypted: bool = False
52
+ truncated: bool = False # True → file exceeded the storage cap; bytes were never stored
53
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
54
+ _message: "Message | None" = field(default=None, repr=False, compare=False)
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict[str, Any]) -> "Attachment":
58
+ return cls(
59
+ id=data.get("id"),
60
+ filename=data.get("filename"),
61
+ content_type=data.get("content_type"),
62
+ size_bytes=data.get("size_bytes"),
63
+ is_encrypted=bool(data.get("is_encrypted")),
64
+ truncated=bool(data.get("truncated")),
65
+ raw=data,
66
+ )
67
+
68
+ def download(self) -> bytes:
69
+ """Download this attachment's bytes.
70
+
71
+ Raises `MailFlatError` when the attachment is not attached to a message, when the
72
+ file was too large to store (`truncated`), or when the inbox is end-to-end encrypted
73
+ (the server cannot decrypt it).
74
+ """
75
+ if self._message is None or self._message._inbox is None or self.id is None:
76
+ raise MailFlatError(
77
+ "This attachment isn't attached to a message. "
78
+ "Use inbox.download_attachment(message_id, attachment_id) instead."
79
+ )
80
+ if self.truncated:
81
+ raise MailFlatError(f"Attachment {self.filename!r} was too large to store")
82
+ return self._message._inbox.download_attachment(self._message.id, self.id)
83
+
84
+
85
+ @dataclass
86
+ class Message:
87
+ """A single email — the typed form of the /api/v1 payload."""
88
+
89
+ id: int | None = None
90
+ sender: str | None = None
91
+ subject: str | None = None
92
+ text: str | None = None # body_text
93
+ html: str | None = None # body_html
94
+ otp: str | None = None # otp_code (when the server extracted one)
95
+ tag: str | None = None
96
+ to_address: str | None = None
97
+ is_encrypted: bool = False
98
+ direction: str | None = None # "in" | "out"
99
+ is_read: bool = False
100
+ send_status: str | None = None
101
+ send_error: str | None = None
102
+ received_at: str | None = None
103
+ # URLs found in the body (HTML hrefs first, then plain text). Verification-link flows
104
+ # read this instead of parsing the body themselves.
105
+ links: list[str] = field(default_factory=list)
106
+ 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.
109
+ spam: dict[str, Any] | None = None
110
+ # Raw message headers (Message-ID lives here — needed for threading and dedup).
111
+ # None on encrypted inboxes: headers carry the Subject, so they go inside the envelope.
112
+ headers: dict[str, Any] | None = None
113
+ 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.
115
+ _inbox: "Inbox | None" = field(default=None, repr=False, compare=False)
116
+
117
+ def delete(self) -> dict[str, Any]:
118
+ """Delete this message (the inbox itself stays)."""
119
+ if self._inbox is None or self.id is None:
120
+ raise ValueError(
121
+ "This message isn't attached to an inbox (or has no id). "
122
+ "Use inbox.delete_message(message_id) instead."
123
+ )
124
+ return self._inbox.delete_message(self.id)
125
+
126
+ def mark_read(self) -> dict[str, Any]:
127
+ """Mark this message as read, so the next poll can skip it."""
128
+ if self._inbox is None or self.id is None:
129
+ raise ValueError(
130
+ "This message isn't attached to an inbox (or has no id). "
131
+ "Use inbox.mark_read(message_id) instead."
132
+ )
133
+ return self._inbox.mark_read(self.id)
134
+
135
+ @classmethod
136
+ def from_dict(cls, data: dict[str, Any]) -> "Message":
137
+ msg = cls(
138
+ id=data.get("id"),
139
+ sender=data.get("sender"),
140
+ subject=data.get("subject"),
141
+ text=data.get("body_text"),
142
+ html=data.get("body_html"),
143
+ otp=data.get("otp_code"),
144
+ tag=data.get("tag"),
145
+ to_address=data.get("to_address"),
146
+ is_encrypted=bool(data.get("is_encrypted")),
147
+ direction=data.get("direction"),
148
+ is_read=bool(data.get("is_read")),
149
+ send_status=data.get("send_status"),
150
+ send_error=data.get("send_error"),
151
+ received_at=data.get("received_at"),
152
+ links=list(data.get("links") or []),
153
+ spam=data.get("spam"),
154
+ headers=data.get("headers"),
155
+ raw=data,
156
+ )
157
+ msg.attachments = [Attachment.from_dict(a) for a in (data.get("attachments") or [])]
158
+ for att in msg.attachments:
159
+ att._message = msg
160
+ return msg
161
+
162
+
163
+ class Inbox:
164
+ """Operations on a single MailFlat inbox.
165
+
166
+ Obtained from `MailFlat.create()` / `.list()` / `.inbox(address)` rather than
167
+ constructed directly.
168
+ """
169
+
170
+ def __init__(self, client: "MailFlat", address: str, **meta: Any) -> None:
171
+ self._client = client
172
+ self.address: str = address
173
+ # create/list yanıtından gelen ek alanlar (varsa)
174
+ self.name: str | None = meta.get("name")
175
+ self.api_key: str | None = meta.get("api_key") # per-inbox key (mf_sk_...)
176
+ self.retention_hours: int | None = meta.get("retention_hours")
177
+ self.encrypted: bool = bool(meta.get("encrypted"))
178
+ self.via_api: bool | None = meta.get("via_api")
179
+ 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.
181
+ self.raw: dict[str, Any] = {"address": address, **meta}
182
+
183
+ def __repr__(self) -> str:
184
+ return f"<Inbox {self.address!r}>"
185
+
186
+ # ----------------------------------------------------------------- okuma
187
+ 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)."""
189
+ m = Message.from_dict(data)
190
+ m._inbox = self
191
+ return m
192
+
193
+ def messages(self, *, direction: str = "in") -> list[Message]:
194
+ """Return this inbox's messages, newest first.
195
+
196
+ `direction` defaults to `"in"`: received mail only. Pass `"out"` for mail you sent
197
+ from this address, or `"all"` for both.
198
+ """
199
+ _check_direction(direction)
200
+ res = self._client._get(
201
+ f"/api/v1/inboxes/{self.address}/messages?direction={direction}")
202
+ return [self._wrap(e) for e in (res.get("emails") or [])]
203
+
204
+ def latest(self, *, direction: str = "in") -> Message | None:
205
+ """Return the most recent message, or None when the inbox is empty.
206
+
207
+ Defaults to received mail. Before 0.4.0 this also returned mail you had just sent,
208
+ which made `send()` followed by `wait_for_message()` return your own message.
209
+ """
210
+ _check_direction(direction)
211
+ res = self._client._get(
212
+ f"/api/v1/inboxes/{self.address}/latest?direction={direction}")
213
+ email = res.get("email")
214
+ return self._wrap(email) if email else None
215
+
216
+ def wait_for_message(
217
+ self, *, timeout: float = 30, poll_interval: float = 1.0, direction: str = "in"
218
+ ) -> Message:
219
+ """Poll until a message arrives and return it.
220
+
221
+ Only received mail counts by default, so an agent can `send()` to a peer and then
222
+ wait for the reply without immediately matching its own outgoing message.
223
+
224
+ Raises `OTPTimeoutError` on timeout, `EncryptedInboxError` on an E2E inbox.
225
+ """
226
+ _check_direction(direction)
227
+ deadline = time.monotonic() + max(0.0, timeout)
228
+ while True:
229
+ res = self._client._get(
230
+ f"/api/v1/inboxes/{self.address}/latest?direction={direction}")
231
+ if res.get("encrypted"):
232
+ raise EncryptedInboxError(
233
+ res.get("note") or "This inbox is end-to-end encrypted; "
234
+ "use a non-encrypted inbox for agent automation."
235
+ )
236
+ email = res.get("email")
237
+ if email:
238
+ return self._wrap(email)
239
+ if time.monotonic() >= deadline:
240
+ raise OTPTimeoutError(
241
+ f"No message arrived for {self.address} within {timeout}s"
242
+ )
243
+ time.sleep(poll_interval)
244
+
245
+ def wait_for_otp(self, *, timeout: float = 30, poll_interval: float = 1.0) -> str:
246
+ """Poll until an OTP arrives and return the code.
247
+
248
+ Raises `OTPTimeoutError` on timeout, `EncryptedInboxError` on an E2E inbox.
249
+ """
250
+ deadline = time.monotonic() + max(0.0, timeout)
251
+ while True:
252
+ res = self._client._get(f"/api/v1/inboxes/{self.address}/latest?direction=in")
253
+ if res.get("encrypted"):
254
+ raise EncryptedInboxError(
255
+ res.get("note") or "This inbox is end-to-end encrypted; "
256
+ "OTP cannot be read via the API."
257
+ )
258
+ email = res.get("email") or {}
259
+ otp = email.get("otp_code")
260
+ if otp:
261
+ return otp
262
+ if time.monotonic() >= deadline:
263
+ raise OTPTimeoutError(
264
+ f"No OTP arrived for {self.address} within {timeout}s"
265
+ )
266
+ time.sleep(poll_interval)
267
+
268
+ def download_attachment(self, message_id: int, attachment_id: int) -> bytes:
269
+ """Download one attachment's bytes.
270
+
271
+ Metadata already ships with each message (`msg.attachments`); this fetches the file
272
+ itself. Prefer `msg.attachments[0].download()`.
273
+ """
274
+ resp = self._client._get_bytes(
275
+ f"/api/v1/inboxes/{self.address}/messages/{message_id}/attachments/{attachment_id}"
276
+ )
277
+ ctype = resp.headers.get("content-type", "")
278
+ if ctype.startswith("application/json"):
279
+ # Şifreli kutuda sunucu çözemez → zarf JSON'u döner, baytları değil.
280
+ raise EncryptedInboxError(
281
+ "This inbox is end-to-end encrypted; the server cannot decrypt this attachment."
282
+ )
283
+ return resp.content
284
+
285
+ # ----------------------------------------------------------------- yazma
286
+ def send(
287
+ self, to: str, *, subject: str = "", body: str = "", html: str | None = None
288
+ ) -> dict[str, Any]:
289
+ """Send mail from this inbox's address (DKIM-signed, through our own MTA).
290
+
291
+ Not retried on gateway errors: a retried send can deliver the same mail twice.
292
+ """
293
+ payload: dict[str, Any] = {"to": to, "subject": subject, "body": body}
294
+ if html is not None:
295
+ payload["html"] = html
296
+ return self._client._post(f"/api/v1/inboxes/{self.address}/send", payload)
297
+
298
+ def mark_read(self, message_id: int) -> dict[str, Any]:
299
+ """Mark one message as read.
300
+
301
+ Lets an agent poll for *new* mail instead of re-reading the whole inbox and
302
+ tracking state on its own.
303
+ """
304
+ return self._client._post(
305
+ f"/api/v1/inboxes/{self.address}/messages/{message_id}/read")
306
+
307
+ def burn(self) -> dict[str, Any]:
308
+ """Delete every message in this inbox and keep the address.
309
+
310
+ Useful between test scenarios: the address stays registered wherever you used it.
311
+ """
312
+ return self._client._post(f"/api/v1/inboxes/{self.address}/burn")
313
+
314
+ def delete(self) -> dict[str, Any]:
315
+ """Delete this inbox and all of its messages. Cannot be undone."""
316
+ return self._client._delete(f"/api/v1/inboxes/{self.address}")
317
+
318
+ def delete_message(self, message_id: int) -> dict[str, Any]:
319
+ """Delete a single message; the inbox stays. Backs `Message.delete()`."""
320
+ return self._client._delete(
321
+ f"/api/v1/inboxes/{self.address}/messages/{message_id}")
@@ -1,17 +1,23 @@
1
- """MailFlat LangChain entegrasyonu — `MailFlatToolkit` + 6 araçlık LangChain tool seti.
1
+ """MailFlat LangChain integration — `MailFlatToolkit` plus a 9-tool LangChain tool set.
2
2
 
3
- `mailflat` SDK'sının üstüne ince bir LangChain kabuğu; tüm HTTP/iş mantığı client'ta (DRY).
4
- `langchain-core` opsiyonel bağımlılıktır → `pip install mailflat[langchain]`.
3
+ A thin LangChain shell over the `mailflat` SDK; all HTTP and behaviour live in the client.
4
+ `langchain-core` is an optional dependency → `pip install mailflat[langchain]`.
5
5
 
6
- Kullanım:
6
+ NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
7
+
8
+ Usage:
7
9
  from mailflat.langchain import MailFlatToolkit
8
10
  toolkit = MailFlatToolkit(api_key=env("MAILFLAT_KEY"))
9
- tools = toolkit.get_tools() # create_inbox, list_inboxes, read_messages,
10
- # wait_for_otp, send_email, delete_inbox
11
+ tools = toolkit.get_tools() # create_inbox, list_inboxes, read_messages, wait_for_otp,
12
+ # wait_for_message, send_email, mark_read, burn_inbox,
13
+ # delete_inbox (delete_message bilerek YOK)
14
+
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.
11
17
 
12
18
  Connected to:
13
- - imports from: mailflat.client (MailFlat), langchain_core.tools (StructuredTool)
14
- - imported by: kullanıcı kodu (LangChain ajanları)
19
+ - imports from: mailflat.client (MailFlat), mailflat.redact, langchain_core.tools
20
+ - imported by: user code (LangChain agents)
15
21
 
16
22
  Key exports:
17
23
  - `MailFlatToolkit(api_key=..., base_url=...)` — `.get_tools()` → list[BaseTool]
@@ -30,13 +36,14 @@ except ImportError as exc: # langchain-core kurulu değil → açıklayıcı ha
30
36
 
31
37
  from .client import DEFAULT_BASE_URL, MailFlat
32
38
  from .errors import EncryptedInboxError, MailFlatError, OTPTimeoutError
39
+ from .redact import redact_secrets
33
40
 
34
41
  if TYPE_CHECKING:
35
42
  from collections.abc import Sequence
36
43
 
37
44
 
38
45
  class MailFlatToolkit:
39
- """MailFlat araçlarını bir LangChain ajanına bağlamak için toolkit.
46
+ """Toolkit that plugs MailFlat's tools into a LangChain agent.
40
47
 
41
48
  >>> from mailflat.langchain import MailFlatToolkit
42
49
  >>> toolkit = MailFlatToolkit(api_key="mf_live_...")
@@ -55,7 +62,7 @@ class MailFlatToolkit:
55
62
 
56
63
  # ------------------------------------------------------------------ tools
57
64
  def get_tools(self) -> "Sequence[BaseTool]":
58
- """6 MailFlat aracını LangChain `BaseTool` listesi olarak döndürür."""
65
+ """Return every MailFlat tool as a list of LangChain `BaseTool`s."""
59
66
  client = self._client
60
67
 
61
68
  def create_inbox(prefix: str = "", label: str = "", retention_hours: int = 0) -> dict:
@@ -73,25 +80,30 @@ class MailFlatToolkit:
73
80
  label=label or None,
74
81
  retention_hours=retention_hours if retention_hours and retention_hours > 0 else None,
75
82
  )
76
- return inbox.raw
83
+ return redact_secrets(inbox.raw)
77
84
  except MailFlatError as e:
78
85
  return {"error": str(e)}
79
86
 
80
87
  def list_inboxes() -> dict:
81
88
  """List all inboxes available to this API key."""
82
89
  try:
83
- return {"ok": True, "inboxes": [i.raw for i in client.list()]}
90
+ return redact_secrets({"ok": True, "inboxes": [i.raw for i in client.list()]})
84
91
  except MailFlatError as e:
85
92
  return {"error": str(e)}
86
93
 
87
- def read_messages(address: str) -> dict:
88
- """Read all messages in the given inbox address (newest first).
94
+ def read_messages(address: str, direction: str = "in") -> dict:
95
+ """Read messages in the given inbox address (newest first).
89
96
 
90
97
  Args:
91
98
  address: The full inbox address, e.g. signup-8f3@mailflat.net.
99
+ direction: 'in' for received mail (default), 'out' for mail you sent from
100
+ this address, 'all' for both.
92
101
  """
93
102
  try:
94
- return {"ok": True, "emails": [m.raw for m in client.inbox(address).messages()]}
103
+ msgs = client.inbox(address).messages(direction=direction)
104
+ return redact_secrets({"ok": True, "emails": [m.raw for m in msgs]})
105
+ except ValueError as e: # geçersiz direction — ağa çıkmadan
106
+ return {"error": str(e)}
95
107
  except MailFlatError as e:
96
108
  return {"error": str(e)}
97
109
 
@@ -106,7 +118,7 @@ class MailFlatToolkit:
106
118
  inbox = client.inbox(address)
107
119
  otp = inbox.wait_for_otp(timeout=timeout)
108
120
  latest = inbox.latest()
109
- return {"otp_code": otp, "email": latest.raw if latest else None}
121
+ return redact_secrets({"otp_code": otp, "email": latest.raw if latest else None})
110
122
  except OTPTimeoutError:
111
123
  return {"otp_code": None, "error": "timeout"}
112
124
  except EncryptedInboxError as e:
@@ -114,6 +126,26 @@ class MailFlatToolkit:
114
126
  except MailFlatError as e:
115
127
  return {"otp_code": None, "error": str(e)}
116
128
 
129
+ def wait_for_message(address: str, timeout: int = 30) -> dict:
130
+ """Poll the inbox until a NEW message arrives, then return it.
131
+
132
+ Only received mail counts, so you can send to a peer and wait for their reply
133
+ without matching your own outgoing message.
134
+
135
+ Args:
136
+ address: The inbox address to poll.
137
+ timeout: Maximum seconds to wait before giving up (default 30).
138
+ """
139
+ try:
140
+ msg = client.inbox(address).wait_for_message(timeout=timeout)
141
+ return redact_secrets({"ok": True, "email": msg.raw})
142
+ except OTPTimeoutError:
143
+ return {"email": None, "error": "timeout"}
144
+ except EncryptedInboxError as e:
145
+ return {"email": None, "encrypted": True, "error": str(e)}
146
+ except MailFlatError as e:
147
+ return {"email": None, "error": str(e)}
148
+
117
149
  def send_email(
118
150
  address: str, to: str, subject: str = "", body: str = "", html: str = ""
119
151
  ) -> dict:
@@ -127,7 +159,33 @@ class MailFlatToolkit:
127
159
  html: Optional HTML body.
128
160
  """
129
161
  try:
130
- return client.inbox(address).send(to, subject=subject, body=body, html=html or None)
162
+ return redact_secrets(client.inbox(address).send(to, subject=subject, body=body, html=html or None))
163
+ except MailFlatError as e:
164
+ return {"error": str(e)}
165
+
166
+ def mark_read(address: str, message_id: int) -> dict:
167
+ """Mark one message as read so later polls can skip it.
168
+
169
+ Args:
170
+ address: The inbox address that holds the message.
171
+ message_id: The message id, as returned by read_messages.
172
+ """
173
+ try:
174
+ return redact_secrets(client.inbox(address).mark_read(message_id))
175
+ except MailFlatError as e:
176
+ return {"error": str(e)}
177
+
178
+ def burn_inbox(address: str) -> dict:
179
+ """Delete every message in an inbox but keep the address itself.
180
+
181
+ Use this to start a clean scenario without losing the address you already
182
+ registered somewhere.
183
+
184
+ Args:
185
+ address: The inbox address to empty.
186
+ """
187
+ try:
188
+ return redact_secrets(client.inbox(address).burn())
131
189
  except MailFlatError as e:
132
190
  return {"error": str(e)}
133
191
 
@@ -138,7 +196,7 @@ class MailFlatToolkit:
138
196
  address: The inbox address to delete.
139
197
  """
140
198
  try:
141
- return client.inbox(address).delete()
199
+ return redact_secrets(client.inbox(address).delete())
142
200
  except MailFlatError as e:
143
201
  return {"error": str(e)}
144
202
 
@@ -147,7 +205,10 @@ class MailFlatToolkit:
147
205
  list_inboxes,
148
206
  read_messages,
149
207
  wait_for_otp,
208
+ wait_for_message,
150
209
  send_email,
210
+ mark_read,
211
+ burn_inbox,
151
212
  delete_inbox,
152
213
  ]
153
214
  # from_function tip ipuçları + docstring'den args_schema'yı kendisi çıkarır
@@ -0,0 +1,60 @@
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
@@ -1,45 +0,0 @@
1
- """MailFlat Python SDK — disposable, otomasyon-dostu e-posta inbox'ları için resmi client.
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: kullanıcı kodu, mailflat-mcp, mailflat.langchain
8
-
9
- Key exports:
10
- - `MailFlat` — client
11
- - `Inbox`, `Message` — domain tipleri
12
- - hata tipleri: `MailFlatError`, `AuthenticationError`, `PermissionError`,
13
- `NotFoundError`, `RateLimitError`, `APIError`, `OTPTimeoutError`, `EncryptedInboxError`
14
- """
15
- from __future__ import annotations
16
-
17
- from .client import MailFlat
18
- from .errors import (
19
- APIError,
20
- AuthenticationError,
21
- EncryptedInboxError,
22
- MailFlatError,
23
- NotFoundError,
24
- OTPTimeoutError,
25
- PermissionError,
26
- RateLimitError,
27
- )
28
- from .inbox import Inbox, Message
29
-
30
- __version__ = "0.3.0"
31
-
32
- __all__ = [
33
- "MailFlat",
34
- "Inbox",
35
- "Message",
36
- "MailFlatError",
37
- "AuthenticationError",
38
- "PermissionError",
39
- "NotFoundError",
40
- "RateLimitError",
41
- "APIError",
42
- "OTPTimeoutError",
43
- "EncryptedInboxError",
44
- "__version__",
45
- ]
@@ -1,66 +0,0 @@
1
- """MailFlat SDK hata tipleri — HTTP durum kodlarını anlamlı Python exception'larına çevirir.
2
-
3
- Connected to:
4
- - imported by: mailflat.client, mailflat.inbox, mailflat.__init__
5
-
6
- Key exports:
7
- - `MailFlatError` — tüm SDK hatalarının tabanı
8
- - `AuthenticationError` — 401 (geçersiz/eksik API key)
9
- - `PermissionError` — 403 (key bu inbox'a sahip değil)
10
- - `NotFoundError` — 404
11
- - `RateLimitError` — 429
12
- - `APIError` — diğer 4xx/5xx
13
- - `OTPTimeoutError` — wait_for_otp süre aşımı
14
- - `EncryptedInboxError` — E2E inbox içeriği API'den okunamaz
15
- """
16
- from __future__ import annotations
17
-
18
-
19
- class MailFlatError(Exception):
20
- """Tüm MailFlat SDK hatalarının tabanı."""
21
-
22
- def __init__(self, message: str, *, status_code: int | None = None) -> None:
23
- super().__init__(message)
24
- self.message = message
25
- self.status_code = status_code
26
-
27
-
28
- class AuthenticationError(MailFlatError):
29
- """401 — API key eksik veya geçersiz."""
30
-
31
-
32
- class PermissionError(MailFlatError): # noqa: A001 - kasıtlı, SDK kapsamında net isim
33
- """403 — API key bu inbox'a sahip değil."""
34
-
35
-
36
- class NotFoundError(MailFlatError):
37
- """404 — kaynak bulunamadı."""
38
-
39
-
40
- class RateLimitError(MailFlatError):
41
- """429 — rate limit aşıldı."""
42
-
43
-
44
- class APIError(MailFlatError):
45
- """Beklenmeyen 4xx/5xx yanıtı."""
46
-
47
-
48
- class OTPTimeoutError(MailFlatError):
49
- """wait_for_otp verilen süre içinde OTP bulamadı."""
50
-
51
-
52
- class EncryptedInboxError(MailFlatError):
53
- """Inbox uçtan uca şifreli — sunucu içeriği okuyamaz, OTP/gövde API'den alınamaz."""
54
-
55
-
56
- def raise_for_status(status_code: int, detail: str) -> None:
57
- """HTTP durum kodunu uygun MailFlatError alt tipine çevirip fırlatır."""
58
- if status_code == 401:
59
- raise AuthenticationError(detail or "Invalid or missing API key", status_code=status_code)
60
- if status_code == 403:
61
- raise PermissionError(detail or "This API key does not own that inbox", status_code=status_code)
62
- if status_code == 404:
63
- raise NotFoundError(detail or "Not found", status_code=status_code)
64
- if status_code == 429:
65
- raise RateLimitError(detail or "Rate limit exceeded", status_code=status_code)
66
- raise APIError(detail or f"Request failed with status {status_code}", status_code=status_code)
@@ -1,181 +0,0 @@
1
- """Inbox + Message — bir MailFlat inbox'ı ve mesajları üzerindeki yüksek seviye işlemler.
2
-
3
- `Inbox`, `MailFlat` client tarafından döndürülür; mesaj okuma, OTP bekleme, gönderme ve
4
- silme metodlarını taşır. `Message`, /api/v1 e-posta yanıtının tiplenmiş hâlidir.
5
-
6
- Connected to:
7
- - imports from: mailflat.errors
8
- - imported by: mailflat.client, mailflat.__init__
9
-
10
- Key exports:
11
- - `Inbox` — `.address`, `.messages()`, `.latest()`, `.wait_for_otp()`, `.send()`, `.delete()`
12
- - `Message` — `.otp`, `.subject`, `.text`, `.html`, `.sender`, ...
13
- """
14
- from __future__ import annotations
15
-
16
- import time
17
- from dataclasses import dataclass, field
18
- from typing import TYPE_CHECKING, Any
19
-
20
- from .errors import EncryptedInboxError, OTPTimeoutError
21
-
22
- if TYPE_CHECKING: # döngüsel import'tan kaçın (sadece tip ipucu)
23
- from .client import MailFlat
24
-
25
-
26
- @dataclass
27
- class Message:
28
- """Tek bir e-posta — /api/v1 serialize çıktısının tiplenmiş hâli."""
29
-
30
- id: int | None = None
31
- sender: str | None = None
32
- subject: str | None = None
33
- text: str | None = None # body_text
34
- html: str | None = None # body_html
35
- otp: str | None = None # otp_code (sunucu çıkardıysa)
36
- tag: str | None = None
37
- to_address: str | None = None
38
- is_encrypted: bool = False
39
- direction: str | None = None # "in" | "out"
40
- is_read: bool = False
41
- send_status: str | None = None
42
- send_error: str | None = None
43
- received_at: str | None = None
44
- raw: dict[str, Any] = field(default_factory=dict, repr=False)
45
- # Bu mesajı döndüren Inbox (msg.delete() şekeri için). Inbox.messages()/latest()/wait_* doldurur.
46
- _inbox: "Inbox | None" = field(default=None, repr=False, compare=False)
47
-
48
- def delete(self) -> dict[str, Any]:
49
- """Bu maili sil (inbox kalır). Mesaj bir Inbox'tan geldiyse çalışır."""
50
- if self._inbox is None or self.id is None:
51
- raise ValueError(
52
- "This message isn't attached to an inbox (or has no id). "
53
- "Use inbox.delete_message(message_id) instead."
54
- )
55
- return self._inbox.delete_message(self.id)
56
-
57
- @classmethod
58
- def from_dict(cls, data: dict[str, Any]) -> "Message":
59
- return cls(
60
- id=data.get("id"),
61
- sender=data.get("sender"),
62
- subject=data.get("subject"),
63
- text=data.get("body_text"),
64
- html=data.get("body_html"),
65
- otp=data.get("otp_code"),
66
- tag=data.get("tag"),
67
- to_address=data.get("to_address"),
68
- is_encrypted=bool(data.get("is_encrypted")),
69
- direction=data.get("direction"),
70
- is_read=bool(data.get("is_read")),
71
- send_status=data.get("send_status"),
72
- send_error=data.get("send_error"),
73
- received_at=data.get("received_at"),
74
- raw=data,
75
- )
76
-
77
-
78
- class Inbox:
79
- """Tek bir disposable inbox üzerindeki işlemler.
80
-
81
- Doğrudan oluşturmak yerine `MailFlat.create()` / `.list()` / `.inbox(address)` ile alınır.
82
- """
83
-
84
- def __init__(self, client: "MailFlat", address: str, **meta: Any) -> None:
85
- self._client = client
86
- self.address: str = address
87
- # create/list yanıtından gelen ek alanlar (varsa)
88
- self.name: str | None = meta.get("name")
89
- self.api_key: str | None = meta.get("api_key") # per-inbox key (mf_sk_...)
90
- self.retention_hours: int | None = meta.get("retention_hours")
91
- self.encrypted: bool = bool(meta.get("encrypted"))
92
- self.via_api: bool | None = meta.get("via_api")
93
- self.created_at: str | None = meta.get("created_at")
94
- # backend yanıtının tamamı (Message.raw ile simetrik) — MCP/agent katmanı için.
95
- self.raw: dict[str, Any] = {"address": address, **meta}
96
-
97
- def __repr__(self) -> str:
98
- return f"<Inbox {self.address!r}>"
99
-
100
- # ----------------------------------------------------------------- okuma
101
- def _wrap(self, data: dict[str, Any]) -> Message:
102
- """API mail dict'ini Message'a çevirir + bu inbox ref'ini iliştirir (msg.delete() için)."""
103
- m = Message.from_dict(data)
104
- m._inbox = self
105
- return m
106
-
107
- def messages(self) -> list[Message]:
108
- """Inbox'taki tüm mesajları (yeniden eskiye) döndürür."""
109
- res = self._client._get(f"/api/v1/inboxes/{self.address}/messages")
110
- return [self._wrap(e) for e in (res.get("emails") or [])]
111
-
112
- def latest(self) -> Message | None:
113
- """En son mesajı döndürür (yoksa None)."""
114
- res = self._client._get(f"/api/v1/inboxes/{self.address}/latest")
115
- email = res.get("email")
116
- return self._wrap(email) if email else None
117
-
118
- def wait_for_message(
119
- self, *, timeout: float = 30, poll_interval: float = 1.0
120
- ) -> Message:
121
- """Yeni bir mesaj gelene kadar poll'lar; gelince `Message` döndürür.
122
-
123
- timeout aşılırsa `OTPTimeoutError`, inbox E2E şifreliyse `EncryptedInboxError`.
124
- """
125
- deadline = time.monotonic() + max(0.0, timeout)
126
- while True:
127
- res = self._client._get(f"/api/v1/inboxes/{self.address}/latest")
128
- if res.get("encrypted"):
129
- raise EncryptedInboxError(
130
- res.get("note") or "This inbox is end-to-end encrypted; "
131
- "use a non-encrypted inbox for agent automation."
132
- )
133
- email = res.get("email")
134
- if email:
135
- return self._wrap(email)
136
- if time.monotonic() >= deadline:
137
- raise OTPTimeoutError(
138
- f"No message arrived for {self.address} within {timeout}s"
139
- )
140
- time.sleep(poll_interval)
141
-
142
- def wait_for_otp(self, *, timeout: float = 30, poll_interval: float = 1.0) -> str:
143
- """OTP kodu gelene kadar poll'lar ve kodu (str) döndürür.
144
-
145
- timeout aşılırsa `OTPTimeoutError`, inbox E2E şifreliyse `EncryptedInboxError`.
146
- """
147
- deadline = time.monotonic() + max(0.0, timeout)
148
- while True:
149
- res = self._client._get(f"/api/v1/inboxes/{self.address}/latest")
150
- if res.get("encrypted"):
151
- raise EncryptedInboxError(
152
- res.get("note") or "This inbox is end-to-end encrypted; "
153
- "OTP cannot be read via the API."
154
- )
155
- email = res.get("email") or {}
156
- otp = email.get("otp_code")
157
- if otp:
158
- return otp
159
- if time.monotonic() >= deadline:
160
- raise OTPTimeoutError(
161
- f"No OTP arrived for {self.address} within {timeout}s"
162
- )
163
- time.sleep(poll_interval)
164
-
165
- # ----------------------------------------------------------------- yazma
166
- def send(
167
- self, to: str, *, subject: str = "", body: str = "", html: str | None = None
168
- ) -> dict[str, Any]:
169
- """Bu inbox adresinden mail gönderir (DKIM imzalı, kendi MTA'mız üzerinden)."""
170
- payload: dict[str, Any] = {"to": to, "subject": subject, "body": body}
171
- if html is not None:
172
- payload["html"] = html
173
- return self._client._post(f"/api/v1/inboxes/{self.address}/send", payload)
174
-
175
- def delete(self) -> dict[str, Any]:
176
- """Inbox'u ve tüm mesajlarını siler. Geri alınamaz."""
177
- return self._client._delete(f"/api/v1/inboxes/{self.address}")
178
-
179
- def delete_message(self, message_id: int) -> dict[str, Any]:
180
- """Inbox'taki TEK bir maili siler (inbox kalır). `Message.delete()` bunu çağırır."""
181
- return self._client._delete(f"/api/v1/inboxes/{self.address}/messages/{message_id}")
File without changes
File without changes
File without changes