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())
|