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