maily-server 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.
Files changed (86) hide show
  1. maily/__init__.py +9 -0
  2. maily/app/__init__.py +0 -0
  3. maily/app/authz.py +253 -0
  4. maily/app/deps.py +63 -0
  5. maily/app/errors.py +92 -0
  6. maily/app/main.py +249 -0
  7. maily/app/middleware/origin.py +69 -0
  8. maily/app/routers/__init__.py +0 -0
  9. maily/app/routers/admin.py +397 -0
  10. maily/app/routers/gmail.py +252 -0
  11. maily/app/routers/health.py +86 -0
  12. maily/app/routers/keys.py +270 -0
  13. maily/app/routers/mailboxes.py +243 -0
  14. maily/app/routers/messages.py +232 -0
  15. maily/app/routers/providers.py +73 -0
  16. maily/app/schemas.py +162 -0
  17. maily/app/services/__init__.py +0 -0
  18. maily/app/services/domain_cache.py +308 -0
  19. maily/app/services/gmail_config.py +82 -0
  20. maily/app/services/keys.py +279 -0
  21. maily/app/services/mailboxes.py +235 -0
  22. maily/app/services/provider_headers.py +149 -0
  23. maily/app/services/retention.py +372 -0
  24. maily/app/services/retention_rules.py +95 -0
  25. maily/app/services/scoped_history.py +254 -0
  26. maily/app/services/scoped_stats.py +169 -0
  27. maily/app/services/stats_history.py +225 -0
  28. maily/client.py +147 -0
  29. maily/crypto.py +43 -0
  30. maily/db/__init__.py +18 -0
  31. maily/db/engine.py +89 -0
  32. maily/db/maintenance.py +254 -0
  33. maily/db/models.py +229 -0
  34. maily/exceptions.py +43 -0
  35. maily/http.py +90 -0
  36. maily/providers/__init__.py +41 -0
  37. maily/providers/anonymmail_net/__init__.py +3 -0
  38. maily/providers/anonymmail_net/service.py +506 -0
  39. maily/providers/base.py +253 -0
  40. maily/providers/dispoemail_org/__init__.py +3 -0
  41. maily/providers/dispoemail_org/service.py +370 -0
  42. maily/providers/gmail/__init__.py +13 -0
  43. maily/providers/gmail/db_store.py +126 -0
  44. maily/providers/gmail/oauth.py +128 -0
  45. maily/providers/gmail/parse.py +189 -0
  46. maily/providers/gmail/service.py +330 -0
  47. maily/providers/gmail/store.py +69 -0
  48. maily/providers/mailtm/__init__.py +3 -0
  49. maily/providers/mailtm/service.py +262 -0
  50. maily/providers/noopmail_org/__init__.py +3 -0
  51. maily/providers/noopmail_org/service.py +215 -0
  52. maily/providers/registry.py +121 -0
  53. maily/providers/temp_tf/__init__.py +3 -0
  54. maily/providers/temp_tf/service.py +490 -0
  55. maily/providers/tempmail_io/__init__.py +3 -0
  56. maily/providers/tempmail_io/headers.py +59 -0
  57. maily/providers/tempmail_io/service.py +228 -0
  58. maily/providers/tempmail_plus/__init__.py +3 -0
  59. maily/providers/tempmail_plus/service.py +259 -0
  60. maily/py.typed +0 -0
  61. maily/schemas.py +166 -0
  62. maily/sdk.py +71 -0
  63. maily/settings.py +103 -0
  64. maily/standalone_client.py +2431 -0
  65. maily/static/css/main.css +1139 -0
  66. maily/static/docs.html +789 -0
  67. maily/static/index.html +148 -0
  68. maily/static/js/api.js +224 -0
  69. maily/static/js/app.js +370 -0
  70. maily/static/js/pages/access.js +168 -0
  71. maily/static/js/pages/detail.js +630 -0
  72. maily/static/js/pages/domain-cache.js +160 -0
  73. maily/static/js/pages/gmail.js +483 -0
  74. maily/static/js/pages/keys.js +614 -0
  75. maily/static/js/pages/mailboxes.js +520 -0
  76. maily/static/js/pages/overview.js +687 -0
  77. maily/static/js/pages/retention.js +163 -0
  78. maily/static/js/ui.js +273 -0
  79. maily/utils/__init__.py +4 -0
  80. maily/utils/misc.py +15 -0
  81. maily/utils/retry.py +68 -0
  82. maily_server-0.4.0.dist-info/METADATA +414 -0
  83. maily_server-0.4.0.dist-info/RECORD +86 -0
  84. maily_server-0.4.0.dist-info/WHEEL +4 -0
  85. maily_server-0.4.0.dist-info/entry_points.txt +2 -0
  86. maily_server-0.4.0.dist-info/licenses/LICENSE +21 -0
