maily-client 0.4.0__py3-none-any.whl

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.
client.py ADDED
@@ -0,0 +1,2434 @@
1
+ # -=- THIS FILE IS GENERATED from src/maily/standalone_client.py -=-
2
+ # Do not edit by hand; edit the canonical file and run:
3
+ # python scripts/build_client.py
4
+ """maily standalone client — CANONICAL single-file implementation.
5
+
6
+ Single canonical source of truth for the client. Two ways to use this code:
7
+
8
+ 1. Inside the server package — via the thin proxy `maily.client`
9
+ (this module is imported from there).
10
+ 2. Outside — the `client/client.py` file in the repo root, which
11
+ is generated from this module via `scripts/build_client.py`
12
+ (adds only the 'generated' banner). Do not edit `client/client.py`
13
+ by hand — edit here and regenerate; parity is enforced by
14
+ `tests/test_client_standalone.py::test_standalone_mirror_is_current`.
15
+
16
+ Self-contained: does not pull in server dependencies (FastAPI, SQLAlchemy,
17
+ uvicorn, etc.), requires only httpx, loguru, and pydantic.
18
+
19
+ # Preferred — install from PyPI:
20
+ pip install maily-client
21
+
22
+ # Or directly from the GitHub repository:
23
+ pip install "maily-client @ git+https://github.com/stormsia/maily.git#subdirectory=client"
24
+ # Or copy the single-file client directly into your project:
25
+ # curl -O https://raw.githubusercontent.com/stormsia/maily/main/client/client.py
26
+
27
+ Self-update: the client can check the version on git and update itself from
28
+ the repo's client/client.py:
29
+
30
+ maily-client check # check if a new version is on git
31
+ maily-client update # download and install the new version
32
+
33
+ (or `python client.py check/update` if running locally).
34
+
35
+ From code:
36
+
37
+ import client
38
+ from client import Maily, self_update, check_update
39
+
40
+ self_update() # async-free, updates the client file on disk
41
+ up = check_update() # (current, remote) if an update is available, else None
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import builtins
48
+ import os
49
+ import re
50
+ import sys
51
+ import threading
52
+ import time
53
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping
54
+ from contextlib import suppress
55
+ from typing import Any
56
+ from urllib.parse import quote
57
+
58
+ import httpx
59
+ from loguru import logger
60
+ from pydantic import BaseModel, ConfigDict, Field
61
+
62
+ __version__ = "0.4.0" # x-release-please-version
63
+
64
+ # Where the client fetches itself for self-updates (override with env var).
65
+ CLIENT_SOURCE = os.environ.get(
66
+ "MAILY_CLIENT_SOURCE",
67
+ "https://raw.githubusercontent.com/stormsia/maily/main/client/client.py",
68
+ )
69
+
70
+ __all__ = [
71
+ # Client and sections
72
+ "Maily",
73
+ "SyncMaily",
74
+ "SyncHealthAPI",
75
+ "SyncConfigAPI",
76
+ "SyncProvidersAPI",
77
+ "SyncMailboxesAPI",
78
+ "SyncMessagesAPI",
79
+ "SyncGmailAPI",
80
+ "SyncAdminAPI",
81
+ "SyncKeysAPI",
82
+ "HealthAPI",
83
+ "ConfigAPI",
84
+ "ProvidersAPI",
85
+ "MailboxesAPI",
86
+ "MessagesAPI",
87
+ "GmailAPI",
88
+ "AdminAPI",
89
+ "KeysAPI",
90
+ # Errors
91
+ "ClientError",
92
+ "WaitTimeoutError",
93
+ "ApiError",
94
+ "AuthenticationError",
95
+ "ForbiddenError",
96
+ "NotFoundError",
97
+ "ValidationError",
98
+ "RateLimitError",
99
+ "UpstreamProviderError",
100
+ # Data models
101
+ "HealthOut",
102
+ "Provider",
103
+ "ProviderOut",
104
+ "ProviderCapabilities",
105
+ "Target",
106
+ "TargetOut",
107
+ "Mailbox",
108
+ "MailboxOut",
109
+ "MessageSummary",
110
+ "MessageOut",
111
+ "MessageDetail",
112
+ "MessageDetailOut",
113
+ "MessageSource",
114
+ "SourceOut",
115
+ "GmailAccount",
116
+ "GmailAccountOut",
117
+ "OAuthStart",
118
+ "OAuthStartOut",
119
+ "GmailCreds",
120
+ "GmailCredsOut",
121
+ "Retention",
122
+ "RetentionOut",
123
+ "SweepResult",
124
+ "DomainCacheProvider",
125
+ "DomainCacheProviderOut",
126
+ "DomainCache",
127
+ "DomainCacheOut",
128
+ "DomainCacheRun",
129
+ "ApiKeyOut",
130
+ "ApiKeyCreatedOut",
131
+ "ApiKeyMeOut",
132
+ "KeyScopeOut",
133
+ "KeyRetentionOut",
134
+ "ExtractedConfirmation",
135
+ # Update utilities
136
+ "check_update",
137
+ "self_update",
138
+ ]
139
+
140
+
141
+ # --------------------------------------------------------------------------- #
142
+ # Response models (self-contained — do not depend on maily.schemas)
143
+ # --------------------------------------------------------------------------- #
144
+ class _Base(BaseModel):
145
+ model_config = ConfigDict(from_attributes=True, extra="ignore")
146
+
147
+
148
+ class HealthOut(_Base):
149
+ status: str = ""
150
+
151
+
152
+ class ProviderCapabilities(_Base):
153
+ can_choose_username: bool = True
154
+ can_choose_domain: bool = True
155
+ can_set_password: bool = False
156
+ supports_delete_mailbox: bool = True
157
+ supports_delete_message: bool = True
158
+ supports_mark_read: bool = False
159
+ supports_source: bool = True
160
+ supports_attachments: bool = True
161
+
162
+
163
+ class ProviderOut(_Base):
164
+ slug: str
165
+ kind: str
166
+ capabilities: ProviderCapabilities | None = None
167
+
168
+
169
+ class TargetOut(_Base):
170
+ id: str
171
+ kind: str
172
+ provider: str
173
+ label: str
174
+
175
+
176
+ class MailboxOut(_Base):
177
+ id: int
178
+ provider: str
179
+ address: str
180
+ parent_id: int | None = None
181
+ is_active: bool
182
+ created_at: Any = None
183
+ parent_status: str | None = None
184
+
185
+
186
+ class MessageOut(_Base):
187
+ id: int
188
+ message_api_id: str
189
+ subject: str = ""
190
+ from_address: str = ""
191
+ from_name: str = ""
192
+ seen: bool = False
193
+ has_attachments: bool = False
194
+ size: int = 0
195
+ received_at: Any = None
196
+
197
+
198
+ class MessageDetailOut(MessageOut):
199
+ text: str = ""
200
+ html: list[str] = []
201
+
202
+
203
+ class SourceOut(_Base):
204
+ id: str
205
+ data: str
206
+ download_url: str = ""
207
+
208
+
209
+ class GmailAccountOut(_Base):
210
+ id: int
211
+ email: str
212
+ status: str
213
+ send_as: list[str] = []
214
+
215
+
216
+ class OAuthStartOut(_Base):
217
+ auth_url: str
218
+ state: str
219
+
220
+
221
+ class GmailCredsOut(_Base):
222
+ configured: bool
223
+ source: str
224
+ client_id: str | None = None
225
+ redirect_uri: str = ""
226
+
227
+
228
+ class RetentionOut(_Base):
229
+ provider: str
230
+ mailbox_ttl_hours: int | None = None
231
+ message_ttl_hours: int | None = None
232
+
233
+
234
+ class SweepResult(_Base):
235
+ mailboxes_deleted: int = 0
236
+ messages_deleted: int = 0
237
+
238
+
239
+ class DomainCacheProviderOut(_Base):
240
+ provider: str
241
+ kind: str = "domain"
242
+ domains_count: int = 0
243
+ updated_at: Any = None
244
+ error: str | None = None
245
+
246
+
247
+ class DomainCacheOut(_Base):
248
+ enabled: bool = True
249
+ interval_seconds: int = 1800
250
+ providers: list[DomainCacheProviderOut] = []
251
+
252
+
253
+ class DomainCacheRun(_Base):
254
+ refreshed: list[str] = []
255
+ failed: list[str] = []
256
+
257
+
258
+ class KeyScopeOut(_Base):
259
+ providers: list[str] = Field(default_factory=list)
260
+ parent_ids: list[int] = Field(default_factory=list)
261
+ actions: list[str] = Field(default_factory=list)
262
+
263
+
264
+ class KeyRetentionOut(_Base):
265
+ provider: str
266
+ mailbox_ttl_hours: int | None = None
267
+ message_ttl_hours: int | None = None
268
+
269
+
270
+ class ApiKeyOut(_Base):
271
+ id: int
272
+ name: str
273
+ prefix: str
274
+ scope: KeyScopeOut = Field(default_factory=KeyScopeOut)
275
+ expires_at: Any = None
276
+ created_at: Any = None
277
+ updated_at: Any = None
278
+ key_retention: list[KeyRetentionOut] = Field(default_factory=list)
279
+
280
+
281
+ class ApiKeyCreatedOut(ApiKeyOut):
282
+ secret: str
283
+
284
+
285
+ class ApiKeyMeOut(_Base):
286
+ is_root: bool = False
287
+ name: str = ""
288
+ expires_at: Any = None
289
+ scope: KeyScopeOut | None = None
290
+ key_retention: list[KeyRetentionOut] = Field(default_factory=list)
291
+
292
+
293
+ class ExtractedConfirmation(_Base, Mapping[str, Any]):
294
+ message_id: str
295
+ from_address: str
296
+ subject: str
297
+ confirmation_link: str | None = None
298
+ confirmation_code: str | None = None
299
+
300
+ def __getitem__(self, key: str) -> Any:
301
+ if key in type(self).model_fields:
302
+ return getattr(self, key)
303
+ raise KeyError(key)
304
+
305
+ def __iter__(self) -> Iterator[str]: # type: ignore[override]
306
+ return iter(type(self).model_fields)
307
+
308
+ def __len__(self) -> int:
309
+ return len(type(self).model_fields)
310
+
311
+
312
+ Provider = ProviderOut
313
+ Target = TargetOut
314
+ Mailbox = MailboxOut
315
+ MessageSummary = MessageOut
316
+ MessageDetail = MessageDetailOut
317
+ MessageSource = SourceOut
318
+ GmailAccount = GmailAccountOut
319
+ OAuthStart = OAuthStartOut
320
+ GmailCreds = GmailCredsOut
321
+ Retention = RetentionOut
322
+ DomainCacheProvider = DomainCacheProviderOut
323
+ DomainCache = DomainCacheOut
324
+
325
+ _URL_RE = re.compile(r"https?://[^\s<>\"')\]]+", re.IGNORECASE)
326
+
327
+
328
+ # --------------------------------------------------------------------------- #
329
+ # Errors
330
+ # --------------------------------------------------------------------------- #
331
+ class ClientError(Exception):
332
+ """Base client error for maily."""
333
+
334
+
335
+ class WaitTimeoutError(ClientError, TimeoutError):
336
+ """Timed out waiting for incoming email, confirmation link, or verification code."""
337
+
338
+
339
+ class ApiError(ClientError):
340
+ """Service HTTP error response: non-2xx status with detail from the response body."""
341
+
342
+ def __init__(self, status_code: int | None, detail: str = "") -> None:
343
+ self.status_code = status_code
344
+ self.detail = detail or (f"HTTP {status_code}" if status_code else "request failed")
345
+ super().__init__(self.detail)
346
+
347
+
348
+ class AuthenticationError(ApiError):
349
+ """HTTP 401: Invalid or missing API key."""
350
+
351
+
352
+ class ForbiddenError(ApiError):
353
+ """HTTP 403: Access forbidden (insufficient key permissions for resource/action)."""
354
+
355
+
356
+ class NotFoundError(ApiError):
357
+ """HTTP 404: Requested resource not found."""
358
+
359
+
360
+ class ValidationError(ApiError):
361
+ """HTTP 400 or 422: Request parameter validation error."""
362
+
363
+
364
+ class RateLimitError(ApiError):
365
+ """HTTP 429: Rate limit exceeded."""
366
+
367
+
368
+ class UpstreamProviderError(ApiError):
369
+ """HTTP 502/504: Upstream email provider failure or timeout."""
370
+
371
+
372
+ # --------------------------------------------------------------------------- #
373
+ # Git self-update
374
+ # --------------------------------------------------------------------------- #
375
+ def _parse_version(text: str) -> str | None:
376
+ match = re.search(r"^__version__\s*=\s*[\"']([^\"']+)[\"']", text, re.MULTILINE)
377
+ return match.group(1) if match else None
378
+
379
+
380
+ def _fetch_client(source_url: str | None) -> str:
381
+ url = source_url or CLIENT_SOURCE
382
+ resp = httpx.get(url, timeout=15.0, follow_redirects=True)
383
+ resp.raise_for_status()
384
+ return resp.text
385
+
386
+
387
+ def check_update(source_url: str | None = None) -> tuple[str, str] | None:
388
+ """Return (local, remote) versions if a newer version exists on git, else None."""
389
+ remote = _parse_version(_fetch_client(source_url))
390
+ if not remote:
391
+ raise ClientError("could not read __version__ from the client source")
392
+ if remote == __version__:
393
+ return None
394
+ return (__version__, remote)
395
+
396
+
397
+ def _atomic_replace(path: str, text: str) -> None:
398
+ tmp = path + ".tmp"
399
+ with open(tmp, "w", encoding="utf-8", newline="") as fh:
400
+ fh.write(text)
401
+ if os.name == "nt":
402
+ with (
403
+ open(tmp, encoding="utf-8") as src,
404
+ open(path, "w", encoding="utf-8", newline="") as dst,
405
+ ):
406
+ dst.write(src.read())
407
+ with suppress(OSError):
408
+ os.remove(tmp)
409
+ else:
410
+ os.replace(tmp, path)
411
+
412
+
413
+ def self_update(
414
+ source_url: str | None = None,
415
+ *,
416
+ after: Callable[[], None] | None = None,
417
+ ) -> bool:
418
+ """Update the client file with the new version from git.
419
+
420
+ Returns True if the file was replaced, False if the version is already current.
421
+ The source URL can be overridden with the MAILY_CLIENT_SOURCE env var.
422
+ """
423
+ text = _fetch_client(source_url)
424
+ remote = _parse_version(text)
425
+ if not remote:
426
+ raise ClientError("could not read __version__ from the client source")
427
+ if remote == __version__:
428
+ return False
429
+
430
+ import inspect
431
+
432
+ path = inspect.getsourcefile(Maily) or __file__
433
+ _atomic_replace(path, text)
434
+ logger.info("client updated {} -> {} ({})", __version__, remote, path)
435
+ if after is not None:
436
+ after()
437
+ return True
438
+
439
+
440
+ # --------------------------------------------------------------------------- #
441
+ # Transport
442
+ # --------------------------------------------------------------------------- #
443
+ _TRANSIENT_NETWORK_ERRORS = (httpx.ConnectTimeout, httpx.ConnectError, httpx.NetworkError)
444
+
445
+
446
+ def _parse_retry_after(header_val: str | None, attempt: int, backoff: float) -> float:
447
+ """Parse HTTP Retry-After header as seconds (float/int).
448
+
449
+ Falls back to exponential backoff `backoff * (2 ** attempt)` if missing or invalid.
450
+ """
451
+ if header_val is not None:
452
+ try:
453
+ val = float(header_val.strip())
454
+ if val >= 0:
455
+ return val
456
+ except (ValueError, TypeError):
457
+ pass
458
+ return max(0.0, backoff * (2**attempt))
459
+
460
+
461
+ class _Transport:
462
+ """Common HTTP transport layer: shared AsyncClient, API key, logging, error handling."""
463
+
464
+ def __init__(
465
+ self,
466
+ base_url: str,
467
+ api_key: str = "",
468
+ timeout: float = 30.0,
469
+ *,
470
+ client: httpx.AsyncClient | None = None,
471
+ max_retries: int = 0,
472
+ retry_backoff: float = 0.5,
473
+ ) -> None:
474
+ self._client = client or httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout)
475
+ self._owns_client = client is None
476
+ self._headers: dict[str, str] = {}
477
+ if api_key:
478
+ self._headers["X-API-Key"] = api_key
479
+ self.max_retries = max(0, int(max_retries))
480
+ self.retry_backoff = max(0.0, float(retry_backoff))
481
+
482
+ def set_api_key(self, api_key: str) -> None:
483
+ """Update the API key dynamically (empty string to remove)."""
484
+ if api_key:
485
+ self._headers["X-API-Key"] = api_key
486
+ else:
487
+ self._headers.pop("X-API-Key", None)
488
+
489
+ def _parse_retry_after(self, header_val: str | None, attempt: int) -> float:
490
+ return _parse_retry_after(header_val, attempt, self.retry_backoff)
491
+
492
+ async def request(
493
+ self,
494
+ method: str,
495
+ path: str,
496
+ *,
497
+ params: dict[str, Any] | None = None,
498
+ json: Any = None,
499
+ ) -> Any:
500
+ for attempt in range(self.max_retries + 1):
501
+ started = time.perf_counter()
502
+ try:
503
+ resp = await self._client.request(
504
+ method, path, params=params, json=json, headers=self._headers
505
+ )
506
+ except _TRANSIENT_NETWORK_ERRORS as exc:
507
+ if attempt < self.max_retries:
508
+ delay = self.retry_backoff * (2**attempt)
509
+ logger.warning(
510
+ "{} {} transient connection error ({}), retrying in {:.2f}s (attempt {}/{})",
511
+ method,
512
+ path,
513
+ exc,
514
+ delay,
515
+ attempt + 1,
516
+ self.max_retries,
517
+ )
518
+ await asyncio.sleep(delay)
519
+ continue
520
+ logger.opt(exception=False).warning("{} {} failed: {}", method, path, exc)
521
+ raise ApiError(None, f"network error: {exc}") from exc
522
+ except httpx.HTTPError as exc:
523
+ logger.opt(exception=False).warning("{} {} failed: {}", method, path, exc)
524
+ raise ApiError(None, f"network error: {exc}") from exc
525
+
526
+ elapsed = (time.perf_counter() - started) * 1000
527
+ if resp.status_code >= 400:
528
+ detail = _extract_detail(resp)
529
+ logger.warning(
530
+ "{} {} -> {} ({:.0f}ms): {}", method, path, resp.status_code, elapsed, detail
531
+ )
532
+ if resp.status_code == 429 and attempt < self.max_retries:
533
+ delay = self._parse_retry_after(resp.headers.get("Retry-After"), attempt)
534
+ logger.warning(
535
+ "{} {} rate limited (429), retrying in {:.2f}s (attempt {}/{})",
536
+ method,
537
+ path,
538
+ delay,
539
+ attempt + 1,
540
+ self.max_retries,
541
+ )
542
+ await asyncio.sleep(delay)
543
+ continue
544
+ if resp.status_code in (502, 504) and attempt < self.max_retries:
545
+ delay = self.retry_backoff * (2**attempt)
546
+ logger.warning(
547
+ "{} {} upstream error ({}), retrying in {:.2f}s (attempt {}/{})",
548
+ method,
549
+ path,
550
+ resp.status_code,
551
+ delay,
552
+ attempt + 1,
553
+ self.max_retries,
554
+ )
555
+ await asyncio.sleep(delay)
556
+ continue
557
+ if resp.status_code == 401:
558
+ raise AuthenticationError(resp.status_code, detail)
559
+ if resp.status_code == 403:
560
+ raise ForbiddenError(resp.status_code, detail)
561
+ if resp.status_code == 404:
562
+ raise NotFoundError(resp.status_code, detail)
563
+ if resp.status_code in (400, 422):
564
+ raise ValidationError(resp.status_code, detail)
565
+ if resp.status_code == 429:
566
+ raise RateLimitError(resp.status_code, detail)
567
+ if resp.status_code in (502, 504):
568
+ raise UpstreamProviderError(resp.status_code, detail)
569
+ raise ApiError(resp.status_code, detail)
570
+
571
+ logger.debug("{} {} -> {} ({:.0f}ms)", method, path, resp.status_code, elapsed)
572
+ if resp.status_code == 204:
573
+ return None
574
+ return resp.json()
575
+ raise ApiError(None, "retries exhausted")
576
+
577
+ async def close(self) -> None:
578
+ if self._owns_client:
579
+ await self._client.aclose()
580
+
581
+ async def __aenter__(self) -> _Transport:
582
+ return self
583
+
584
+ async def __aexit__(self, *_exc: Any) -> None:
585
+ await self.close()
586
+
587
+
588
+ def _extract_detail(resp: httpx.Response) -> str:
589
+ try:
590
+ body = resp.json()
591
+ except Exception:
592
+ text = resp.text[:300] if resp.text else ""
593
+ return text if text.strip() else f"HTTP {resp.status_code}"
594
+
595
+ if isinstance(body, dict):
596
+ detail = body.get("detail")
597
+ if isinstance(detail, str) and detail:
598
+ return detail
599
+ if isinstance(detail, list):
600
+ formatted: list[str] = []
601
+ for item in detail:
602
+ if isinstance(item, dict):
603
+ loc = item.get("loc")
604
+ msg = item.get("msg") or ""
605
+ field = ""
606
+ if isinstance(loc, (list, tuple)):
607
+ parts = [str(p) for p in loc]
608
+ if parts and parts[0] in ("body", "query", "path", "header", "cookie"):
609
+ parts = parts[1:]
610
+ field = ".".join(parts)
611
+ if field and msg:
612
+ formatted.append(f"{field}: {msg}")
613
+ elif msg:
614
+ formatted.append(str(msg))
615
+ elif field:
616
+ formatted.append(field)
617
+ else:
618
+ formatted.append(str(item))
619
+ elif isinstance(item, str):
620
+ formatted.append(item)
621
+ else:
622
+ formatted.append(str(item))
623
+ if formatted:
624
+ return "; ".join(formatted)
625
+ if detail is not None and not (isinstance(detail, (dict, list, str)) and not detail):
626
+ return str(detail)[:300]
627
+ return str(body)[:300]
628
+
629
+ return str(body)[:300]
630
+
631
+
632
+ def _mailbox_id_val(mailbox_id: int | MailboxOut) -> int:
633
+ return mailbox_id.id if hasattr(mailbox_id, "id") else int(mailbox_id)
634
+
635
+
636
+ # --------------------------------------------------------------------------- #
637
+ # Resource APIs
638
+ # --------------------------------------------------------------------------- #
639
+ class HealthAPI:
640
+ """GET /health — service health check (no API key required)."""
641
+
642
+ def __init__(self, transport: _Transport) -> None:
643
+ self._t = transport
644
+
645
+ async def ping(self) -> HealthOut:
646
+ return HealthOut.model_validate(await self._t.request("GET", "/health"))
647
+
648
+
649
+ class ConfigAPI:
650
+ """GET /v1/config — public server facts (no API key required)."""
651
+
652
+ def __init__(self, transport: _Transport) -> None:
653
+ self._t = transport
654
+
655
+ async def get(self) -> dict[str, Any]:
656
+ return await self._t.request("GET", "/v1/config")
657
+
658
+
659
+ class ProvidersAPI:
660
+ """Providers and targets (domains / parent accounts)."""
661
+
662
+ def __init__(self, transport: _Transport) -> None:
663
+ self._t = transport
664
+
665
+ async def list(self) -> list[Provider]:
666
+ """All available providers."""
667
+ return [Provider.model_validate(p) for p in await self._t.request("GET", "/v1/providers")]
668
+
669
+ async def targets(self, provider: str | None = None) -> builtins.list[Target]:
670
+ """Mailbox creation targets: domains (domain providers) or Gmail accounts."""
671
+ params = {"provider": provider} if provider else None
672
+ return [
673
+ Target.model_validate(t)
674
+ for t in await self._t.request("GET", "/v1/targets", params=params)
675
+ ]
676
+
677
+
678
+ class MailboxesAPI:
679
+ """Mailbox creation, listing, retrieval, and deletion."""
680
+
681
+ def __init__(self, transport: _Transport) -> None:
682
+ self._t = transport
683
+
684
+ async def create(
685
+ self,
686
+ *,
687
+ provider: str | None = None,
688
+ domain: str | None = None,
689
+ parent_id: int | None = None,
690
+ address: str | None = None,
691
+ password: str | None = None,
692
+ ) -> Mailbox:
693
+ """Create a mailbox. Use domain for domain providers, or parent_id for Gmail."""
694
+ payload = {
695
+ k: v
696
+ for k, v in {
697
+ "provider": provider,
698
+ "domain": domain,
699
+ "parent_id": parent_id,
700
+ "address": address,
701
+ "password": password,
702
+ }.items()
703
+ if v is not None
704
+ }
705
+ return Mailbox.model_validate(await self._t.request("POST", "/v1/mailboxes", json=payload))
706
+
707
+ async def list(
708
+ self,
709
+ *,
710
+ provider: str | None = None,
711
+ active: bool | None = None,
712
+ q: str | None = None,
713
+ limit: int | None = None,
714
+ offset: int | None = None,
715
+ ) -> list[Mailbox]:
716
+ """List mailboxes. active=None returns all (including deleted)."""
717
+ params: dict[str, Any] = {}
718
+ if provider:
719
+ params["provider"] = provider
720
+ if active is not None:
721
+ params["active"] = str(active).lower()
722
+ if q:
723
+ params["q"] = q
724
+ if limit is not None:
725
+ params["limit"] = str(limit)
726
+ if offset is not None:
727
+ params["offset"] = str(offset)
728
+ return [
729
+ Mailbox.model_validate(m)
730
+ for m in await self._t.request("GET", "/v1/mailboxes", params=params or None)
731
+ ]
732
+
733
+ async def get(self, mailbox_id: int | Mailbox) -> Mailbox:
734
+ mid = _mailbox_id_val(mailbox_id)
735
+ return Mailbox.model_validate(await self._t.request("GET", f"/v1/mailboxes/{mid}"))
736
+
737
+ async def by_address(self, email: str) -> Mailbox:
738
+ """Find mailbox by full email address."""
739
+ return Mailbox.model_validate(
740
+ await self._t.request("GET", f"/v1/mailboxes/by-address/{quote(email, safe='')}")
741
+ )
742
+
743
+ async def delete(self, mailbox_id: int | Mailbox) -> None:
744
+ """Delete mailbox (both upstream at provider and locally)."""
745
+ mid = _mailbox_id_val(mailbox_id)
746
+ await self._t.request("DELETE", f"/v1/mailboxes/{mid}")
747
+
748
+
749
+ class MessagesAPI:
750
+ """Mailbox messages: list, read, mark, delete, and wait for incoming mail."""
751
+
752
+ def __init__(self, transport: _Transport) -> None:
753
+ self._t = transport
754
+
755
+ async def list(self, mailbox_id: int | Mailbox, page: int = 1) -> list[MessageSummary]:
756
+ """List messages (paginated)."""
757
+ mid = _mailbox_id_val(mailbox_id)
758
+ return [
759
+ MessageSummary.model_validate(m)
760
+ for m in await self._t.request(
761
+ "GET", f"/v1/mailboxes/{mid}/messages", params={"page": page}
762
+ )
763
+ ]
764
+
765
+ async def get(self, mailbox_id: int | Mailbox, message_id: str) -> MessageDetail:
766
+ """Full message detail including plain text and HTML."""
767
+ mid = _mailbox_id_val(mailbox_id)
768
+ return MessageDetail.model_validate(
769
+ await self._t.request(
770
+ "GET", f"/v1/mailboxes/{mid}/messages/{quote(message_id, safe='')}"
771
+ )
772
+ )
773
+
774
+ async def mark_read(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSummary:
775
+ mid = _mailbox_id_val(mailbox_id)
776
+ return MessageSummary.model_validate(
777
+ await self._t.request(
778
+ "POST", f"/v1/mailboxes/{mid}/messages/{quote(message_id, safe='')}/read"
779
+ )
780
+ )
781
+
782
+ async def delete(self, mailbox_id: int | Mailbox, message_id: str) -> None:
783
+ mid = _mailbox_id_val(mailbox_id)
784
+ await self._t.request(
785
+ "DELETE", f"/v1/mailboxes/{mid}/messages/{quote(message_id, safe='')}"
786
+ )
787
+
788
+ async def source(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSource:
789
+ """Raw message source (RFC 822 / MIME)."""
790
+ mid = _mailbox_id_val(mailbox_id)
791
+ return MessageSource.model_validate(
792
+ await self._t.request(
793
+ "GET", f"/v1/mailboxes/{mid}/messages/{quote(message_id, safe='')}/source"
794
+ )
795
+ )
796
+
797
+ async def wait_for(
798
+ self,
799
+ mailbox_id: int | Mailbox,
800
+ *,
801
+ timeout: float = 120.0,
802
+ interval: float = 2.0,
803
+ predicate: Callable[[MessageSummary], bool] | None = None,
804
+ mark_seen: bool = False,
805
+ ) -> MessageSummary:
806
+ """Poll the message list until a matching message arrives (defaults to any).
807
+
808
+ When mark_seen=True, the found message is immediately marked as read.
809
+ Raises WaitTimeoutError (also a TimeoutError) when timeout expires.
810
+ """
811
+ mid = _mailbox_id_val(mailbox_id)
812
+ deadline = time.monotonic() + timeout
813
+ while True:
814
+ for m in await self.list(mid):
815
+ if predicate is None or predicate(m):
816
+ if mark_seen:
817
+ await self.mark_read(mid, m.message_api_id)
818
+ return m
819
+ if time.monotonic() >= deadline:
820
+ raise WaitTimeoutError(f"no message arrived within {timeout}s")
821
+ await asyncio.sleep(interval)
822
+
823
+ async def wait_for_confirmation_email(
824
+ self,
825
+ mailbox_id: int | Mailbox,
826
+ *,
827
+ sender_domain: str | None = None,
828
+ url_contain: builtins.list[str] | None = None,
829
+ digit_code_mask: str | None = None,
830
+ timeout: float = 60.0,
831
+ check_interval: float = 5.0,
832
+ ) -> ExtractedConfirmation | None:
833
+ """Wait for a confirmation email and extract confirmation link and/or code.
834
+
835
+ Parameters (all optional, but either `url_contain` or `digit_code_mask` is needed):
836
+ sender_domain — filter by sender domain, e.g. "gmail.com"
837
+ url_contain — substrings that must be in the link,
838
+ e.g. ["confirm", "verify", "activate", "token"]
839
+ digit_code_mask — regex pattern for the confirmation code
840
+
841
+ Returns an ExtractedConfirmation instance (supporting both attribute
842
+ and dictionary mapping access) or None when timeout expires.
843
+ """
844
+ mid = _mailbox_id_val(mailbox_id)
845
+ if timeout < 0:
846
+ raise ValueError("timeout must be >= 0")
847
+ if check_interval <= 0:
848
+ raise ValueError("check_interval must be > 0")
849
+
850
+ keywords: list[str] = []
851
+ for keyword in url_contain or []:
852
+ if keyword:
853
+ keywords.append(keyword.casefold())
854
+ try:
855
+ code_pattern = re.compile(digit_code_mask, re.IGNORECASE) if digit_code_mask else None
856
+ except re.error as exc:
857
+ raise ValueError(
858
+ f"Invalid regular expression digit_code_mask: {digit_code_mask!r}"
859
+ ) from exc
860
+
861
+ loop = asyncio.get_running_loop()
862
+ deadline = loop.time() + timeout
863
+
864
+ def _is_from_domain(address: str, domain: str) -> bool:
865
+ match = re.search(r"<([^>]+)>", address)
866
+ email = match.group(1) if match else address
867
+ email_domain = email.rsplit("@", 1)[-1].strip().casefold()
868
+ return email_domain == domain.casefold().lstrip("@")
869
+
870
+ examined: set[str] = set()
871
+
872
+ while loop.time() < deadline:
873
+ for msg in await self.list(mid):
874
+ message_id = msg.message_api_id
875
+ if message_id in examined:
876
+ continue
877
+ if sender_domain and not _is_from_domain(msg.from_address, sender_domain):
878
+ examined.add(message_id)
879
+ continue
880
+
881
+ try:
882
+ detail = await self.get(mid, message_id)
883
+ except Exception:
884
+ logger.exception("Failed to load message details for %s", message_id)
885
+ continue
886
+
887
+ examined.add(message_id)
888
+
889
+ body = "\n".join(filter(None, [detail.text or "", *detail.html]))
890
+
891
+ urls = _URL_RE.findall(body)
892
+ confirmation_link = next(
893
+ (
894
+ url
895
+ for url in urls
896
+ if not keywords or any(kw in url.casefold() for kw in keywords)
897
+ ),
898
+ None,
899
+ )
900
+
901
+ confirmation_code = None
902
+ if code_pattern:
903
+ match = code_pattern.search(body)
904
+ if match:
905
+ confirmation_code = match.group(1) if match.lastindex else match.group(0)
906
+
907
+ if confirmation_link is None and confirmation_code is None:
908
+ continue
909
+
910
+ logger.info(
911
+ "Confirmation email found from %s (subject: %s)",
912
+ msg.from_address,
913
+ msg.subject,
914
+ )
915
+ return ExtractedConfirmation(
916
+ message_id=message_id,
917
+ from_address=msg.from_address,
918
+ subject=msg.subject,
919
+ confirmation_link=confirmation_link,
920
+ confirmation_code=confirmation_code,
921
+ )
922
+
923
+ remaining = deadline - loop.time()
924
+ if remaining <= 0:
925
+ break
926
+ await asyncio.sleep(min(check_interval, remaining))
927
+
928
+ return None
929
+
930
+ async def wait_for_code(
931
+ self,
932
+ mailbox_id: int | Mailbox,
933
+ *,
934
+ pattern: str = r"\b(\d{4,8})\b",
935
+ sender_domain: str | None = None,
936
+ timeout: float = 60.0,
937
+ check_interval: float = 3.0,
938
+ ) -> str:
939
+ """Wait for an email and return the digit confirmation code as a string.
940
+
941
+ Raises WaitTimeoutError when timeout expires.
942
+ """
943
+ res = await self.wait_for_confirmation_email(
944
+ mailbox_id,
945
+ sender_domain=sender_domain,
946
+ digit_code_mask=pattern,
947
+ timeout=timeout,
948
+ check_interval=check_interval,
949
+ )
950
+ if not res or not res.get("confirmation_code"):
951
+ raise WaitTimeoutError(f"no verification code arrived within {timeout}s")
952
+ return str(res["confirmation_code"])
953
+
954
+ async def wait_for_link(
955
+ self,
956
+ mailbox_id: int | Mailbox,
957
+ *,
958
+ url_contain: builtins.list[str] | None = None,
959
+ sender_domain: str | None = None,
960
+ timeout: float = 60.0,
961
+ check_interval: float = 3.0,
962
+ ) -> str:
963
+ """Wait for an email and return the confirmation URL.
964
+
965
+ Raises WaitTimeoutError when timeout expires.
966
+ """
967
+ res = await self.wait_for_confirmation_email(
968
+ mailbox_id,
969
+ sender_domain=sender_domain,
970
+ url_contain=url_contain or ["http"],
971
+ timeout=timeout,
972
+ check_interval=check_interval,
973
+ )
974
+ if not res or not res.get("confirmation_link"):
975
+ raise WaitTimeoutError(f"no confirmation link arrived within {timeout}s")
976
+ return str(res["confirmation_link"])
977
+
978
+ async def iter_messages(
979
+ self,
980
+ mailbox_id: int | Mailbox,
981
+ *,
982
+ poll_interval: float = 3.0,
983
+ timeout: float | None = None,
984
+ ) -> AsyncIterator[MessageSummary]:
985
+ """Async generator: continuously polls the mailbox and yields new messages as they arrive."""
986
+ mid = _mailbox_id_val(mailbox_id)
987
+ seen_ids: set[str] = set()
988
+ loop = asyncio.get_running_loop()
989
+ deadline = (loop.time() + timeout) if timeout is not None else None
990
+
991
+ for m in await self.list(mid):
992
+ seen_ids.add(m.message_api_id)
993
+
994
+ while True:
995
+ if deadline is not None and loop.time() >= deadline:
996
+ break
997
+ for m in await self.list(mid):
998
+ if m.message_api_id not in seen_ids:
999
+ seen_ids.add(m.message_api_id)
1000
+ yield m
1001
+ if deadline is not None and loop.time() + poll_interval >= deadline:
1002
+ break
1003
+ await asyncio.sleep(poll_interval)
1004
+
1005
+
1006
+ class GmailAPI:
1007
+ """OAuth flow and Gmail account management."""
1008
+
1009
+ def __init__(self, transport: _Transport) -> None:
1010
+ self._t = transport
1011
+
1012
+ async def oauth_start(self) -> OAuthStart:
1013
+ """OAuth authorization URL: open in browser to link any Google account."""
1014
+ return OAuthStart.model_validate(await self._t.request("POST", "/v1/gmail/oauth/start"))
1015
+
1016
+ async def import_account(
1017
+ self,
1018
+ *,
1019
+ refresh_token: str | None = None,
1020
+ token_json: Any = None,
1021
+ ) -> GmailAccount:
1022
+ """Import an existing token (refresh_token or token_json)."""
1023
+ payload: dict[str, Any] = {}
1024
+ if refresh_token:
1025
+ payload["refresh_token"] = refresh_token
1026
+ if token_json is not None:
1027
+ payload["token_json"] = token_json
1028
+ return GmailAccount.model_validate(
1029
+ await self._t.request("POST", "/v1/gmail/accounts/import", json=payload)
1030
+ )
1031
+
1032
+ async def accounts(self) -> list[GmailAccount]:
1033
+ return [
1034
+ GmailAccount.model_validate(a)
1035
+ for a in await self._t.request("GET", "/v1/gmail/accounts")
1036
+ ]
1037
+
1038
+ async def delete_account(self, parent_id: int) -> None:
1039
+ await self._t.request("DELETE", f"/v1/gmail/accounts/{parent_id}")
1040
+
1041
+ async def sync(self, parent_id: int) -> GmailAccount:
1042
+ """Sync account profile and send-as aliases."""
1043
+ return GmailAccount.model_validate(
1044
+ await self._t.request("POST", f"/v1/gmail/accounts/{parent_id}/sync")
1045
+ )
1046
+
1047
+
1048
+ class KeysAPI:
1049
+ """API key management: list, create, view, rotate, delete."""
1050
+
1051
+ def __init__(self, transport: _Transport) -> None:
1052
+ self._t = transport
1053
+
1054
+ async def list(self) -> builtins.list[ApiKeyOut]:
1055
+ """Root only. List issued keys (without secrets)."""
1056
+ data = await self._t.request("GET", "/v1/admin/keys")
1057
+ return [ApiKeyOut.model_validate(k) for k in data]
1058
+
1059
+ async def create(
1060
+ self,
1061
+ *,
1062
+ name: str,
1063
+ expires_at: str | None = None,
1064
+ scope: dict[str, Any] | None = None,
1065
+ retention: builtins.list[dict[str, Any]] | None = None,
1066
+ ) -> ApiKeyCreatedOut:
1067
+ """Root only. Create a key (secret returned once in creation response)."""
1068
+ payload: dict[str, Any] = {
1069
+ "name": name,
1070
+ "expires_at": expires_at,
1071
+ "scope": scope or {"providers": [], "parent_ids": [], "actions": []},
1072
+ "retention": retention or [],
1073
+ }
1074
+ return ApiKeyCreatedOut.model_validate(
1075
+ await self._t.request("POST", "/v1/admin/keys", json=payload)
1076
+ )
1077
+
1078
+ async def get(self, key_id: int) -> ApiKeyOut:
1079
+ """Root only. Get key details by ID (without secret)."""
1080
+ return ApiKeyOut.model_validate(await self._t.request("GET", f"/v1/admin/keys/{key_id}"))
1081
+
1082
+ async def me(self) -> ApiKeyMeOut:
1083
+ """Get current active key details (from X-API-Key header)."""
1084
+ return ApiKeyMeOut.model_validate(await self._t.request("GET", "/v1/admin/keys/me"))
1085
+
1086
+ async def secret(self, key_id: int) -> str:
1087
+ """Root only. Reveal key secret."""
1088
+ data = await self._t.request("GET", f"/v1/admin/keys/{key_id}/secret")
1089
+ return str(data.get("secret") or "")
1090
+
1091
+ async def update(
1092
+ self,
1093
+ key_id: int,
1094
+ *,
1095
+ name: str,
1096
+ expires_at: str | None = None,
1097
+ scope: dict[str, Any] | None = None,
1098
+ ) -> ApiKeyOut:
1099
+ """Root only. Update key name, expiration date, or scope."""
1100
+ payload = {
1101
+ "name": name,
1102
+ "expires_at": expires_at,
1103
+ "scope": scope or {"providers": [], "parent_ids": [], "actions": []},
1104
+ }
1105
+ return ApiKeyOut.model_validate(
1106
+ await self._t.request("PATCH", f"/v1/admin/keys/{key_id}", json=payload)
1107
+ )
1108
+
1109
+ async def retention_set(
1110
+ self, key_id: int, retention: builtins.list[dict[str, Any]]
1111
+ ) -> builtins.list[KeyRetentionOut]:
1112
+ """Root only. Set custom retention TTL rules for the key."""
1113
+ data = await self._t.request("PUT", f"/v1/admin/keys/{key_id}/retention", json=retention)
1114
+ return [KeyRetentionOut.model_validate(r) for r in data]
1115
+
1116
+ async def rotate(self, key_id: int) -> ApiKeyCreatedOut:
1117
+ """Root only. Rotate key secret (revokes old secret, returns new secret once)."""
1118
+ return ApiKeyCreatedOut.model_validate(
1119
+ await self._t.request("POST", f"/v1/admin/keys/{key_id}/rotate")
1120
+ )
1121
+
1122
+ async def delete(self, key_id: int) -> None:
1123
+ """Root only. Delete key."""
1124
+ await self._t.request("DELETE", f"/v1/admin/keys/{key_id}")
1125
+
1126
+
1127
+ class AdminAPI:
1128
+ """Admin API: statistics, retention (TTL cleanup), domain cache, Gmail credentials, keys."""
1129
+
1130
+ def __init__(self, transport: _Transport) -> None:
1131
+ self._t = transport
1132
+ self.keys_api = KeysAPI(transport)
1133
+
1134
+ async def stats(self) -> dict:
1135
+ """Statistics: mailboxes, messages, providers, and 7-day trend."""
1136
+ return await self._t.request("GET", "/v1/admin/stats")
1137
+
1138
+ async def retention_list(self) -> list[Retention]:
1139
+ return [
1140
+ Retention.model_validate(r) for r in await self._t.request("GET", "/v1/admin/retention")
1141
+ ]
1142
+
1143
+ async def retention_set(
1144
+ self,
1145
+ provider: str,
1146
+ *,
1147
+ mailbox_ttl_hours: int | None = None,
1148
+ message_ttl_hours: int | None = None,
1149
+ ) -> Retention:
1150
+ """Enable or update retention TTL for a provider (at least one TTL required)."""
1151
+ payload = {
1152
+ k: v
1153
+ for k, v in {
1154
+ "mailbox_ttl_hours": mailbox_ttl_hours,
1155
+ "message_ttl_hours": message_ttl_hours,
1156
+ }.items()
1157
+ if v is not None
1158
+ }
1159
+ return Retention.model_validate(
1160
+ await self._t.request(
1161
+ "PUT", f"/v1/admin/retention/{quote(provider, safe='')}", json=payload
1162
+ )
1163
+ )
1164
+
1165
+ async def retention_disable(self, provider: str) -> None:
1166
+ await self._t.request("DELETE", f"/v1/admin/retention/{quote(provider, safe='')}")
1167
+
1168
+ async def retention_run(self, *, force_maintenance: bool = False) -> SweepResult:
1169
+ """Run cleanup now: sweep expired mailboxes and messages."""
1170
+ return SweepResult.model_validate(
1171
+ await self._t.request(
1172
+ "POST",
1173
+ "/v1/admin/retention/run",
1174
+ params={"force_maintenance": "true"} if force_maintenance else None,
1175
+ )
1176
+ )
1177
+
1178
+ async def domain_cache(self) -> DomainCache:
1179
+ """Current domain cache status (enabled, interval, providers)."""
1180
+ return DomainCache.model_validate(await self._t.request("GET", "/v1/admin/domain-cache"))
1181
+
1182
+ async def domain_cache_save(
1183
+ self,
1184
+ *,
1185
+ enabled: bool | None = None,
1186
+ interval_seconds: int | None = None,
1187
+ ) -> DomainCache:
1188
+ """Update domain cache settings (at least one parameter required)."""
1189
+ payload = {
1190
+ k: v
1191
+ for k, v in {
1192
+ "enabled": enabled,
1193
+ "interval_seconds": interval_seconds,
1194
+ }.items()
1195
+ if v is not None
1196
+ }
1197
+ return DomainCache.model_validate(
1198
+ await self._t.request("PUT", "/v1/admin/domain-cache", json=payload)
1199
+ )
1200
+
1201
+ async def domain_cache_run(self) -> DomainCacheRun:
1202
+ """Force refresh domain cache now."""
1203
+ return DomainCacheRun.model_validate(
1204
+ await self._t.request("POST", "/v1/admin/domain-cache/run")
1205
+ )
1206
+
1207
+ async def gmail_creds(self) -> GmailCreds:
1208
+ return GmailCreds.model_validate(await self._t.request("GET", "/v1/admin/gmail/creds"))
1209
+
1210
+ async def gmail_creds_save(
1211
+ self,
1212
+ client_id: str,
1213
+ client_secret: str,
1214
+ redirect_uri: str | None = None,
1215
+ ) -> GmailCreds:
1216
+ payload: dict[str, str] = {"client_id": client_id, "client_secret": client_secret}
1217
+ if redirect_uri:
1218
+ payload["redirect_uri"] = redirect_uri
1219
+ return GmailCreds.model_validate(
1220
+ await self._t.request("PUT", "/v1/admin/gmail/creds", json=payload)
1221
+ )
1222
+
1223
+ async def gmail_creds_clear(self) -> None:
1224
+ await self._t.request("DELETE", "/v1/admin/gmail/creds")
1225
+
1226
+ async def keys(self) -> list[dict[str, Any]]:
1227
+ """Root only. Lists keys; the secret is not in the response."""
1228
+ return await self._t.request("GET", "/v1/admin/keys")
1229
+
1230
+ async def key_create(
1231
+ self,
1232
+ *,
1233
+ name: str,
1234
+ expires_at: str | None,
1235
+ scope: dict[str, Any],
1236
+ retention: list[dict[str, Any]],
1237
+ ) -> dict[str, Any]:
1238
+ """Root only. Creates a key; the response includes the secret once."""
1239
+ return await self._t.request(
1240
+ "POST",
1241
+ "/v1/admin/keys",
1242
+ json={
1243
+ "name": name,
1244
+ "expires_at": expires_at,
1245
+ "scope": scope,
1246
+ "retention": retention,
1247
+ },
1248
+ )
1249
+
1250
+ async def key_get(self, key_id: int) -> dict[str, Any]:
1251
+ """Root only. Returns one key without the secret."""
1252
+ return await self._t.request("GET", f"/v1/admin/keys/{key_id}")
1253
+
1254
+ async def key_me(self) -> dict[str, Any]:
1255
+ """Any authenticated key. Root gets is_root; an issued key gets no secret."""
1256
+ return await self._t.request("GET", "/v1/admin/keys/me")
1257
+
1258
+ async def key_secret(self, key_id: int) -> dict[str, Any]:
1259
+ """Root only. Reveals the secret."""
1260
+ return await self._t.request("GET", f"/v1/admin/keys/{key_id}/secret")
1261
+
1262
+ async def key_update(
1263
+ self,
1264
+ key_id: int,
1265
+ *,
1266
+ name: str,
1267
+ expires_at: str | None,
1268
+ scope: dict[str, Any],
1269
+ ) -> dict[str, Any]:
1270
+ """Root only. Updates name, expiry, and scope; the secret is unchanged."""
1271
+ return await self._t.request(
1272
+ "PATCH",
1273
+ f"/v1/admin/keys/{key_id}",
1274
+ json={"name": name, "expires_at": expires_at, "scope": scope},
1275
+ )
1276
+
1277
+ async def key_retention_set(
1278
+ self, key_id: int, retention: list[dict[str, Any]]
1279
+ ) -> list[dict[str, Any]]:
1280
+ """Root only. Replaces that key's retention rows."""
1281
+ return await self._t.request("PUT", f"/v1/admin/keys/{key_id}/retention", json=retention)
1282
+
1283
+ async def key_rotate(self, key_id: int) -> dict[str, Any]:
1284
+ """Root only. Rotates the secret; the response includes the new secret once."""
1285
+ return await self._t.request("POST", f"/v1/admin/keys/{key_id}/rotate")
1286
+
1287
+ async def key_delete(self, key_id: int) -> None:
1288
+ """Root only. Deletes the key."""
1289
+ await self._t.request("DELETE", f"/v1/admin/keys/{key_id}")
1290
+
1291
+
1292
+ # --------------------------------------------------------------------------- #
1293
+ # Public client
1294
+ # --------------------------------------------------------------------------- #
1295
+ class Maily:
1296
+ """Async client for maily. Use via `async with` or close manually.
1297
+
1298
+ async with Maily("http://127.0.0.1:8477", api_key="sk-…") as api:
1299
+ box = await api.create_temp_mailbox()
1300
+ code = await api.wait_for_code(box)
1301
+ print("Verification code:", code)
1302
+
1303
+ Sections: .health, .config, .providers, .mailboxes, .messages, .gmail, .admin, .keys
1304
+ """
1305
+
1306
+ def __init__(
1307
+ self,
1308
+ base_url: str = "http://127.0.0.1:8477",
1309
+ api_key: str = "",
1310
+ timeout: float = 30.0,
1311
+ *,
1312
+ client: httpx.AsyncClient | None = None,
1313
+ max_retries: int = 0,
1314
+ retry_backoff: float = 0.5,
1315
+ ) -> None:
1316
+ self._transport = _Transport(
1317
+ base_url,
1318
+ api_key,
1319
+ timeout,
1320
+ client=client,
1321
+ max_retries=max_retries,
1322
+ retry_backoff=retry_backoff,
1323
+ )
1324
+ self.health = HealthAPI(self._transport)
1325
+ self.config = ConfigAPI(self._transport)
1326
+ self.providers = ProvidersAPI(self._transport)
1327
+ self.mailboxes = MailboxesAPI(self._transport)
1328
+ self.messages = MessagesAPI(self._transport)
1329
+ self.gmail = GmailAPI(self._transport)
1330
+ self.admin = AdminAPI(self._transport)
1331
+ self.keys = KeysAPI(self._transport)
1332
+
1333
+ @property
1334
+ def max_retries(self) -> int:
1335
+ """Client-side retry limit for transient network/server/rate-limit errors."""
1336
+ return self._transport.max_retries
1337
+
1338
+ @max_retries.setter
1339
+ def max_retries(self, value: int) -> None:
1340
+ self._transport.max_retries = max(0, int(value))
1341
+
1342
+ @property
1343
+ def retry_backoff(self) -> float:
1344
+ """Base backoff in seconds for exponential retry backoff."""
1345
+ return self._transport.retry_backoff
1346
+
1347
+ @retry_backoff.setter
1348
+ def retry_backoff(self, value: float) -> None:
1349
+ self._transport.retry_backoff = max(0.0, float(value))
1350
+
1351
+ def set_api_key(self, api_key: str) -> None:
1352
+ """Update API key dynamically."""
1353
+ self._transport.set_api_key(api_key)
1354
+
1355
+ async def check_client_version(self) -> str:
1356
+ """Server version from /v1/config. Warns on version mismatch."""
1357
+ cfg = await self.config.get()
1358
+ remote = str(cfg.get("client_version") or "")
1359
+ if remote and remote != __version__:
1360
+ logger.warning(
1361
+ "client {} != server {} — update client: `maily-client update`",
1362
+ __version__,
1363
+ remote,
1364
+ )
1365
+ return remote
1366
+
1367
+ async def create_temp_mailbox(
1368
+ self,
1369
+ *,
1370
+ provider: str | None = None,
1371
+ domain: str | None = None,
1372
+ ) -> Mailbox:
1373
+ """Convenience helper: create a temporary mailbox in one call.
1374
+
1375
+ If domain is omitted, automatically picks the first available provider domain.
1376
+ """
1377
+ if not domain and not provider:
1378
+ targets = await self.providers.targets()
1379
+ domain_targets = [t for t in targets if t.kind == "domain"]
1380
+ if domain_targets:
1381
+ domain = domain_targets[0].label
1382
+ provider = domain_targets[0].provider
1383
+ elif not domain and provider:
1384
+ targets = await self.providers.targets(provider=provider)
1385
+ if targets:
1386
+ domain = targets[0].label
1387
+ return await self.mailboxes.create(provider=provider, domain=domain)
1388
+
1389
+ async def wait_for_code(
1390
+ self,
1391
+ mailbox_id: int | Mailbox,
1392
+ *,
1393
+ pattern: str = r"\b(\d{4,8})\b",
1394
+ sender_domain: str | None = None,
1395
+ timeout: float = 60.0,
1396
+ check_interval: float = 3.0,
1397
+ ) -> str:
1398
+ """Poll mailbox until an email with a digit verification code arrives."""
1399
+ return await self.messages.wait_for_code(
1400
+ mailbox_id,
1401
+ pattern=pattern,
1402
+ sender_domain=sender_domain,
1403
+ timeout=timeout,
1404
+ check_interval=check_interval,
1405
+ )
1406
+
1407
+ async def wait_for_link(
1408
+ self,
1409
+ mailbox_id: int | Mailbox,
1410
+ *,
1411
+ url_contain: builtins.list[str] | None = None,
1412
+ sender_domain: str | None = None,
1413
+ timeout: float = 60.0,
1414
+ check_interval: float = 3.0,
1415
+ ) -> str:
1416
+ """Poll mailbox until an email with a confirmation link arrives."""
1417
+ return await self.messages.wait_for_link(
1418
+ mailbox_id,
1419
+ url_contain=url_contain,
1420
+ sender_domain=sender_domain,
1421
+ timeout=timeout,
1422
+ check_interval=check_interval,
1423
+ )
1424
+
1425
+ async def wait_for_confirmation_email(
1426
+ self,
1427
+ mailbox_id: int | Mailbox,
1428
+ *,
1429
+ sender_domain: str | None = None,
1430
+ url_contain: builtins.list[str] | None = None,
1431
+ digit_code_mask: str | None = None,
1432
+ timeout: float = 60.0,
1433
+ check_interval: float = 5.0,
1434
+ ) -> ExtractedConfirmation | None:
1435
+ """Wait for a confirmation email and extract confirmation link and/or code."""
1436
+ return await self.messages.wait_for_confirmation_email(
1437
+ mailbox_id,
1438
+ sender_domain=sender_domain,
1439
+ url_contain=url_contain,
1440
+ digit_code_mask=digit_code_mask,
1441
+ timeout=timeout,
1442
+ check_interval=check_interval,
1443
+ )
1444
+
1445
+ async def close(self) -> None:
1446
+ await self._transport.close()
1447
+
1448
+ async def __aenter__(self) -> Maily:
1449
+ return self
1450
+
1451
+ async def __aexit__(self, *_exc: Any) -> None:
1452
+ await self.close()
1453
+
1454
+ # --- SDK-compatible flat methods (deprecated, use sections above) ---
1455
+
1456
+ async def create_mailbox(
1457
+ self,
1458
+ *,
1459
+ provider: str | None = None,
1460
+ domain: str | None = None,
1461
+ parent_id: int | None = None,
1462
+ address: str | None = None,
1463
+ password: str | None = None,
1464
+ ) -> Mailbox:
1465
+ return await self.mailboxes.create(
1466
+ provider=provider,
1467
+ domain=domain,
1468
+ parent_id=parent_id,
1469
+ address=address,
1470
+ password=password,
1471
+ )
1472
+
1473
+ async def list_mailboxes(
1474
+ self, provider: str | None = None, active: bool | None = None
1475
+ ) -> list[Mailbox]:
1476
+ return await self.mailboxes.list(provider=provider, active=active)
1477
+
1478
+ async def mailbox(self, mailbox_id: int | Mailbox) -> Mailbox:
1479
+ return await self.mailboxes.get(mailbox_id)
1480
+
1481
+ async def mailbox_by_address(self, email: str) -> Mailbox:
1482
+ return await self.mailboxes.by_address(email)
1483
+
1484
+ async def delete_mailbox(self, mailbox_id: int | Mailbox) -> None:
1485
+ await self.mailboxes.delete(mailbox_id)
1486
+
1487
+ async def list_messages(self, mailbox_id: int | Mailbox, page: int = 1) -> list[MessageSummary]:
1488
+ return await self.messages.list(mailbox_id, page=page)
1489
+
1490
+ async def get_message(self, mailbox_id: int | Mailbox, message_id: str) -> MessageDetail:
1491
+ return await self.messages.get(mailbox_id, message_id)
1492
+
1493
+ async def mark_read(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSummary:
1494
+ return await self.messages.mark_read(mailbox_id, message_id)
1495
+
1496
+ async def delete_message(self, mailbox_id: int | Mailbox, message_id: str) -> None:
1497
+ await self.messages.delete(mailbox_id, message_id)
1498
+
1499
+ async def get_source(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSource:
1500
+ return await self.messages.source(mailbox_id, message_id)
1501
+
1502
+ async def gmail_oauth_start(self) -> OAuthStart:
1503
+ return await self.gmail.oauth_start()
1504
+
1505
+ async def gmail_import_account(
1506
+ self, *, refresh_token: str | None = None, token_json: Any = None
1507
+ ) -> GmailAccount:
1508
+ return await self.gmail.import_account(refresh_token=refresh_token, token_json=token_json)
1509
+
1510
+ async def list_gmail_accounts(self) -> list[GmailAccount]:
1511
+ return await self.gmail.accounts()
1512
+
1513
+ async def delete_gmail_account(self, parent_id: int) -> None:
1514
+ await self.gmail.delete_account(parent_id)
1515
+
1516
+ async def sync_gmail_account(self, parent_id: int) -> GmailAccount:
1517
+ return await self.gmail.sync(parent_id)
1518
+
1519
+ async def domain_cache_settings(self) -> DomainCache:
1520
+ return await self.admin.domain_cache()
1521
+
1522
+ async def save_domain_cache_settings(
1523
+ self, *, enabled: bool | None = None, interval_seconds: int | None = None
1524
+ ) -> DomainCache:
1525
+ return await self.admin.domain_cache_save(
1526
+ enabled=enabled, interval_seconds=interval_seconds
1527
+ )
1528
+
1529
+ async def refresh_domain_cache(self) -> DomainCacheRun:
1530
+ return await self.admin.domain_cache_run()
1531
+
1532
+ async def wait_for_message(
1533
+ self,
1534
+ mailbox_id: int | Mailbox,
1535
+ *,
1536
+ timeout: float = 120.0,
1537
+ interval: float = 2.0,
1538
+ predicate: Callable[[MessageSummary], bool] | None = None,
1539
+ ) -> MessageSummary:
1540
+ return await self.messages.wait_for(
1541
+ mailbox_id, timeout=timeout, interval=interval, predicate=predicate
1542
+ )
1543
+
1544
+
1545
+ # --------------------------------------------------------------------------- #
1546
+ # Synchronous section wrappers
1547
+ # --------------------------------------------------------------------------- #
1548
+ class SyncHealthAPI:
1549
+ """GET /health — service health check (no API key required)."""
1550
+
1551
+ def __init__(self, async_api: HealthAPI, run: Callable[[Any], Any]) -> None:
1552
+ self._async = async_api
1553
+ self._run = run
1554
+
1555
+ def ping(self) -> HealthOut:
1556
+ return self._run(self._async.ping())
1557
+
1558
+
1559
+ class SyncConfigAPI:
1560
+ """GET /v1/config — public server facts (no API key required)."""
1561
+
1562
+ def __init__(self, async_api: ConfigAPI, run: Callable[[Any], Any]) -> None:
1563
+ self._async = async_api
1564
+ self._run = run
1565
+
1566
+ def get(self) -> dict[str, Any]:
1567
+ return self._run(self._async.get())
1568
+
1569
+
1570
+ class SyncProvidersAPI:
1571
+ """Providers and targets (domains / parent accounts)."""
1572
+
1573
+ def __init__(self, async_api: ProvidersAPI, run: Callable[[Any], Any]) -> None:
1574
+ self._async = async_api
1575
+ self._run = run
1576
+
1577
+ def list(self) -> list[Provider]:
1578
+ """All available providers."""
1579
+ return self._run(self._async.list())
1580
+
1581
+ def targets(self, provider: str | None = None) -> builtins.list[Target]:
1582
+ """Mailbox creation targets: domains (domain providers) or Gmail accounts."""
1583
+ return self._run(self._async.targets(provider=provider))
1584
+
1585
+
1586
+ class SyncMailboxesAPI:
1587
+ """Mailbox creation, listing, retrieval, and deletion."""
1588
+
1589
+ def __init__(self, async_api: MailboxesAPI, run: Callable[[Any], Any]) -> None:
1590
+ self._async = async_api
1591
+ self._run = run
1592
+
1593
+ def create(
1594
+ self,
1595
+ *,
1596
+ provider: str | None = None,
1597
+ domain: str | None = None,
1598
+ parent_id: int | None = None,
1599
+ address: str | None = None,
1600
+ password: str | None = None,
1601
+ ) -> Mailbox:
1602
+ """Create a mailbox. Use domain for domain providers, or parent_id for Gmail."""
1603
+ return self._run(
1604
+ self._async.create(
1605
+ provider=provider,
1606
+ domain=domain,
1607
+ parent_id=parent_id,
1608
+ address=address,
1609
+ password=password,
1610
+ )
1611
+ )
1612
+
1613
+ def list(
1614
+ self,
1615
+ *,
1616
+ provider: str | None = None,
1617
+ active: bool | None = None,
1618
+ q: str | None = None,
1619
+ limit: int | None = None,
1620
+ offset: int | None = None,
1621
+ ) -> list[Mailbox]:
1622
+ """List mailboxes. active=None returns all (including deleted)."""
1623
+ return self._run(
1624
+ self._async.list(
1625
+ provider=provider,
1626
+ active=active,
1627
+ q=q,
1628
+ limit=limit,
1629
+ offset=offset,
1630
+ )
1631
+ )
1632
+
1633
+ def get(self, mailbox_id: int | Mailbox) -> Mailbox:
1634
+ return self._run(self._async.get(mailbox_id))
1635
+
1636
+ def by_address(self, email: str) -> Mailbox:
1637
+ """Find mailbox by full email address."""
1638
+ return self._run(self._async.by_address(email))
1639
+
1640
+ def delete(self, mailbox_id: int | Mailbox) -> None:
1641
+ """Delete mailbox (both upstream at provider and locally)."""
1642
+ return self._run(self._async.delete(mailbox_id))
1643
+
1644
+
1645
+ class SyncMessagesAPI:
1646
+ """Mailbox messages: list, read, mark, delete, and wait for incoming mail."""
1647
+
1648
+ def __init__(self, async_api: MessagesAPI, run: Callable[[Any], Any]) -> None:
1649
+ self._async = async_api
1650
+ self._run = run
1651
+
1652
+ def list(self, mailbox_id: int | Mailbox, page: int = 1) -> list[MessageSummary]:
1653
+ """List messages (paginated)."""
1654
+ return self._run(self._async.list(mailbox_id, page=page))
1655
+
1656
+ def get(self, mailbox_id: int | Mailbox, message_id: str) -> MessageDetail:
1657
+ """Full message detail including plain text and HTML."""
1658
+ return self._run(self._async.get(mailbox_id, message_id))
1659
+
1660
+ def mark_read(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSummary:
1661
+ return self._run(self._async.mark_read(mailbox_id, message_id))
1662
+
1663
+ def delete(self, mailbox_id: int | Mailbox, message_id: str) -> None:
1664
+ return self._run(self._async.delete(mailbox_id, message_id))
1665
+
1666
+ def source(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSource:
1667
+ """Raw message source (RFC 822 / MIME)."""
1668
+ return self._run(self._async.source(mailbox_id, message_id))
1669
+
1670
+ def wait_for(
1671
+ self,
1672
+ mailbox_id: int | Mailbox,
1673
+ *,
1674
+ timeout: float = 120.0,
1675
+ interval: float = 2.0,
1676
+ predicate: Callable[[MessageSummary], bool] | None = None,
1677
+ mark_seen: bool = False,
1678
+ ) -> MessageSummary:
1679
+ """Poll the message list until a matching message arrives (defaults to any)."""
1680
+ return self._run(
1681
+ self._async.wait_for(
1682
+ mailbox_id,
1683
+ timeout=timeout,
1684
+ interval=interval,
1685
+ predicate=predicate,
1686
+ mark_seen=mark_seen,
1687
+ )
1688
+ )
1689
+
1690
+ def wait_for_confirmation_email(
1691
+ self,
1692
+ mailbox_id: int | Mailbox,
1693
+ *,
1694
+ sender_domain: str | None = None,
1695
+ url_contain: builtins.list[str] | None = None,
1696
+ digit_code_mask: str | None = None,
1697
+ timeout: float = 60.0,
1698
+ check_interval: float = 5.0,
1699
+ ) -> ExtractedConfirmation | None:
1700
+ """Wait for a confirmation email and extract confirmation link and/or code."""
1701
+ return self._run(
1702
+ self._async.wait_for_confirmation_email(
1703
+ mailbox_id,
1704
+ sender_domain=sender_domain,
1705
+ url_contain=url_contain,
1706
+ digit_code_mask=digit_code_mask,
1707
+ timeout=timeout,
1708
+ check_interval=check_interval,
1709
+ )
1710
+ )
1711
+
1712
+ def wait_for_code(
1713
+ self,
1714
+ mailbox_id: int | Mailbox,
1715
+ *,
1716
+ pattern: str = r"\b(\d{4,8})\b",
1717
+ sender_domain: str | None = None,
1718
+ timeout: float = 60.0,
1719
+ check_interval: float = 3.0,
1720
+ ) -> str:
1721
+ """Wait for an email and return the digit confirmation code as a string."""
1722
+ return self._run(
1723
+ self._async.wait_for_code(
1724
+ mailbox_id,
1725
+ pattern=pattern,
1726
+ sender_domain=sender_domain,
1727
+ timeout=timeout,
1728
+ check_interval=check_interval,
1729
+ )
1730
+ )
1731
+
1732
+ def wait_for_link(
1733
+ self,
1734
+ mailbox_id: int | Mailbox,
1735
+ *,
1736
+ url_contain: builtins.list[str] | None = None,
1737
+ sender_domain: str | None = None,
1738
+ timeout: float = 60.0,
1739
+ check_interval: float = 3.0,
1740
+ ) -> str:
1741
+ """Wait for an email and return the confirmation URL."""
1742
+ return self._run(
1743
+ self._async.wait_for_link(
1744
+ mailbox_id,
1745
+ url_contain=url_contain,
1746
+ sender_domain=sender_domain,
1747
+ timeout=timeout,
1748
+ check_interval=check_interval,
1749
+ )
1750
+ )
1751
+
1752
+ def iter_messages(
1753
+ self,
1754
+ mailbox_id: int | Mailbox,
1755
+ *,
1756
+ poll_interval: float = 3.0,
1757
+ timeout: float | None = None,
1758
+ ) -> Iterator[MessageSummary]:
1759
+ """Continuously poll mailbox and yield new messages as they arrive."""
1760
+ mid = _mailbox_id_val(mailbox_id)
1761
+ seen_ids: set[str] = set()
1762
+ deadline = (time.monotonic() + timeout) if timeout is not None else None
1763
+
1764
+ for m in self.list(mid):
1765
+ seen_ids.add(m.message_api_id)
1766
+
1767
+ while True:
1768
+ if deadline is not None and time.monotonic() >= deadline:
1769
+ break
1770
+ for m in self.list(mid):
1771
+ if m.message_api_id not in seen_ids:
1772
+ seen_ids.add(m.message_api_id)
1773
+ yield m
1774
+ if deadline is not None and time.monotonic() + poll_interval >= deadline:
1775
+ break
1776
+ time.sleep(poll_interval)
1777
+
1778
+
1779
+ class SyncGmailAPI:
1780
+ """OAuth flow and Gmail account management."""
1781
+
1782
+ def __init__(self, async_api: GmailAPI, run: Callable[[Any], Any]) -> None:
1783
+ self._async = async_api
1784
+ self._run = run
1785
+
1786
+ def oauth_start(self) -> OAuthStart:
1787
+ """OAuth authorization URL: open in browser to link any Google account."""
1788
+ return self._run(self._async.oauth_start())
1789
+
1790
+ def import_account(
1791
+ self,
1792
+ *,
1793
+ refresh_token: str | None = None,
1794
+ token_json: Any = None,
1795
+ ) -> GmailAccount:
1796
+ """Import an existing token (refresh_token or token_json)."""
1797
+ return self._run(
1798
+ self._async.import_account(refresh_token=refresh_token, token_json=token_json)
1799
+ )
1800
+
1801
+ def accounts(self) -> list[GmailAccount]:
1802
+ return self._run(self._async.accounts())
1803
+
1804
+ def delete_account(self, parent_id: int) -> None:
1805
+ return self._run(self._async.delete_account(parent_id))
1806
+
1807
+ def sync(self, parent_id: int) -> GmailAccount:
1808
+ """Sync account profile and send-as aliases."""
1809
+ return self._run(self._async.sync(parent_id))
1810
+
1811
+
1812
+ class SyncKeysAPI:
1813
+ """API key management: list, create, view, rotate, delete."""
1814
+
1815
+ def __init__(self, async_api: KeysAPI, run: Callable[[Any], Any]) -> None:
1816
+ self._async = async_api
1817
+ self._run = run
1818
+
1819
+ def list(self) -> builtins.list[ApiKeyOut]:
1820
+ """Root only. List issued keys (without secrets)."""
1821
+ return self._run(self._async.list())
1822
+
1823
+ def create(
1824
+ self,
1825
+ *,
1826
+ name: str,
1827
+ expires_at: str | None = None,
1828
+ scope: dict[str, Any] | None = None,
1829
+ retention: builtins.list[dict[str, Any]] | None = None,
1830
+ ) -> ApiKeyCreatedOut:
1831
+ """Root only. Create a key (secret returned once in creation response)."""
1832
+ return self._run(
1833
+ self._async.create(
1834
+ name=name,
1835
+ expires_at=expires_at,
1836
+ scope=scope,
1837
+ retention=retention,
1838
+ )
1839
+ )
1840
+
1841
+ def get(self, key_id: int) -> ApiKeyOut:
1842
+ """Root only. Get key details by ID (without secret)."""
1843
+ return self._run(self._async.get(key_id))
1844
+
1845
+ def me(self) -> ApiKeyMeOut:
1846
+ """Get current active key details (from X-API-Key header)."""
1847
+ return self._run(self._async.me())
1848
+
1849
+ def secret(self, key_id: int) -> str:
1850
+ """Root only. Reveal key secret."""
1851
+ return self._run(self._async.secret(key_id))
1852
+
1853
+ def update(
1854
+ self,
1855
+ key_id: int,
1856
+ *,
1857
+ name: str,
1858
+ expires_at: str | None = None,
1859
+ scope: dict[str, Any] | None = None,
1860
+ ) -> ApiKeyOut:
1861
+ """Root only. Update key name, expiration date, or scope."""
1862
+ return self._run(
1863
+ self._async.update(
1864
+ key_id,
1865
+ name=name,
1866
+ expires_at=expires_at,
1867
+ scope=scope,
1868
+ )
1869
+ )
1870
+
1871
+ def retention_set(
1872
+ self, key_id: int, retention: builtins.list[dict[str, Any]]
1873
+ ) -> builtins.list[KeyRetentionOut]:
1874
+ """Root only. Set custom retention TTL rules for the key."""
1875
+ return self._run(self._async.retention_set(key_id, retention))
1876
+
1877
+ def rotate(self, key_id: int) -> ApiKeyCreatedOut:
1878
+ """Root only. Rotate key secret (revokes old secret, returns new secret once)."""
1879
+ return self._run(self._async.rotate(key_id))
1880
+
1881
+ def delete(self, key_id: int) -> None:
1882
+ """Root only. Delete key."""
1883
+ return self._run(self._async.delete(key_id))
1884
+
1885
+
1886
+ class SyncAdminAPI:
1887
+ """Admin API: statistics, retention (TTL cleanup), domain cache, Gmail credentials, keys."""
1888
+
1889
+ def __init__(self, async_api: AdminAPI, run: Callable[[Any], Any]) -> None:
1890
+ self._async = async_api
1891
+ self._run = run
1892
+ self.keys_api = SyncKeysAPI(async_api.keys_api, run)
1893
+
1894
+ def stats(self) -> dict:
1895
+ """Statistics: mailboxes, messages, providers, and 7-day trend."""
1896
+ return self._run(self._async.stats())
1897
+
1898
+ def retention_list(self) -> list[Retention]:
1899
+ return self._run(self._async.retention_list())
1900
+
1901
+ def retention_set(
1902
+ self,
1903
+ provider: str,
1904
+ *,
1905
+ mailbox_ttl_hours: int | None = None,
1906
+ message_ttl_hours: int | None = None,
1907
+ ) -> Retention:
1908
+ """Enable or update retention TTL for a provider (at least one TTL required)."""
1909
+ return self._run(
1910
+ self._async.retention_set(
1911
+ provider,
1912
+ mailbox_ttl_hours=mailbox_ttl_hours,
1913
+ message_ttl_hours=message_ttl_hours,
1914
+ )
1915
+ )
1916
+
1917
+ def retention_disable(self, provider: str) -> None:
1918
+ return self._run(self._async.retention_disable(provider))
1919
+
1920
+ def retention_run(self, *, force_maintenance: bool = False) -> SweepResult:
1921
+ """Run cleanup now: sweep expired mailboxes and messages."""
1922
+ return self._run(self._async.retention_run(force_maintenance=force_maintenance))
1923
+
1924
+ def domain_cache(self) -> DomainCache:
1925
+ """Current domain cache status (enabled, interval, providers)."""
1926
+ return self._run(self._async.domain_cache())
1927
+
1928
+ def domain_cache_save(
1929
+ self,
1930
+ *,
1931
+ enabled: bool | None = None,
1932
+ interval_seconds: int | None = None,
1933
+ ) -> DomainCache:
1934
+ """Update domain cache settings (at least one parameter required)."""
1935
+ return self._run(
1936
+ self._async.domain_cache_save(
1937
+ enabled=enabled,
1938
+ interval_seconds=interval_seconds,
1939
+ )
1940
+ )
1941
+
1942
+ def domain_cache_run(self) -> DomainCacheRun:
1943
+ """Force refresh domain cache now."""
1944
+ return self._run(self._async.domain_cache_run())
1945
+
1946
+ def gmail_creds(self) -> GmailCreds:
1947
+ return self._run(self._async.gmail_creds())
1948
+
1949
+ def gmail_creds_save(
1950
+ self,
1951
+ client_id: str,
1952
+ client_secret: str,
1953
+ redirect_uri: str | None = None,
1954
+ ) -> GmailCreds:
1955
+ return self._run(
1956
+ self._async.gmail_creds_save(
1957
+ client_id,
1958
+ client_secret,
1959
+ redirect_uri=redirect_uri,
1960
+ )
1961
+ )
1962
+
1963
+ def gmail_creds_clear(self) -> None:
1964
+ return self._run(self._async.gmail_creds_clear())
1965
+
1966
+ def keys(self) -> list[dict[str, Any]]:
1967
+ """Root only. Lists keys; the secret is not in the response."""
1968
+ return self._run(self._async.keys())
1969
+
1970
+ def key_create(
1971
+ self,
1972
+ *,
1973
+ name: str,
1974
+ expires_at: str | None,
1975
+ scope: dict[str, Any],
1976
+ retention: list[dict[str, Any]],
1977
+ ) -> dict[str, Any]:
1978
+ """Root only. Creates a key; the response includes the secret once."""
1979
+ return self._run(
1980
+ self._async.key_create(
1981
+ name=name,
1982
+ expires_at=expires_at,
1983
+ scope=scope,
1984
+ retention=retention,
1985
+ )
1986
+ )
1987
+
1988
+ def key_get(self, key_id: int) -> dict[str, Any]:
1989
+ """Root only. Returns one key without the secret."""
1990
+ return self._run(self._async.key_get(key_id))
1991
+
1992
+ def key_me(self) -> dict[str, Any]:
1993
+ """Any authenticated key. Root gets is_root; an issued key gets no secret."""
1994
+ return self._run(self._async.key_me())
1995
+
1996
+ def key_secret(self, key_id: int) -> dict[str, Any]:
1997
+ """Root only. Reveals the secret."""
1998
+ return self._run(self._async.key_secret(key_id))
1999
+
2000
+ def key_update(
2001
+ self,
2002
+ key_id: int,
2003
+ *,
2004
+ name: str,
2005
+ expires_at: str | None,
2006
+ scope: dict[str, Any],
2007
+ ) -> dict[str, Any]:
2008
+ """Root only. Updates name, expiry, and scope; the secret is unchanged."""
2009
+ return self._run(
2010
+ self._async.key_update(
2011
+ key_id,
2012
+ name=name,
2013
+ expires_at=expires_at,
2014
+ scope=scope,
2015
+ )
2016
+ )
2017
+
2018
+ def key_retention_set(
2019
+ self, key_id: int, retention: list[dict[str, Any]]
2020
+ ) -> list[dict[str, Any]]:
2021
+ """Root only. Replaces that key's retention rows."""
2022
+ return self._run(self._async.key_retention_set(key_id, retention))
2023
+
2024
+ def key_rotate(self, key_id: int) -> dict[str, Any]:
2025
+ """Root only. Rotates the secret; the response includes the new secret once."""
2026
+ return self._run(self._async.key_rotate(key_id))
2027
+
2028
+ def key_delete(self, key_id: int) -> None:
2029
+ """Root only. Deletes the key."""
2030
+ return self._run(self._async.key_delete(key_id))
2031
+
2032
+
2033
+ # --------------------------------------------------------------------------- #
2034
+ # Synchronous client facade
2035
+ # --------------------------------------------------------------------------- #
2036
+ class SyncMaily:
2037
+ """Synchronous client facade for maily.
2038
+
2039
+ Runs a dedicated background daemon thread with a persistent asyncio event loop,
2040
+ allowing synchronous callers to execute all client operations safely without
2041
+ manual asyncio.run() or event loop closure issues.
2042
+
2043
+ Example:
2044
+ with SyncMaily("http://127.0.0.1:8477", api_key="sk-...") as api:
2045
+ box = api.create_temp_mailbox()
2046
+ code = api.wait_for_code(box)
2047
+ print("Verification code:", code)
2048
+
2049
+ Sections: .health, .config, .providers, .mailboxes, .messages, .gmail, .admin, .keys
2050
+ """
2051
+
2052
+ health: SyncHealthAPI
2053
+ config: SyncConfigAPI
2054
+ providers: SyncProvidersAPI
2055
+ mailboxes: SyncMailboxesAPI
2056
+ messages: SyncMessagesAPI
2057
+ gmail: SyncGmailAPI
2058
+ admin: SyncAdminAPI
2059
+ keys: SyncKeysAPI
2060
+
2061
+ def __init__(
2062
+ self,
2063
+ base_url: str = "http://127.0.0.1:8477",
2064
+ api_key: str = "",
2065
+ timeout: float = 30.0,
2066
+ *,
2067
+ max_retries: int = 0,
2068
+ retry_backoff: float = 0.5,
2069
+ client: httpx.AsyncClient | None = None,
2070
+ ) -> None:
2071
+ self._loop = asyncio.new_event_loop()
2072
+ self._closed = False
2073
+ self._async_client: Maily | None = None
2074
+
2075
+ ready = threading.Event()
2076
+ init_err: list[BaseException] = []
2077
+
2078
+ def _run_loop() -> None:
2079
+ asyncio.set_event_loop(self._loop)
2080
+ try:
2081
+ try:
2082
+ self._async_client = Maily(
2083
+ base_url=base_url,
2084
+ api_key=api_key,
2085
+ timeout=timeout,
2086
+ max_retries=max_retries,
2087
+ retry_backoff=retry_backoff,
2088
+ client=client,
2089
+ )
2090
+ except BaseException as exc:
2091
+ init_err.append(exc)
2092
+ ready.set()
2093
+ return
2094
+
2095
+ self._loop.call_soon(ready.set)
2096
+ self._loop.run_forever()
2097
+ finally:
2098
+ with suppress(Exception):
2099
+ pending = [t for t in asyncio.all_tasks(self._loop) if not t.done()]
2100
+ for task in pending:
2101
+ task.cancel()
2102
+ if pending:
2103
+ self._loop.run_until_complete(
2104
+ asyncio.gather(*pending, return_exceptions=True)
2105
+ )
2106
+ self._loop.close()
2107
+
2108
+ self._thread = threading.Thread(target=_run_loop, name="SyncMaily-worker", daemon=True)
2109
+ self._thread.start()
2110
+ ready.wait()
2111
+
2112
+ if init_err:
2113
+ self._closed = True
2114
+ if self._thread.is_alive():
2115
+ self._thread.join(timeout=5.0)
2116
+ raise init_err[0]
2117
+
2118
+ assert self._async_client is not None
2119
+ self.health = SyncHealthAPI(self._async_client.health, self._run)
2120
+ self.config = SyncConfigAPI(self._async_client.config, self._run)
2121
+ self.providers = SyncProvidersAPI(self._async_client.providers, self._run)
2122
+ self.mailboxes = SyncMailboxesAPI(self._async_client.mailboxes, self._run)
2123
+ self.messages = SyncMessagesAPI(self._async_client.messages, self._run)
2124
+ self.gmail = SyncGmailAPI(self._async_client.gmail, self._run)
2125
+ self.admin = SyncAdminAPI(self._async_client.admin, self._run)
2126
+ self.keys = SyncKeysAPI(self._async_client.keys, self._run)
2127
+
2128
+ @property
2129
+ def max_retries(self) -> int:
2130
+ """Client-side retry limit for transient network/server/rate-limit errors."""
2131
+ assert self._async_client is not None
2132
+ return self._async_client.max_retries
2133
+
2134
+ @max_retries.setter
2135
+ def max_retries(self, value: int) -> None:
2136
+ if self._async_client:
2137
+ self._async_client.max_retries = max(0, int(value))
2138
+
2139
+ @property
2140
+ def retry_backoff(self) -> float:
2141
+ """Base backoff in seconds for exponential retry backoff."""
2142
+ assert self._async_client is not None
2143
+ return self._async_client.retry_backoff
2144
+
2145
+ @retry_backoff.setter
2146
+ def retry_backoff(self, value: float) -> None:
2147
+ if self._async_client:
2148
+ self._async_client.retry_backoff = max(0.0, float(value))
2149
+
2150
+ def _run(self, coro: Any) -> Any:
2151
+ if self._closed or not self._loop.is_running():
2152
+ if asyncio.iscoroutine(coro):
2153
+ coro.close()
2154
+ raise ClientError("SyncMaily is closed")
2155
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
2156
+ return future.result()
2157
+
2158
+ def set_api_key(self, api_key: str) -> None:
2159
+ """Update API key dynamically."""
2160
+ if self._closed or not self._async_client:
2161
+ raise ClientError("SyncMaily is closed")
2162
+ self._async_client.set_api_key(api_key)
2163
+
2164
+ def check_client_version(self) -> str:
2165
+ """Server version from /v1/config. Warns on version mismatch."""
2166
+ assert self._async_client is not None
2167
+ return self._run(self._async_client.check_client_version())
2168
+
2169
+ def create_temp_mailbox(
2170
+ self,
2171
+ *,
2172
+ provider: str | None = None,
2173
+ domain: str | None = None,
2174
+ ) -> Mailbox:
2175
+ """Convenience helper: create a temporary mailbox in one call."""
2176
+ assert self._async_client is not None
2177
+ return self._run(self._async_client.create_temp_mailbox(provider=provider, domain=domain))
2178
+
2179
+ def wait_for_code(
2180
+ self,
2181
+ mailbox_id: int | Mailbox,
2182
+ *,
2183
+ pattern: str = r"\b(\d{4,8})\b",
2184
+ sender_domain: str | None = None,
2185
+ timeout: float = 60.0,
2186
+ check_interval: float = 3.0,
2187
+ ) -> str:
2188
+ """Poll mailbox until an email with a digit verification code arrives."""
2189
+ assert self._async_client is not None
2190
+ return self._run(
2191
+ self._async_client.wait_for_code(
2192
+ mailbox_id,
2193
+ pattern=pattern,
2194
+ sender_domain=sender_domain,
2195
+ timeout=timeout,
2196
+ check_interval=check_interval,
2197
+ )
2198
+ )
2199
+
2200
+ def wait_for_link(
2201
+ self,
2202
+ mailbox_id: int | Mailbox,
2203
+ *,
2204
+ url_contain: builtins.list[str] | None = None,
2205
+ sender_domain: str | None = None,
2206
+ timeout: float = 60.0,
2207
+ check_interval: float = 3.0,
2208
+ ) -> str:
2209
+ """Poll mailbox until an email with a confirmation link arrives."""
2210
+ assert self._async_client is not None
2211
+ return self._run(
2212
+ self._async_client.wait_for_link(
2213
+ mailbox_id,
2214
+ url_contain=url_contain,
2215
+ sender_domain=sender_domain,
2216
+ timeout=timeout,
2217
+ check_interval=check_interval,
2218
+ )
2219
+ )
2220
+
2221
+ def wait_for_confirmation_email(
2222
+ self,
2223
+ mailbox_id: int | Mailbox,
2224
+ *,
2225
+ sender_domain: str | None = None,
2226
+ url_contain: builtins.list[str] | None = None,
2227
+ digit_code_mask: str | None = None,
2228
+ timeout: float = 60.0,
2229
+ check_interval: float = 5.0,
2230
+ ) -> ExtractedConfirmation | None:
2231
+ """Wait for a confirmation email and extract confirmation link and/or code."""
2232
+ assert self._async_client is not None
2233
+ return self._run(
2234
+ self._async_client.messages.wait_for_confirmation_email(
2235
+ mailbox_id,
2236
+ sender_domain=sender_domain,
2237
+ url_contain=url_contain,
2238
+ digit_code_mask=digit_code_mask,
2239
+ timeout=timeout,
2240
+ check_interval=check_interval,
2241
+ )
2242
+ )
2243
+
2244
+ def close(self) -> None:
2245
+ """Close the synchronous client and terminate its background event loop thread."""
2246
+ if self._closed:
2247
+ return
2248
+ self._closed = True
2249
+ if self._async_client and self._loop.is_running():
2250
+ with suppress(Exception):
2251
+ future = asyncio.run_coroutine_threadsafe(self._async_client.close(), self._loop)
2252
+ future.result(timeout=10.0)
2253
+ if self._loop.is_running():
2254
+ self._loop.call_soon_threadsafe(self._loop.stop)
2255
+ if self._thread.is_alive() and threading.current_thread() is not self._thread:
2256
+ self._thread.join(timeout=5.0)
2257
+
2258
+ def __enter__(self) -> SyncMaily:
2259
+ return self
2260
+
2261
+ def __exit__(self, *_exc: Any) -> None:
2262
+ self.close()
2263
+
2264
+ def __del__(self) -> None:
2265
+ with suppress(Exception):
2266
+ self.close()
2267
+
2268
+ # --- Synchronous SDK-compatible flat methods ---
2269
+
2270
+ def create_mailbox(
2271
+ self,
2272
+ *,
2273
+ provider: str | None = None,
2274
+ domain: str | None = None,
2275
+ parent_id: int | None = None,
2276
+ address: str | None = None,
2277
+ password: str | None = None,
2278
+ ) -> Mailbox:
2279
+ assert self._async_client is not None
2280
+ return self._run(
2281
+ self._async_client.create_mailbox(
2282
+ provider=provider,
2283
+ domain=domain,
2284
+ parent_id=parent_id,
2285
+ address=address,
2286
+ password=password,
2287
+ )
2288
+ )
2289
+
2290
+ def list_mailboxes(
2291
+ self, provider: str | None = None, active: bool | None = None
2292
+ ) -> list[Mailbox]:
2293
+ assert self._async_client is not None
2294
+ return self._run(self._async_client.list_mailboxes(provider=provider, active=active))
2295
+
2296
+ def mailbox(self, mailbox_id: int | Mailbox) -> Mailbox:
2297
+ assert self._async_client is not None
2298
+ return self._run(self._async_client.mailbox(mailbox_id))
2299
+
2300
+ def mailbox_by_address(self, email: str) -> Mailbox:
2301
+ assert self._async_client is not None
2302
+ return self._run(self._async_client.mailbox_by_address(email))
2303
+
2304
+ def delete_mailbox(self, mailbox_id: int | Mailbox) -> None:
2305
+ assert self._async_client is not None
2306
+ return self._run(self._async_client.delete_mailbox(mailbox_id))
2307
+
2308
+ def list_messages(self, mailbox_id: int | Mailbox, page: int = 1) -> list[MessageSummary]:
2309
+ assert self._async_client is not None
2310
+ return self._run(self._async_client.list_messages(mailbox_id, page=page))
2311
+
2312
+ def get_message(self, mailbox_id: int | Mailbox, message_id: str) -> MessageDetail:
2313
+ assert self._async_client is not None
2314
+ return self._run(self._async_client.get_message(mailbox_id, message_id))
2315
+
2316
+ def mark_read(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSummary:
2317
+ assert self._async_client is not None
2318
+ return self._run(self._async_client.mark_read(mailbox_id, message_id))
2319
+
2320
+ def delete_message(self, mailbox_id: int | Mailbox, message_id: str) -> None:
2321
+ assert self._async_client is not None
2322
+ return self._run(self._async_client.delete_message(mailbox_id, message_id))
2323
+
2324
+ def get_source(self, mailbox_id: int | Mailbox, message_id: str) -> MessageSource:
2325
+ assert self._async_client is not None
2326
+ return self._run(self._async_client.get_source(mailbox_id, message_id))
2327
+
2328
+ def gmail_oauth_start(self) -> OAuthStart:
2329
+ assert self._async_client is not None
2330
+ return self._run(self._async_client.gmail_oauth_start())
2331
+
2332
+ def gmail_import_account(
2333
+ self, *, refresh_token: str | None = None, token_json: Any = None
2334
+ ) -> GmailAccount:
2335
+ assert self._async_client is not None
2336
+ return self._run(
2337
+ self._async_client.gmail_import_account(
2338
+ refresh_token=refresh_token, token_json=token_json
2339
+ )
2340
+ )
2341
+
2342
+ def list_gmail_accounts(self) -> list[GmailAccount]:
2343
+ assert self._async_client is not None
2344
+ return self._run(self._async_client.list_gmail_accounts())
2345
+
2346
+ def delete_gmail_account(self, parent_id: int) -> None:
2347
+ assert self._async_client is not None
2348
+ return self._run(self._async_client.delete_gmail_account(parent_id))
2349
+
2350
+ def sync_gmail_account(self, parent_id: int) -> GmailAccount:
2351
+ assert self._async_client is not None
2352
+ return self._run(self._async_client.sync_gmail_account(parent_id))
2353
+
2354
+ def domain_cache_settings(self) -> DomainCache:
2355
+ assert self._async_client is not None
2356
+ return self._run(self._async_client.domain_cache_settings())
2357
+
2358
+ def save_domain_cache_settings(
2359
+ self, *, enabled: bool | None = None, interval_seconds: int | None = None
2360
+ ) -> DomainCache:
2361
+ assert self._async_client is not None
2362
+ return self._run(
2363
+ self._async_client.save_domain_cache_settings(
2364
+ enabled=enabled, interval_seconds=interval_seconds
2365
+ )
2366
+ )
2367
+
2368
+ def refresh_domain_cache(self) -> DomainCacheRun:
2369
+ assert self._async_client is not None
2370
+ return self._run(self._async_client.refresh_domain_cache())
2371
+
2372
+ def wait_for_message(
2373
+ self,
2374
+ mailbox_id: int | Mailbox,
2375
+ *,
2376
+ timeout: float = 120.0,
2377
+ interval: float = 2.0,
2378
+ predicate: Callable[[MessageSummary], bool] | None = None,
2379
+ ) -> MessageSummary:
2380
+ assert self._async_client is not None
2381
+ return self._run(
2382
+ self._async_client.wait_for_message(
2383
+ mailbox_id, timeout=timeout, interval=interval, predicate=predicate
2384
+ )
2385
+ )
2386
+
2387
+
2388
+ # --------------------------------------------------------------------------- #
2389
+ # CLI: python client.py check|update|version
2390
+ # --------------------------------------------------------------------------- #
2391
+ def _main(argv: list[str] | None = None) -> int:
2392
+ args = sys.argv[1:] if argv is None else list(argv)
2393
+ if not args or args[0] in ("-h", "--help", "help"):
2394
+ print(__doc__.split("\n\n")[1].strip(), file=sys.stderr)
2395
+ print(
2396
+ "\nusage:\n"
2397
+ " maily-client version - show version\n"
2398
+ " maily-client check - check git for a newer version\n"
2399
+ " maily-client update - update client file from git\n"
2400
+ " (or: python client.py check/update)\n",
2401
+ file=sys.stderr,
2402
+ )
2403
+ return 0
2404
+ if args[0] == "version":
2405
+ print(__version__)
2406
+ return 0
2407
+ if args[0] == "check":
2408
+ try:
2409
+ result = check_update(args[1] if len(args) > 1 else None)
2410
+ except Exception as exc:
2411
+ print(f"check failed: {exc}", file=sys.stderr)
2412
+ return 1
2413
+ if result is None:
2414
+ print(f"client is up to date ({__version__})")
2415
+ return 0
2416
+ print(f"update available: {result[0]} -> {result[1]}")
2417
+ return 2
2418
+ if args[0] == "update":
2419
+ try:
2420
+ updated = self_update(args[1] if len(args) > 1 else None)
2421
+ except Exception as exc:
2422
+ print(f"update failed: {exc}", file=sys.stderr)
2423
+ return 1
2424
+ if not updated:
2425
+ print(f"client is up to date ({__version__})")
2426
+ return 0
2427
+ print("updated. Restart your scripts.")
2428
+ return 0
2429
+ print(f"unknown command: {args[0]}", file=sys.stderr)
2430
+ return 2
2431
+
2432
+
2433
+ if __name__ == "__main__":
2434
+ raise SystemExit(_main())