mailflat 0.3.3__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.
- {mailflat-0.3.3 → mailflat-0.4.0}/PKG-INFO +24 -7
- {mailflat-0.3.3 → mailflat-0.4.0}/README.md +23 -6
- {mailflat-0.3.3 → mailflat-0.4.0}/src/mailflat/__init__.py +13 -8
- {mailflat-0.3.3 → mailflat-0.4.0}/src/mailflat/client.py +88 -21
- mailflat-0.4.0/src/mailflat/errors.py +87 -0
- mailflat-0.4.0/src/mailflat/inbox.py +321 -0
- {mailflat-0.3.3 → mailflat-0.4.0}/src/mailflat/langchain.py +69 -12
- {mailflat-0.3.3 → mailflat-0.4.0}/src/mailflat/redact.py +12 -8
- mailflat-0.3.3/src/mailflat/errors.py +0 -66
- mailflat-0.3.3/src/mailflat/inbox.py +0 -181
- {mailflat-0.3.3 → mailflat-0.4.0}/.gitignore +0 -0
- {mailflat-0.3.3 → mailflat-0.4.0}/LICENSE +0 -0
- {mailflat-0.3.3 → mailflat-0.4.0}/pyproject.toml +0 -0
- {mailflat-0.3.3 → mailflat-0.4.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.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]` —
|
|
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
|
|
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`,
|
|
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), `
|
|
101
|
-
|
|
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]` —
|
|
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
|
|
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`,
|
|
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), `
|
|
72
|
-
|
|
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
|
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
"""MailFlat Python SDK —
|
|
1
|
+
"""MailFlat Python SDK — the official client for automation-friendly email inboxes.
|
|
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
|
+
|
|
5
7
|
Connected to:
|
|
6
8
|
- imports from: mailflat.client, mailflat.inbox, mailflat.errors
|
|
7
|
-
- imported by:
|
|
9
|
+
- imported by: user code, mailflat-mcp, mailflat.langchain
|
|
8
10
|
|
|
9
11
|
Key exports:
|
|
10
12
|
- `MailFlat` — client
|
|
11
|
-
- `Inbox`, `Message` — domain
|
|
12
|
-
-
|
|
13
|
+
- `Inbox`, `Message`, `Attachment` — domain types
|
|
14
|
+
- errors: `MailFlatError`, `AuthenticationError`, `MailFlatPermissionError`,
|
|
13
15
|
`NotFoundError`, `RateLimitError`, `APIError`, `OTPTimeoutError`, `EncryptedInboxError`
|
|
14
|
-
- `redact_secrets()` —
|
|
16
|
+
- `redact_secrets()` — strips secret fields from agent tool output (used by MCP/LangChain)
|
|
15
17
|
"""
|
|
16
18
|
from __future__ import annotations
|
|
17
19
|
|
|
@@ -21,25 +23,28 @@ from .errors import (
|
|
|
21
23
|
AuthenticationError,
|
|
22
24
|
EncryptedInboxError,
|
|
23
25
|
MailFlatError,
|
|
26
|
+
MailFlatPermissionError,
|
|
24
27
|
NotFoundError,
|
|
25
28
|
OTPTimeoutError,
|
|
26
29
|
PermissionError,
|
|
27
30
|
RateLimitError,
|
|
28
31
|
)
|
|
29
|
-
from .inbox import Inbox, Message
|
|
32
|
+
from .inbox import Attachment, Inbox, Message
|
|
30
33
|
from .redact import redact_secrets
|
|
31
34
|
|
|
32
35
|
# Paketin TEK sürüm kaynağı — pyproject.toml bunu [tool.hatch.version] ile okur.
|
|
33
36
|
# Yayın = burayı yükselt, başka hiçbir yeri değil.
|
|
34
|
-
__version__ = "0.
|
|
37
|
+
__version__ = "0.4.0"
|
|
35
38
|
|
|
36
39
|
__all__ = [
|
|
37
40
|
"MailFlat",
|
|
38
41
|
"Inbox",
|
|
39
42
|
"Message",
|
|
43
|
+
"Attachment",
|
|
40
44
|
"MailFlatError",
|
|
41
45
|
"AuthenticationError",
|
|
42
|
-
"
|
|
46
|
+
"MailFlatPermissionError",
|
|
47
|
+
"PermissionError", # deprecated alias of MailFlatPermissionError
|
|
43
48
|
"NotFoundError",
|
|
44
49
|
"RateLimitError",
|
|
45
50
|
"APIError",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""MailFlat client — /api/v1
|
|
1
|
+
"""MailFlat client — a thin, typed Python wrapper around the /api/v1 automation API.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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(...)` —
|
|
13
|
-
- `.list()` — `Inbox`
|
|
14
|
-
- `.inbox(address)` —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
191
|
+
"""Create a new inbox and return it as an `Inbox`.
|
|
125
192
|
|
|
126
|
-
- prefix:
|
|
127
|
-
- label:
|
|
128
|
-
- subdomain: mailflat.net
|
|
129
|
-
- domain:
|
|
130
|
-
- retention_hours:
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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,20 +1,23 @@
|
|
|
1
|
-
"""MailFlat LangChain
|
|
1
|
+
"""MailFlat LangChain integration — `MailFlatToolkit` plus a 9-tool LangChain tool set.
|
|
2
2
|
|
|
3
|
-
`mailflat` SDK
|
|
4
|
-
`langchain-core`
|
|
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
|
-
|
|
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
|
-
#
|
|
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)
|
|
11
14
|
|
|
12
15
|
⚠️ Araç çıktıları `redact_secrets()`'ten geçer: per-inbox `api_key` modelin bağlamına
|
|
13
16
|
(ve oradan LangSmith trace'lerine) girmemeli — bkz. B-055.
|
|
14
17
|
|
|
15
18
|
Connected to:
|
|
16
19
|
- imports from: mailflat.client (MailFlat), mailflat.redact, langchain_core.tools
|
|
17
|
-
- imported by:
|
|
20
|
+
- imported by: user code (LangChain agents)
|
|
18
21
|
|
|
19
22
|
Key exports:
|
|
20
23
|
- `MailFlatToolkit(api_key=..., base_url=...)` — `.get_tools()` → list[BaseTool]
|
|
@@ -40,7 +43,7 @@ if TYPE_CHECKING:
|
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
class MailFlatToolkit:
|
|
43
|
-
"""
|
|
46
|
+
"""Toolkit that plugs MailFlat's tools into a LangChain agent.
|
|
44
47
|
|
|
45
48
|
>>> from mailflat.langchain import MailFlatToolkit
|
|
46
49
|
>>> toolkit = MailFlatToolkit(api_key="mf_live_...")
|
|
@@ -59,7 +62,7 @@ class MailFlatToolkit:
|
|
|
59
62
|
|
|
60
63
|
# ------------------------------------------------------------------ tools
|
|
61
64
|
def get_tools(self) -> "Sequence[BaseTool]":
|
|
62
|
-
"""
|
|
65
|
+
"""Return every MailFlat tool as a list of LangChain `BaseTool`s."""
|
|
63
66
|
client = self._client
|
|
64
67
|
|
|
65
68
|
def create_inbox(prefix: str = "", label: str = "", retention_hours: int = 0) -> dict:
|
|
@@ -88,14 +91,19 @@ class MailFlatToolkit:
|
|
|
88
91
|
except MailFlatError as e:
|
|
89
92
|
return {"error": str(e)}
|
|
90
93
|
|
|
91
|
-
def read_messages(address: str) -> dict:
|
|
92
|
-
"""Read
|
|
94
|
+
def read_messages(address: str, direction: str = "in") -> dict:
|
|
95
|
+
"""Read messages in the given inbox address (newest first).
|
|
93
96
|
|
|
94
97
|
Args:
|
|
95
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.
|
|
96
101
|
"""
|
|
97
102
|
try:
|
|
98
|
-
|
|
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)}
|
|
99
107
|
except MailFlatError as e:
|
|
100
108
|
return {"error": str(e)}
|
|
101
109
|
|
|
@@ -118,6 +126,26 @@ class MailFlatToolkit:
|
|
|
118
126
|
except MailFlatError as e:
|
|
119
127
|
return {"otp_code": None, "error": str(e)}
|
|
120
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
|
+
|
|
121
149
|
def send_email(
|
|
122
150
|
address: str, to: str, subject: str = "", body: str = "", html: str = ""
|
|
123
151
|
) -> dict:
|
|
@@ -135,6 +163,32 @@ class MailFlatToolkit:
|
|
|
135
163
|
except MailFlatError as e:
|
|
136
164
|
return {"error": str(e)}
|
|
137
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())
|
|
189
|
+
except MailFlatError as e:
|
|
190
|
+
return {"error": str(e)}
|
|
191
|
+
|
|
138
192
|
def delete_inbox(address: str) -> dict:
|
|
139
193
|
"""Delete an inbox and all its messages by address. Irreversible.
|
|
140
194
|
|
|
@@ -151,7 +205,10 @@ class MailFlatToolkit:
|
|
|
151
205
|
list_inboxes,
|
|
152
206
|
read_messages,
|
|
153
207
|
wait_for_otp,
|
|
208
|
+
wait_for_message,
|
|
154
209
|
send_email,
|
|
210
|
+
mark_read,
|
|
211
|
+
burn_inbox,
|
|
155
212
|
delete_inbox,
|
|
156
213
|
]
|
|
157
214
|
# from_function tip ipuçları + docstring'den args_schema'yı kendisi çıkarır
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Strips secret fields from agent tool output (B-055).
|
|
2
2
|
|
|
3
3
|
Neden var: `create_inbox` ve `list_inboxes` backend yanıtını olduğu gibi döndürüyordu ve o
|
|
4
4
|
yanıtta per-inbox `api_key` (`mf_sk_…`) vardı. Araç çıktısı **model bağlamına** giriyor;
|
|
@@ -14,13 +14,17 @@ Yöntem **isim bazlı**, değer bazlı değil: mesaj gövdesini tarayıp `mf_` i
|
|
|
14
14
|
string'leri silmek, kullanıcının kendi test anahtarını konu ettiği bir maili bozardı. Alan
|
|
15
15
|
adına bakmak hem kesin hem zararsız.
|
|
16
16
|
|
|
17
|
+
NOT: Bu dosya YAYIN yüzeyi (`packages/`) → docstring'ler İngilizce (CLAUDE.md §3 istisnası).
|
|
18
|
+
|
|
17
19
|
Connected to:
|
|
18
20
|
- imports from: —
|
|
19
|
-
- imported by:
|
|
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.
|
|
20
24
|
|
|
21
25
|
Key exports:
|
|
22
|
-
- `redact_secrets(value)` —
|
|
23
|
-
- `SECRET_NAME_PARTS` —
|
|
26
|
+
- `redact_secrets(value)` — copy of the input with secret fields removed
|
|
27
|
+
- `SECRET_NAME_PARTS` — which field names count as secrets
|
|
24
28
|
"""
|
|
25
29
|
from __future__ import annotations
|
|
26
30
|
|
|
@@ -43,11 +47,11 @@ def _is_secret(key: Any) -> bool:
|
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
def redact_secrets(value: Any) -> Any:
|
|
46
|
-
"""`value`
|
|
50
|
+
"""Return a copy of `value` with secret fields removed. The input is not modified.
|
|
47
51
|
|
|
48
|
-
dict →
|
|
49
|
-
list/tuple →
|
|
50
|
-
|
|
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
|
|
51
55
|
"""
|
|
52
56
|
if isinstance(value, dict):
|
|
53
57
|
return {k: redact_secrets(v) for k, v in value.items() if not _is_secret(k)}
|
|
@@ -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
|
|
File without changes
|