maily/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Local multi-provider mail API."""
2
+
3
+ from .client import Maily, SyncMaily
4
+
5
+ # Keep in sync with the `version` in pyproject.toml and with the standalone
6
+ # client at client/client.py (it reads its own __version__ for auto-updates).
7
+ __version__ = "0.4.0" # x-release-please-version
8
+
9
+ __all__ = ["Maily", "SyncMaily", "__version__"]
maily/app/__init__.py ADDED
File without changes
maily/app/authz.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import secrets
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from typing import Annotated, Final
10
+
11
+ from fastapi import Depends, Header, HTTPException, Request, Security
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from ..db.models import ApiKey, Mailbox
16
+ from .deps import SessionDep, api_key_header
17
+
18
+ _API_KEY_HEADER: Final[dict[str, str]] = {"X-MailTM-Error": "api_key"}
19
+ _INVALID_DETAIL: Final[str] = "invalid api key"
20
+ _FORBIDDEN_DETAIL: Final[str] = "forbidden"
21
+
22
+
23
+ def utcnow_naive() -> datetime:
24
+ """UTC now without tzinfo, matching stored naive expiry timestamps."""
25
+ return datetime.now(timezone.utc).replace(tzinfo=None)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Principal:
30
+ """Caller resolved from the env root key or an issued api_keys row."""
31
+
32
+ is_root: bool
33
+ key: ApiKey | None
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Scope:
38
+ """Parsed scope_json. Invalid JSON is the deny scope, not an exception.
39
+
40
+ ``providers_open`` is true only for a parsed empty ``providers`` list,
41
+ which means every provider. A deny scope leaves it false so bad JSON
42
+ cannot be confused with "all providers".
43
+ """
44
+
45
+ providers: tuple[str, ...]
46
+ parent_ids: tuple[int, ...]
47
+ actions: frozenset[str]
48
+ providers_open: bool
49
+
50
+
51
+ def _invalid_api_key() -> HTTPException:
52
+ return HTTPException(status_code=401, detail=_INVALID_DETAIL, headers=_API_KEY_HEADER)
53
+
54
+
55
+ def _forbidden() -> HTTPException:
56
+ return HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
57
+
58
+
59
+ def presented_secret(
60
+ x_api_key: str | None,
61
+ authorization: str | None,
62
+ ) -> str | None:
63
+ """X-API-Key wins; otherwise Authorization: Bearer, same as require_api_key."""
64
+ if x_api_key:
65
+ return x_api_key
66
+ if authorization is None or not authorization.lower().startswith("bearer "):
67
+ return None
68
+ token = authorization[7:].strip()
69
+ if not token:
70
+ return None
71
+ return token
72
+
73
+
74
+ def secret_sha256(secret: str) -> str:
75
+ """Hex SHA-256 of the raw presented secret. Ciphertext is never touched."""
76
+ return hashlib.sha256(secret.encode()).hexdigest()
77
+
78
+
79
+ def _is_root_secret(presented: str, expected: str) -> bool:
80
+ if not expected or not presented:
81
+ return False
82
+ return secrets.compare_digest(presented.encode("utf-8"), expected.encode("utf-8"))
83
+
84
+
85
+ def _deny_scope() -> Scope:
86
+ return Scope(providers=(), parent_ids=(), actions=frozenset(), providers_open=False)
87
+
88
+
89
+ def parse_scope(scope_json: str) -> Scope:
90
+ """Parse scope_json. Any malformed payload denies every check."""
91
+ try:
92
+ raw = json.loads(scope_json)
93
+ except json.JSONDecodeError:
94
+ return _deny_scope()
95
+ if not isinstance(raw, dict):
96
+ return _deny_scope()
97
+ return _scope_from_payload(raw)
98
+
99
+
100
+ def _scope_from_payload(payload: dict[str, object]) -> Scope:
101
+ providers = payload.get("providers")
102
+ parent_ids = payload.get("parent_ids")
103
+ actions = payload.get("actions")
104
+ if not isinstance(providers, list) or not isinstance(parent_ids, list):
105
+ return _deny_scope()
106
+ if not isinstance(actions, list):
107
+ return _deny_scope()
108
+ return _scope_from_lists(providers, parent_ids, actions)
109
+
110
+
111
+ def _scope_from_lists(
112
+ providers: list[object],
113
+ parent_ids: list[object],
114
+ actions: list[object],
115
+ ) -> Scope:
116
+ provider_names = _strings(providers)
117
+ parent_id_values = _parent_ids(parent_ids)
118
+ action_names = _strings(actions)
119
+ if provider_names is None or parent_id_values is None or action_names is None:
120
+ return _deny_scope()
121
+ return Scope(
122
+ providers=provider_names,
123
+ parent_ids=parent_id_values,
124
+ actions=frozenset(action_names),
125
+ providers_open=len(provider_names) == 0,
126
+ )
127
+
128
+
129
+ def _strings(items: list[object]) -> tuple[str, ...] | None:
130
+ names: list[str] = []
131
+ for item in items:
132
+ if not isinstance(item, str):
133
+ return None
134
+ names.append(item)
135
+ return tuple(names)
136
+
137
+
138
+ def _parent_ids(items: list[object]) -> tuple[int, ...] | None:
139
+ ids: list[int] = []
140
+ for item in items:
141
+ if isinstance(item, bool) or not isinstance(item, int):
142
+ return None
143
+ ids.append(item)
144
+ return tuple(ids)
145
+
146
+
147
+ def _expired(key: ApiKey, now: datetime) -> bool:
148
+ expires_at = key.expires_at
149
+ if expires_at is None:
150
+ return False
151
+ return expires_at <= now
152
+
153
+
154
+ async def _lookup_key(session: AsyncSession, digest: str) -> ApiKey | None:
155
+ result = await session.execute(select(ApiKey).where(ApiKey.secret_sha256 == digest))
156
+ return result.scalar_one_or_none()
157
+
158
+
159
+ async def require_principal(
160
+ request: Request,
161
+ session: SessionDep,
162
+ authorization: Annotated[str | None, Header()] = None,
163
+ x_api_key: Annotated[str | None, Security(api_key_header)] = None,
164
+ ) -> Principal:
165
+ """Resolve the caller. Empty env key is root. Env match is root before any lookup."""
166
+ expected: str = request.app.state.settings.api_key
167
+ if not expected:
168
+ return Principal(is_root=True, key=None)
169
+ presented = presented_secret(x_api_key, authorization)
170
+ if presented is None:
171
+ raise _invalid_api_key()
172
+ if _is_root_secret(presented, expected):
173
+ return Principal(is_root=True, key=None)
174
+ key = await _lookup_key(session, secret_sha256(presented))
175
+ if key is None or _expired(key, utcnow_naive()):
176
+ raise _invalid_api_key()
177
+ return Principal(is_root=False, key=key)
178
+
179
+
180
+ PrincipalDep = Annotated[Principal, Depends(require_principal)]
181
+
182
+
183
+ def action_allowed(principal: Principal, *names: str) -> bool:
184
+ """True when root, or when every named action is in the issued key's scope."""
185
+ if principal.is_root:
186
+ return True
187
+ key = principal.key
188
+ if key is None:
189
+ return False
190
+ scope = parse_scope(key.scope_json)
191
+ return all(name in scope.actions for name in names)
192
+
193
+
194
+ def require_action(*names: str) -> Callable[..., Awaitable[Principal]]:
195
+ """Dependency factory. Every named action must be present. Root bypasses."""
196
+
197
+ async def _require(principal: PrincipalDep) -> Principal:
198
+ if not action_allowed(principal, *names):
199
+ raise _forbidden()
200
+ return principal
201
+
202
+ return _require
203
+
204
+
205
+ async def require_root(principal: PrincipalDep) -> Principal:
206
+ """Issued keys are 403. Root (including open mode) passes."""
207
+ if not principal.is_root:
208
+ raise _forbidden()
209
+ return principal
210
+
211
+
212
+ RootDep = Annotated[Principal, Depends(require_root)]
213
+ StatsRead = Annotated[Principal, Depends(require_action("stats:read"))]
214
+ RetentionRead = Annotated[Principal, Depends(require_action("retention:read"))]
215
+ RetentionWrite = Annotated[Principal, Depends(require_action("retention:write"))]
216
+ ParentsAdd = Annotated[Principal, Depends(require_action("parents:add"))]
217
+ ParentsRead = Annotated[Principal, Depends(require_action("parents:read"))]
218
+ MailboxesWrite = Annotated[Principal, Depends(require_action("mailboxes:write"))]
219
+ MailboxesRead = Annotated[Principal, Depends(require_action("mailboxes:read"))]
220
+
221
+
222
+ def owns_mailbox(principal: Principal, mailbox: Mailbox) -> bool:
223
+ """Root owns every mailbox. An issued key owns only rows stamped with its id."""
224
+ if principal.is_root:
225
+ return True
226
+ key = principal.key
227
+ if key is None:
228
+ return False
229
+ return mailbox.owner_key_id == key.id
230
+
231
+
232
+ def provider_allowed(principal: Principal, slug: str) -> bool:
233
+ """Empty providers means every slug. Invalid scope denies. Root always allowed."""
234
+ if principal.is_root:
235
+ return True
236
+ key = principal.key
237
+ if key is None:
238
+ return False
239
+ scope = parse_scope(key.scope_json)
240
+ if scope.providers_open:
241
+ return True
242
+ return slug in scope.providers
243
+
244
+
245
+ def parent_allowed(principal: Principal, parent_id: int) -> bool:
246
+ """Empty parent_ids means none. Root always allowed. Invalid scope denies."""
247
+ if principal.is_root:
248
+ return True
249
+ key = principal.key
250
+ if key is None:
251
+ return False
252
+ scope = parse_scope(key.scope_json)
253
+ return parent_id in scope.parent_ids
maily/app/deps.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from collections.abc import AsyncIterator
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Header, HTTPException, Request, Security
8
+ from fastapi.security import APIKeyHeader
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from ..providers.registry import ProviderRegistry
12
+ from ..settings import Settings
13
+
14
+ api_key_header = APIKeyHeader(
15
+ name="X-API-Key",
16
+ auto_error=False,
17
+ description="API key (required only when the server runs with MAILY_API_KEY set).",
18
+ )
19
+
20
+
21
+ def get_settings(request: Request) -> Settings:
22
+ return request.app.state.settings
23
+
24
+
25
+ def get_registry(request: Request) -> ProviderRegistry:
26
+ return request.app.state.registry
27
+
28
+
29
+ async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
30
+ factory = request.app.state.session_factory
31
+ async with factory() as session:
32
+ try:
33
+ yield session
34
+ except Exception:
35
+ await session.rollback()
36
+ raise
37
+
38
+
39
+ async def require_api_key(
40
+ request: Request,
41
+ authorization: Annotated[str | None, Header()] = None,
42
+ x_api_key: Annotated[str | None, Security(api_key_header)] = None,
43
+ ) -> None:
44
+ expected: str = request.app.state.settings.api_key
45
+ if not expected:
46
+ return
47
+ provided = x_api_key
48
+ if not provided and authorization and authorization.lower().startswith("bearer "):
49
+ provided = authorization[7:].strip()
50
+ if not provided or not secrets.compare_digest(provided, expected):
51
+ # Header lets the UI distinguish this 401 from a provider-auth 401
52
+ # (Gmail token expired → MailAuthError) which must NOT trigger the
53
+ # API-key prompt.
54
+ raise HTTPException(
55
+ status_code=401,
56
+ detail="invalid api key",
57
+ headers={"X-MailTM-Error": "api_key"},
58
+ )
59
+
60
+
61
+ SettingsDep = Annotated[Settings, Depends(get_settings)]
62
+ RegistryDep = Annotated[ProviderRegistry, Depends(get_registry)]
63
+ SessionDep = Annotated[AsyncSession, Depends(get_session)] # type: ignore[assignment]
maily/app/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.exceptions import RequestValidationError
7
+ from fastapi.responses import JSONResponse
8
+ from starlette.exceptions import HTTPException as StarletteHTTPException
9
+
10
+ from ..exceptions import (
11
+ MailAPIError,
12
+ MailAuthError,
13
+ MailConflictError,
14
+ MailNotFoundError,
15
+ MailRateLimitError,
16
+ MailValidationError,
17
+ )
18
+
19
+ logger = logging.getLogger("maily.errors")
20
+
21
+
22
+ def _json(
23
+ status: int,
24
+ detail: str,
25
+ headers: dict[str, str] | None = None,
26
+ code: str | None = None,
27
+ ) -> JSONResponse:
28
+ body: dict[str, str] = {"detail": detail}
29
+ if code:
30
+ body["code"] = code
31
+ return JSONResponse(body, status_code=status, headers=headers)
32
+
33
+
34
+ def register_errors(app: FastAPI) -> None:
35
+ @app.exception_handler(StarletteHTTPException)
36
+ async def _http(_req: Request, exc: StarletteHTTPException) -> JSONResponse:
37
+ # Generic HTTPException (raise HTTPException(...) in routers / deps).
38
+ # The API-key 401 (X-MailTM-Error: api_key) gets a machine-readable
39
+ # `code` so the UI no longer depends on headers or detail strings only.
40
+ headers = dict(exc.headers or {})
41
+ body: dict[str, str] = {"detail": str(exc.detail)}
42
+ if headers.get("X-MailTM-Error") == "api_key":
43
+ body["code"] = "api_key"
44
+ return JSONResponse(body, status_code=exc.status_code, headers=headers)
45
+
46
+ @app.exception_handler(RequestValidationError)
47
+ async def _validation(_req: Request, exc: RequestValidationError) -> JSONResponse:
48
+ return JSONResponse(
49
+ {"detail": exc.errors(), "code": "validation_error"},
50
+ status_code=422,
51
+ )
52
+
53
+ @app.exception_handler(MailNotFoundError)
54
+ async def _not_found(_req: Request, exc: MailNotFoundError) -> JSONResponse:
55
+ return _json(404, str(exc), code="not_found")
56
+
57
+ @app.exception_handler(MailValidationError)
58
+ async def _bad(_req: Request, exc: MailValidationError) -> JSONResponse:
59
+ return _json(400, str(exc), code="validation_error")
60
+
61
+ @app.exception_handler(MailAuthError)
62
+ async def _auth(_req: Request, exc: MailAuthError) -> JSONResponse:
63
+ # 401 collides with the API-key check; the header lets the UI tell
64
+ # "Google rejected the account token" apart from "server needs a key".
65
+ return _json(
66
+ 401,
67
+ str(exc),
68
+ headers={"X-MailTM-Error": "provider_auth"},
69
+ code="provider_auth",
70
+ )
71
+
72
+ @app.exception_handler(MailConflictError)
73
+ async def _conflict(_req: Request, exc: MailConflictError) -> JSONResponse:
74
+ return _json(409, str(exc), code="conflict")
75
+
76
+ @app.exception_handler(MailRateLimitError)
77
+ async def _rate(_req: Request, exc: MailRateLimitError) -> JSONResponse:
78
+ return _json(429, str(exc), code="rate_limit")
79
+
80
+ @app.exception_handler(MailAPIError)
81
+ async def _api(_req: Request, exc: MailAPIError) -> JSONResponse:
82
+ return _json(502, str(exc), code="upstream")
83
+
84
+ @app.exception_handler(KeyError)
85
+ async def _key(_req: Request, exc: KeyError) -> JSONResponse:
86
+ # registry.get raises KeyError for an unknown provider slug
87
+ return _json(404, str(exc).strip("'\""), code="not_found")
88
+
89
+ @app.exception_handler(Exception)
90
+ async def _internal(req: Request, exc: Exception) -> JSONResponse:
91
+ logger.exception("unhandled error on %s %s", req.method, req.url.path)
92
+ return _json(500, "Internal server error", code="internal")
maily/app/main.py ADDED
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager, suppress
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import FileResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from loguru import logger
11
+ from sqlalchemy.ext.asyncio import AsyncEngine
12
+
13
+ from .. import __version__
14
+ from ..db.engine import SessionFactory, make_engine
15
+ from ..http import HttpClient
16
+ from ..providers.gmail.db_store import DbGmailStore
17
+ from ..providers.gmail.oauth import GmailOAuth
18
+ from ..providers.gmail.service import GmailProvider
19
+ from ..providers.registry import ProviderRegistry, create_public_providers
20
+ from ..settings import Settings, get_settings
21
+ from .errors import register_errors
22
+ from .middleware.origin import OriginGuard
23
+ from .routers.admin import router as admin_router
24
+ from .routers.gmail import public as gmail_public
25
+ from .routers.gmail import router as gmail_router
26
+ from .routers.health import router as health_router
27
+ from .routers.keys import me_router as keys_me_router
28
+ from .routers.keys import router as keys_router
29
+ from .routers.mailboxes import router as mailboxes_router
30
+ from .routers.messages import router as messages_router
31
+ from .routers.providers import router as providers_router
32
+ from .services import domain_cache as dc
33
+ from .services import provider_headers as ph
34
+ from .services import stats_history
35
+ from .services.retention import retention_worker, run_sweep
36
+
37
+ OPENAPI_TAGS = [
38
+ {
39
+ "name": "System",
40
+ "description": "Liveness, readiness and client-facing configuration.",
41
+ },
42
+ {
43
+ "name": "Providers",
44
+ "description": "Enabled temp-mail providers and their usable posting targets.",
45
+ },
46
+ {
47
+ "name": "Mailboxes",
48
+ "description": "Create, list, look up and delete disposable mailboxes.",
49
+ },
50
+ {
51
+ "name": "Messages",
52
+ "description": "Read, mark, delete and download messages for a mailbox.",
53
+ },
54
+ {
55
+ "name": "Gmail",
56
+ "description": "Import Gmail accounts, OAuth pairing and per-account sync.",
57
+ },
58
+ {
59
+ "name": "Admin",
60
+ "description": "Operational controls: retention TTLs, domain cache and Gmail credentials.",
61
+ },
62
+ {
63
+ "name": "Keys",
64
+ "description": "Issued API keys are root-only except GET /me.",
65
+ },
66
+ ]
67
+
68
+
69
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
70
+
71
+
72
+ def _refuse_open_bind(settings: Settings) -> None:
73
+ host = settings.host.strip().lower()
74
+ if settings.api_key or host in _LOOPBACK_HOSTS:
75
+ return
76
+ raise RuntimeError(
77
+ "Refusing to listen on a non-loopback address without an API key. "
78
+ "Set MAILY_API_KEY or bind MAILY_HOST to 127.0.0.1."
79
+ )
80
+
81
+
82
+ def _run_migrations(url: str) -> None:
83
+ from alembic import command
84
+ from alembic.config import Config
85
+
86
+ cfg = Config()
87
+ root = Path(__file__).resolve().parents[3]
88
+ alembic_dir = root / "alembic"
89
+ if not alembic_dir.is_dir():
90
+ cwd_alembic = Path.cwd() / "alembic"
91
+ if cwd_alembic.is_dir():
92
+ alembic_dir = cwd_alembic
93
+ else:
94
+ for parent in Path(__file__).resolve().parents:
95
+ if (parent / "alembic").is_dir():
96
+ alembic_dir = parent / "alembic"
97
+ break
98
+ cfg.set_main_option("script_location", str(alembic_dir))
99
+ cfg.set_main_option("sqlalchemy.url", url.replace("sqlite+aiosqlite", "sqlite"))
100
+ command.upgrade(cfg, "head")
101
+
102
+
103
+ @asynccontextmanager
104
+ async def lifespan(app: FastAPI):
105
+ settings: Settings = app.state.settings
106
+ _refuse_open_bind(settings)
107
+ # Resolve (and pin) the Fernet key before any request can race to mint one.
108
+ settings.resolved_secret_key()
109
+ engine: AsyncEngine = make_engine(
110
+ settings.database_url,
111
+ busy_timeout_ms=settings.sqlite_busy_timeout_ms,
112
+ )
113
+ _run_migrations(settings.database_url)
114
+ factory = SessionFactory(engine)
115
+ # Seed the ledger's receive-counters from messages that predate the ledger,
116
+ # so the 7-day incoming chart doesn't fall back to live rows and then
117
+ # silently lose past days to retention deletes. Idempotent per day.
118
+ async with factory() as session:
119
+ seeded = await stats_history.backfill_received(session)
120
+ await session.commit()
121
+ if seeded:
122
+ logger.info(
123
+ "stats: seeded message_received for {} day/provider bucket(s)",
124
+ seeded,
125
+ )
126
+ http = HttpClient(proxy=settings.proxy)
127
+ secret = settings.resolved_secret_key()
128
+ store = DbGmailStore(factory, secret)
129
+ try:
130
+ cid, csec = settings.gmail_client()
131
+ except RuntimeError:
132
+ cid, csec = "", ""
133
+ oauth = GmailOAuth(
134
+ cid,
135
+ csec,
136
+ http,
137
+ token_uri=settings.gmail_token_uri,
138
+ auth_uri=settings.gmail_auth_uri,
139
+ )
140
+ if not cid:
141
+ from .services import gmail_config as gcfg
142
+
143
+ async with factory() as session:
144
+ db_creds = await gcfg.get_creds(session, secret)
145
+ if db_creds:
146
+ oauth.configure(*db_creds)
147
+ gmail = GmailProvider(store, oauth, http)
148
+ registry = ProviderRegistry(
149
+ [
150
+ gmail,
151
+ *create_public_providers(http=http, settings=settings),
152
+ ]
153
+ )
154
+ await ph.bootstrap(factory, http, registry, settings)
155
+ app.state.engine = engine
156
+ app.state.session_factory = factory
157
+ app.state.registry = registry
158
+ app.state.http = http
159
+ app.state.gmail = gmail
160
+ app.state.oauth = oauth
161
+ app.state.oauth_states = {}
162
+ sweep_lock = asyncio.Lock()
163
+ app.state.sweep_lock = sweep_lock
164
+
165
+ sweep_task = asyncio.create_task(
166
+ retention_worker(
167
+ lambda: run_sweep(
168
+ factory, getattr(app.state, "registry", registry), settings, lock=sweep_lock
169
+ ),
170
+ settings.retention_interval_seconds,
171
+ )
172
+ )
173
+ cache_task = asyncio.create_task(dc.domain_cache_worker(factory, registry, settings))
174
+ headers_task = asyncio.create_task(
175
+ ph.provider_headers_worker(factory, http, registry, settings)
176
+ )
177
+ try:
178
+ yield
179
+ finally:
180
+ for task in (sweep_task, cache_task, headers_task):
181
+ task.cancel()
182
+ with suppress(asyncio.CancelledError):
183
+ await sweep_task
184
+ with suppress(asyncio.CancelledError):
185
+ await cache_task
186
+ with suppress(asyncio.CancelledError):
187
+ await headers_task
188
+ await registry.close()
189
+ await http.close()
190
+ await engine.dispose()
191
+
192
+
193
+ def create_app(settings: Settings | None = None) -> FastAPI:
194
+ settings = settings or get_settings()
195
+ app = FastAPI(
196
+ title="maily",
197
+ version=__version__,
198
+ lifespan=lifespan,
199
+ description=(
200
+ "Local multi-provider temporary-email API. "
201
+ "Most endpoints require the `X-API-Key` header (see Admin → API key in the dashboard)."
202
+ ),
203
+ openapi_tags=OPENAPI_TAGS,
204
+ docs_url=None,
205
+ redoc_url=None,
206
+ swagger_ui_parameters={
207
+ "docExpansion": "none",
208
+ "filter": True,
209
+ "displayRequestDuration": True,
210
+ "persistAuthorization": True,
211
+ },
212
+ )
213
+ app.state.settings = settings
214
+ register_errors(app)
215
+ app.include_router(health_router)
216
+ app.include_router(gmail_public)
217
+ app.include_router(providers_router)
218
+ app.include_router(gmail_router)
219
+ app.include_router(mailboxes_router)
220
+ app.include_router(messages_router)
221
+ app.include_router(admin_router)
222
+ app.include_router(keys_me_router)
223
+ app.include_router(keys_router)
224
+ app.add_middleware(OriginGuard)
225
+ static_dir = Path(__file__).resolve().parents[1] / "static"
226
+
227
+ @app.get("/docs", include_in_schema=False)
228
+ async def docs(_: Request) -> FileResponse:
229
+ return FileResponse(static_dir / "docs.html")
230
+
231
+ if static_dir.is_dir():
232
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
233
+ return app
234
+
235
+
236
+ def run() -> None:
237
+ import uvicorn
238
+
239
+ settings = get_settings()
240
+ uvicorn.run(
241
+ "maily.app.main:create_app",
242
+ factory=True,
243
+ host=settings.host,
244
+ port=settings.port,
245
+ )
246
+
247
+
248
+ if __name__ == "__main__":
249
+ run()