maily-client 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.
@@ -0,0 +1,2 @@
1
+ include py.typed
2
+ include README.md
@@ -0,0 +1,413 @@
1
+ Metadata-Version: 2.4
2
+ Name: maily-client
3
+ Version: 0.4.0
4
+ Summary: Standalone single-file async client for a self-hosted maily server
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/stormsia/maily
7
+ Project-URL: Repository, https://github.com/stormsia/maily
8
+ Project-URL: Issues, https://github.com/stormsia/maily/issues
9
+ Project-URL: Documentation, https://github.com/stormsia/maily#readme
10
+ Keywords: temporary-email,disposable-email,testing,automation,mailtm,client,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Communications :: Email
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: loguru>=0.7
24
+ Requires-Dist: pydantic>=2
25
+
26
+ # maily-client
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/maily-client.svg)](https://pypi.org/project/maily-client/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/maily-client.svg)](https://pypi.org/project/maily-client/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
+ [![Typing: Typed](https://img.shields.io/badge/typing-typed-blue.svg)](https://peps.python.org/pep-0561/)
32
+
33
+ Standalone, zero-heavy-dependency Python client for the [maily](https://github.com/stormsia/maily) disposable multi-provider mail API (supporting Mail.tm, custom domains, and Gmail OAuth).
34
+
35
+ ---
36
+
37
+ ## Features
38
+
39
+ - **Single-File Portability**: Ships as a single self-contained file (`client.py`) requiring only `httpx`, `loguru`, and `pydantic`.
40
+ - **Fully Typed**: 100% type-annotated with PEP 561 `py.typed` compliance and Pydantic response models.
41
+ - **Async & Sync Support**: Native `async/await` (`Maily`) with high-concurrency polling, plus a lightweight synchronous facade (`SyncMaily`).
42
+ - **Automation Helpers**: Built-in polling and regex extraction for OTP verification codes (`wait_for_code`) and account activation links (`wait_for_link`).
43
+ - **Resilient Networking**: Optional exponential backoff retry policy handling transient errors (HTTP 502/504, connection timeouts) and honoring HTTP 429 `Retry-After`.
44
+ - **Self-Updating CLI**: Built-in CLI tool `maily-client` to check for and apply updates directly from git.
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ### Preferred — via PyPI:
51
+
52
+ ```bash
53
+ pip install maily-client
54
+ ```
55
+
56
+ ### Alternative 1: Direct from GitHub:
57
+
58
+ ```bash
59
+ pip install "maily-client @ git+https://github.com/stormsia/maily.git#subdirectory=client"
60
+ ```
61
+
62
+ ### Alternative 2: Standalone Single-File Download (Zero Install)
63
+
64
+ Because `client.py` has no internal server dependencies, you can download it directly into your project:
65
+
66
+ ```bash
67
+ # Linux / macOS
68
+ curl -O https://raw.githubusercontent.com/stormsia/maily/main/client/client.py
69
+
70
+ # Windows PowerShell
71
+ Invoke-WebRequest -Uri "https://raw.githubusercontent.com/stormsia/maily/main/client/client.py" -OutFile "client.py"
72
+ ```
73
+
74
+ Dependencies for standalone use:
75
+
76
+ ```bash
77
+ pip install httpx loguru pydantic
78
+ ```
79
+
80
+ ---
81
+
82
+ ## ⚠️ Important: Attribute Access vs Dictionary Key Access
83
+
84
+ > **CRITICAL RULE:**
85
+ > In `maily-client`, **native dot/attribute access is supported ONLY on Pydantic models and specifically `ExtractedConfirmation`** (which implements `Mapping` and allows dual access: both `res.confirmation_code` and `res["confirmation_code"]`).
86
+ >
87
+ > **All other client methods returning standard Python dictionaries** (such as `await api.config.get()`, `sync_api.config.get()`, or raw response dicts) **DO NOT support attribute/dot access**. You MUST access dictionary values using standard bracket key indexing (`d["key"]`), NEVER dot syntax (`d.key`).
88
+
89
+ ```python
90
+ # 1. ExtractedConfirmation (Pydantic model implementing Mapping) supports BOTH:
91
+ res = await api.wait_for_confirmation_email(box, digit_code_mask=r"\b(\d{6})\b")
92
+ if res is not None:
93
+ # ✅ Attribute / dot access:
94
+ print(res.subject)
95
+ print(res.confirmation_code)
96
+ print(res.confirmation_link)
97
+ print(res.message_id)
98
+ print(res.from_address)
99
+
100
+ # ✅ Dictionary mapping access:
101
+ print(res["confirmation_code"])
102
+ print(res["confirmation_link"])
103
+ print(res["subject"])
104
+
105
+ # 2. Standard dictionary responses (e.g., api.config.get()):
106
+ cfg = await api.config.get()
107
+ print(cfg["client_version"]) # ✅ CORRECT: standard dictionary indexing
108
+ # print(cfg.client_version) # ❌ ERROR: AttributeError ('dict' object has no attribute 'client_version')
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Quickstart
114
+
115
+ ### 1. Basic Mailbox Creation & Health Check
116
+
117
+ ```python
118
+ import asyncio
119
+ from client import Maily
120
+
121
+
122
+ async def main() -> None:
123
+ # Connect to your self-hosted maily server
124
+ async with Maily("http://127.0.0.1:8477", api_key="sk-your-api-key") as api:
125
+ # Check server health
126
+ health = await api.health.ping()
127
+ print(f"Server health: {health.status}")
128
+
129
+ # Create a temporary disposable mailbox (auto-selects available domain)
130
+ box = await api.create_temp_mailbox()
131
+ print(f"Created mailbox: {box.address} (ID: {box.id})")
132
+
133
+
134
+ asyncio.run(main())
135
+ ```
136
+
137
+ ### 2. Waiting for OTP / Verification Code (`wait_for_code`)
138
+
139
+ Ideal for automated signup flows (e.g., Pytest, Playwright, Selenium):
140
+
141
+ ```python
142
+ import asyncio
143
+ from client import Maily
144
+
145
+
146
+ async def main() -> None:
147
+ async with Maily("http://127.0.0.1:8477") as api:
148
+ box = await api.create_temp_mailbox()
149
+ print(f"Use {box.address} in your registration form...")
150
+
151
+ # Wait up to 60 seconds for an incoming email with an OTP
152
+ # Default pattern matches 4 to 8 digits (\b\d{4,8}\b)
153
+ otp_code = await api.wait_for_code(
154
+ box,
155
+ pattern=r"\b(\d{6})\b", # Optional custom regex
156
+ sender_domain="auth.example.com", # Optional sender domain filter
157
+ timeout=60.0,
158
+ check_interval=2.0,
159
+ )
160
+ print(f"Extracted verification code: {otp_code}")
161
+
162
+
163
+ asyncio.run(main())
164
+ ```
165
+
166
+ ### 3. Waiting for Activation Link (`wait_for_link`)
167
+
168
+ Extract confirmation or magic links automatically from incoming HTML or plain-text emails:
169
+
170
+ ```python
171
+ import asyncio
172
+ from client import Maily
173
+
174
+
175
+ async def main() -> None:
176
+ async with Maily("http://127.0.0.1:8477") as api:
177
+ box = await api.create_temp_mailbox()
178
+ print(f"Waiting for activation email to: {box.address}...")
179
+
180
+ # Find link containing specific keywords
181
+ activation_url = await api.wait_for_link(
182
+ box,
183
+ url_contain=["verify", "activate", "confirm"],
184
+ sender_domain="example.com",
185
+ timeout=60.0,
186
+ )
187
+ print(f"Activation link: {activation_url}")
188
+
189
+
190
+ asyncio.run(main())
191
+ ```
192
+
193
+ ### 4. Advanced Confirmation Extraction (`wait_for_confirmation_email`)
194
+
195
+ Extract both link and code simultaneously in a single polling loop:
196
+
197
+ ```python
198
+ import asyncio
199
+ from client import ExtractedConfirmation, Maily
200
+
201
+
202
+ async def main() -> None:
203
+ async with Maily("http://127.0.0.1:8477") as api:
204
+ box = await api.create_temp_mailbox()
205
+
206
+ res: ExtractedConfirmation | None = await api.wait_for_confirmation_email(
207
+ box,
208
+ sender_domain="service.com",
209
+ url_contain=["token", "confirm"],
210
+ digit_code_mask=r"\b(\d{6})\b",
211
+ timeout=60.0,
212
+ check_interval=3.0,
213
+ )
214
+ if res is not None:
215
+ # Dual-access: attribute access
216
+ print(f"Subject: {res.subject}")
217
+ print(f"From: {res.from_address}")
218
+ print(f"Message ID: {res.message_id}")
219
+ print(f"Code: {res.confirmation_code}")
220
+ print(f"Link: {res.confirmation_link}")
221
+
222
+ # Dual-access: dictionary mapping access
223
+ code = res["confirmation_code"]
224
+ link = res["confirmation_link"]
225
+ print(f"Code from dict access: {code}, Link: {link}")
226
+
227
+
228
+ asyncio.run(main())
229
+ ```
230
+
231
+ ### 5. Synchronous Facade (`SyncMaily`)
232
+
233
+ For synchronous test suites (standard Pytest, unittest, Selenium) or scripts where `async/await` is inconvenient:
234
+
235
+ ```python
236
+ from client import SyncMaily
237
+
238
+ # Sync context manager automatically manages an isolated background thread and event loop
239
+ with SyncMaily("http://127.0.0.1:8477", api_key="sk-your-key") as api:
240
+ # 1. Create a mailbox synchronously
241
+ box = api.create_temp_mailbox()
242
+ print(f"Created mailbox: {box.address}")
243
+
244
+ # 2. Wait for code synchronously (blocks until email arrives or timeout raises WaitTimeoutError)
245
+ code = api.wait_for_code(box, timeout=45.0)
246
+ print(f"Verification code: {code}")
247
+
248
+ # 3. Wait for confirmation email synchronously
249
+ res = api.wait_for_confirmation_email(
250
+ box,
251
+ url_contain=["verify"],
252
+ digit_code_mask=r"\b(\d{6})\b",
253
+ timeout=30.0,
254
+ )
255
+ if res:
256
+ print(f"Link: {res.confirmation_link}, Code: {res['confirmation_code']}")
257
+
258
+ # 4. Access sync sections directly
259
+ messages = api.messages.list(box)
260
+ print(f"Total messages: {len(messages)}")
261
+
262
+ # 5. Clean up mailbox
263
+ api.delete_mailbox(box)
264
+ ```
265
+
266
+ ---
267
+
268
+ ## API Overview & Structure
269
+
270
+ `Maily` exposes organized domain resource sections as well as top-level flat convenience methods:
271
+
272
+ ### Resource Sections
273
+
274
+ | Section | Description | Key Methods |
275
+ | :--- | :--- | :--- |
276
+ | `api.mailboxes` | Mailbox lifecycle | `create()`, `list()`, `get()`, `by_address()`, `delete()` |
277
+ | `api.messages` | Message inspection & polling | `list()`, `get()`, `mark_read()`, `delete()`, `source()`, `wait_for()`, `wait_for_confirmation_email()`, `wait_for_code()`, `wait_for_link()`, `iter_messages()` |
278
+ | `api.providers` | Upstream provider status | `list()`, `targets()` |
279
+ | `api.gmail` | Gmail OAuth multi-tenant management | `oauth_start()`, `import_account()`, `accounts()`, `delete_account()`, `sync()` |
280
+ | `api.admin` | Server administration | `stats()`, `retention_list()`, `retention_set()`, `retention_disable()`, `retention_run()`, `domain_cache()`, `domain_cache_save()`, `domain_cache_run()` |
281
+ | `api.keys` | API keys management | `create()`, `list()`, `get()`, `delete()`, `me()`, `update()`, `rotate()`, `secret()`, `retention_set()` |
282
+ | `api.health` | System health | `ping()` |
283
+ | `api.config` | Server public configuration | `get()` (returns `dict[str, Any]` — use `dict["key"]`) |
284
+
285
+ ---
286
+
287
+ ## Flat Facade Shortcut Methods
288
+
289
+ Both `Maily` (async) and `SyncMaily` (sync) provide direct top-level facade shortcuts for rapid automation. All 19 flat shortcut methods forward directly to their respective section implementations:
290
+
291
+ | # | Facade Method Signature | Return Type | Description |
292
+ |---|---|---|---|
293
+ | 1 | `create_mailbox(*, provider=None, domain=None, parent_id=None, address=None, password=None)` | `Mailbox` | Creates a new disposable or parent mailbox. |
294
+ | 2 | `list_mailboxes(provider=None, active=None)` | `list[Mailbox]` | Lists mailboxes registered under the active API key. |
295
+ | 3 | `mailbox(mailbox_id: int \| Mailbox)` | `Mailbox` | Retrieves details and metadata for a specific mailbox ID. |
296
+ | 4 | `mailbox_by_address(email: str)` | `Mailbox` | Finds a mailbox by its exact email address string. |
297
+ | 5 | `delete_mailbox(mailbox_id: int \| Mailbox)` | `None` | Deletes a mailbox and purges all its stored messages. |
298
+ | 6 | `list_messages(mailbox_id: int \| Mailbox, page: int = 1)` | `list[MessageSummary]` | Lists message headers/summaries received by the mailbox. |
299
+ | 7 | `get_message(mailbox_id: int \| Mailbox, message_id: str)` | `MessageDetail` | Retrieves complete message body, HTML, attachments, and text. |
300
+ | 8 | `mark_read(mailbox_id: int \| Mailbox, message_id: str)` | `MessageSummary` | Marks the specified message as read on the upstream provider. |
301
+ | 9 | `delete_message(mailbox_id: int \| Mailbox, message_id: str)` | `None` | Permanently deletes a message from the mailbox. |
302
+ | 10 | `get_source(mailbox_id: int \| Mailbox, message_id: str)` | `MessageSource` | Fetches the raw RFC822 MIME source data and download URL. |
303
+ | 11 | `gmail_oauth_start()` | `OAuthStart` | Initiates Google OAuth2 consent flow, returning `auth_url` and `state`. |
304
+ | 12 | `gmail_import_account(*, refresh_token=None, token_json=None)` | `GmailAccount` | Connects a Gmail parent account via OAuth refresh token or JSON credential. |
305
+ | 13 | `list_gmail_accounts()` | `list[GmailAccount]` | Lists all registered Gmail parent accounts and their `send_as` aliases. |
306
+ | 14 | `delete_gmail_account(parent_id: int)` | `None` | Removes a connected Gmail parent account and disables its aliases. |
307
+ | 15 | `sync_gmail_account(parent_id: int)` | `GmailAccount` | Triggers immediate upstream IMAP/REST synchronization for a Gmail account. |
308
+ | 16 | `domain_cache_settings()` | `DomainCache` | Inspects provider domain background cache status and refresh interval. |
309
+ | 17 | `save_domain_cache_settings(*, enabled=None, interval_seconds=None)` | `DomainCache` | Updates background domain cache configuration. |
310
+ | 18 | `refresh_domain_cache()` | `DomainCacheRun` | Triggers an immediate cache refresh sweep across all providers. |
311
+ | 19 | `wait_for_message(mailbox_id, *, timeout=120.0, interval=2.0, predicate=None)` | `MessageSummary` | Polls mailbox until a message arrives matching an optional predicate filter. |
312
+
313
+ ### Additional Root Convenience Methods
314
+
315
+ - `create_temp_mailbox(*, provider: str | None = None, domain: str | None = None) -> Mailbox` — Instantly creates a temporary mailbox, automatically selecting the best available active domain if omitted.
316
+ - `wait_for_code(mailbox_id, *, pattern=r"\b(\d{4,8})\b", sender_domain=None, timeout=60.0, check_interval=3.0) -> str` — Polls for incoming email and returns the extracted digit verification code (raises `WaitTimeoutError` on timeout).
317
+ - `wait_for_link(mailbox_id, *, url_contain=None, sender_domain=None, timeout=60.0, check_interval=3.0) -> str` — Polls for incoming email and returns the matching activation URL (raises `WaitTimeoutError` on timeout).
318
+ - `wait_for_confirmation_email(mailbox_id, *, sender_domain=None, url_contain=None, digit_code_mask=None, timeout=60.0, check_interval=5.0) -> ExtractedConfirmation | None` — High-level extractor returning `ExtractedConfirmation` (supports dot and dict access) or `None` on timeout.
319
+ - `check_client_version() -> str` — Queries `/v1/config` and logs a warning via Loguru if the client version does not match the server.
320
+
321
+ ---
322
+
323
+ ## CLI Commands
324
+
325
+ The package includes a command-line interface executable as `maily-client` (or via `python client.py`):
326
+
327
+ ```bash
328
+ # Display help and usage
329
+ maily-client --help
330
+
331
+ # Show current installed client version
332
+ maily-client version
333
+
334
+ # Check GitHub repository for newer version (exit codes: 0=up-to-date, 2=update available, 1=network error)
335
+ maily-client check
336
+
337
+ # Download and replace local client file with latest release from GitHub
338
+ maily-client update
339
+ ```
340
+
341
+ ### CLI Exit Codes
342
+
343
+ | Exit Code | Meaning |
344
+ | :---: | :--- |
345
+ | `0` | Success / local client file is already up to date. |
346
+ | `1` | Network or I/O failure (failed to connect to GitHub or file write error). |
347
+ | `2` | Update available (when running `check`), or invalid CLI argument. |
348
+
349
+ ### Environment Variables
350
+
351
+ - `MAILYAPI_CLIENT_SOURCE`: URL to fetch the canonical client from when checking or updating (defaults to GitHub `main` branch raw file: `https://raw.githubusercontent.com/stormsia/maily/main/src/maily/standalone_client.py`).
352
+
353
+ ---
354
+
355
+ ## Error Handling & Resiliency
356
+
357
+ The client provides explicit exceptions inheriting from `ClientError`:
358
+
359
+ ```
360
+ ClientError
361
+ ├── WaitTimeoutError
362
+ └── ApiError
363
+ ├── AuthenticationError (HTTP 401: Invalid API key or missing Authorization header)
364
+ ├── ForbiddenError (HTTP 403: Scoped API key does not have permission)
365
+ ├── NotFoundError (HTTP 404: Mailbox or message does not exist)
366
+ ├── ValidationError (HTTP 422: Malformed input or schema validation failure)
367
+ ├── RateLimitError (HTTP 429: Rate limited by server or upstream provider)
368
+ └── UpstreamProviderError (HTTP 502 / 504: Upstream temporary mail provider failure)
369
+ ```
370
+
371
+ ### HTTP 422 Validation Error Formatting
372
+
373
+ FastAPI validation errors returning a list of field error objects (`[{"loc": ["body", "domain"], "msg": "..."}]`) are automatically formatted into readable messages:
374
+
375
+ ```python
376
+ from client import Maily, ValidationError
377
+
378
+ async with Maily("http://127.0.0.1:8477") as api:
379
+ try:
380
+ # Invalid input parameters
381
+ await api.mailboxes.create(domain="invalid domain!@#")
382
+ except ValidationError as exc:
383
+ print(f"Status Code: {exc.status_code}") # 422
384
+ # Clean formatted string: "body -> domain: Value error, invalid domain name characters"
385
+ print(f"Validation Detail: {exc.detail}")
386
+ ```
387
+
388
+ ### HTTP 429 Rate Limiting & Automatic Retries
389
+
390
+ When `max_retries > 0`, the client automatically honors HTTP 429 `Retry-After` headers and applies exponential backoff for transient HTTP 502/504 errors and network connection timeouts:
391
+
392
+ ```python
393
+ from client import Maily, RateLimitError
394
+
395
+ # Configure client-side retry policy
396
+ api = Maily(
397
+ "http://127.0.0.1:8477",
398
+ max_retries=3, # Retry up to 3 times before raising
399
+ retry_backoff=0.5, # Base backoff in seconds (0.5s, 1.0s, 2.0s)
400
+ )
401
+
402
+ try:
403
+ box = await api.create_temp_mailbox()
404
+ except RateLimitError as exc:
405
+ # If retries are exhausted or max_retries is 0
406
+ print(f"Rate limited (HTTP 429). Server message: {exc.detail}")
407
+ ```
408
+
409
+ ---
410
+
411
+ ## License
412
+
413
+ This project is licensed under the [MIT License](https://opensource.org/licenses/MIT).