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.
- {mailflat-0.4.0 → mailflat-0.5.0}/PKG-INFO +15 -4
- {mailflat-0.4.0 → mailflat-0.5.0}/README.md +7 -2
- mailflat-0.5.0/pyproject.toml +60 -0
- {mailflat-0.4.0 → mailflat-0.5.0}/src/mailflat/__init__.py +3 -5
- {mailflat-0.4.0 → mailflat-0.5.0}/src/mailflat/client.py +48 -28
- {mailflat-0.4.0 → mailflat-0.5.0}/src/mailflat/errors.py +4 -7
- {mailflat-0.4.0 → mailflat-0.5.0}/src/mailflat/inbox.py +39 -15
- {mailflat-0.4.0 → mailflat-0.5.0}/src/mailflat/langchain.py +8 -10
- mailflat-0.5.0/src/mailflat/redact.py +58 -0
- mailflat-0.4.0/pyproject.toml +0 -50
- mailflat-0.4.0/src/mailflat/redact.py +0 -60
- {mailflat-0.4.0 → mailflat-0.5.0}/.gitignore +0 -0
- {mailflat-0.4.0 → mailflat-0.5.0}/LICENSE +0 -0
- {mailflat-0.4.0 → mailflat-0.5.0}/src/mailflat/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailflat
|
|
3
|
-
Version: 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
__version__ = "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
|
-
#
|
|
34
|
-
# /send
|
|
35
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
93
|
+
"""Return the `User-Agent` this client sends, e.g. `mailflat-python/<version>`.
|
|
97
94
|
|
|
98
|
-
|
|
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
|
-
#
|
|
116
|
-
def _request(self, method: str, path: str, json: dict[str, Any] | None = None
|
|
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: #
|
|
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
|
|
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"): #
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# built-in
|
|
42
|
-
#
|
|
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: #
|
|
24
|
+
if TYPE_CHECKING: # avoid a circular import (type hint only)
|
|
27
25
|
from .client import MailFlat
|
|
28
26
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
|
|
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}
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
11
|
+
# delete_inbox (delete_message is deliberately absent)
|
|
14
12
|
|
|
15
|
-
⚠️
|
|
16
|
-
(
|
|
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
|
|
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
|
|
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: #
|
|
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
|
|
215
|
-
#
|
|
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
|
mailflat-0.4.0/pyproject.toml
DELETED
|
@@ -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
|