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.
- maily/__init__.py +9 -0
- maily/app/__init__.py +0 -0
- maily/app/authz.py +253 -0
- maily/app/deps.py +63 -0
- maily/app/errors.py +92 -0
- maily/app/main.py +249 -0
- maily/app/middleware/origin.py +69 -0
- maily/app/routers/__init__.py +0 -0
- maily/app/routers/admin.py +397 -0
- maily/app/routers/gmail.py +252 -0
- maily/app/routers/health.py +86 -0
- maily/app/routers/keys.py +270 -0
- maily/app/routers/mailboxes.py +243 -0
- maily/app/routers/messages.py +232 -0
- maily/app/routers/providers.py +73 -0
- maily/app/schemas.py +162 -0
- maily/app/services/__init__.py +0 -0
- maily/app/services/domain_cache.py +308 -0
- maily/app/services/gmail_config.py +82 -0
- maily/app/services/keys.py +279 -0
- maily/app/services/mailboxes.py +235 -0
- maily/app/services/provider_headers.py +149 -0
- maily/app/services/retention.py +372 -0
- maily/app/services/retention_rules.py +95 -0
- maily/app/services/scoped_history.py +254 -0
- maily/app/services/scoped_stats.py +169 -0
- maily/app/services/stats_history.py +225 -0
- maily/client.py +147 -0
- maily/crypto.py +43 -0
- maily/db/__init__.py +18 -0
- maily/db/engine.py +89 -0
- maily/db/maintenance.py +254 -0
- maily/db/models.py +229 -0
- maily/exceptions.py +43 -0
- maily/http.py +90 -0
- maily/providers/__init__.py +41 -0
- maily/providers/anonymmail_net/__init__.py +3 -0
- maily/providers/anonymmail_net/service.py +506 -0
- maily/providers/base.py +253 -0
- maily/providers/dispoemail_org/__init__.py +3 -0
- maily/providers/dispoemail_org/service.py +370 -0
- maily/providers/gmail/__init__.py +13 -0
- maily/providers/gmail/db_store.py +126 -0
- maily/providers/gmail/oauth.py +128 -0
- maily/providers/gmail/parse.py +189 -0
- maily/providers/gmail/service.py +330 -0
- maily/providers/gmail/store.py +69 -0
- maily/providers/mailtm/__init__.py +3 -0
- maily/providers/mailtm/service.py +262 -0
- maily/providers/noopmail_org/__init__.py +3 -0
- maily/providers/noopmail_org/service.py +215 -0
- maily/providers/registry.py +121 -0
- maily/providers/temp_tf/__init__.py +3 -0
- maily/providers/temp_tf/service.py +490 -0
- maily/providers/tempmail_io/__init__.py +3 -0
- maily/providers/tempmail_io/headers.py +59 -0
- maily/providers/tempmail_io/service.py +228 -0
- maily/providers/tempmail_plus/__init__.py +3 -0
- maily/providers/tempmail_plus/service.py +259 -0
- maily/py.typed +0 -0
- maily/schemas.py +166 -0
- maily/sdk.py +71 -0
- maily/settings.py +103 -0
- maily/standalone_client.py +2431 -0
- maily/static/css/main.css +1139 -0
- maily/static/docs.html +789 -0
- maily/static/index.html +148 -0
- maily/static/js/api.js +224 -0
- maily/static/js/app.js +370 -0
- maily/static/js/pages/access.js +168 -0
- maily/static/js/pages/detail.js +630 -0
- maily/static/js/pages/domain-cache.js +160 -0
- maily/static/js/pages/gmail.js +483 -0
- maily/static/js/pages/keys.js +614 -0
- maily/static/js/pages/mailboxes.js +520 -0
- maily/static/js/pages/overview.js +687 -0
- maily/static/js/pages/retention.js +163 -0
- maily/static/js/ui.js +273 -0
- maily/utils/__init__.py +4 -0
- maily/utils/misc.py +15 -0
- maily/utils/retry.py +68 -0
- maily_server-0.4.0.dist-info/METADATA +414 -0
- maily_server-0.4.0.dist-info/RECORD +86 -0
- maily_server-0.4.0.dist-info/WHEEL +4 -0
- maily_server-0.4.0.dist-info/entry_points.txt +2 -0
- 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()
|