mailroom-core 0.1.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.
- mailroom_core/__init__.py +1 -0
- mailroom_core/config/__init__.py +67 -0
- mailroom_core/config/errors.py +100 -0
- mailroom_core/config/load.py +176 -0
- mailroom_core/config/schema.py +235 -0
- mailroom_core/crypto/__init__.py +19 -0
- mailroom_core/crypto/sealed_secrets.py +65 -0
- mailroom_core/py.typed +0 -0
- mailroom_core/store/__init__.py +51 -0
- mailroom_core/store/accounts.py +249 -0
- mailroom_core/store/database.py +79 -0
- mailroom_core/store/migrations/__init__.py +0 -0
- mailroom_core/store/migrations/env.py +6 -0
- mailroom_core/store/migrations/versions/__init__.py +0 -0
- mailroom_core/store/migrations/versions/v0001_store.py +56 -0
- mailroom_core/store/migrations/versions/v0002_user_key.py +68 -0
- mailroom_core/store/schema.py +75 -0
- mailroom_core/testing/__init__.py +24 -0
- mailroom_core/testing/config.py +43 -0
- mailroom_core/testing/database.py +112 -0
- mailroom_core/testing/mail_servers.py +77 -0
- mailroom_core/testing/no_network.py +83 -0
- mailroom_core/testing/plugin.py +74 -0
- mailroom_core-0.1.0.dist-info/METADATA +31 -0
- mailroom_core-0.1.0.dist-info/RECORD +28 -0
- mailroom_core-0.1.0.dist-info/WHEEL +4 -0
- mailroom_core-0.1.0.dist-info/entry_points.txt +2 -0
- mailroom_core-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mailroom's mail layer, shared by mailroom-mcp and the Mailroom app."""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Accounts and limits: core's half of the config (`src/config/`), the rest being mailroom-mcp's."""
|
|
2
|
+
|
|
3
|
+
from mailroom_core.config.errors import (
|
|
4
|
+
ConfigError,
|
|
5
|
+
ConfigIssue,
|
|
6
|
+
config_problem,
|
|
7
|
+
issues_text,
|
|
8
|
+
validation_issues,
|
|
9
|
+
)
|
|
10
|
+
from mailroom_core.config.load import (
|
|
11
|
+
CoreConfig,
|
|
12
|
+
LoadedCore,
|
|
13
|
+
loaded_core,
|
|
14
|
+
sealed_env_secrets,
|
|
15
|
+
without_secret_envs,
|
|
16
|
+
)
|
|
17
|
+
from mailroom_core.config.schema import (
|
|
18
|
+
MAX_SAFE_INTEGER,
|
|
19
|
+
SEND_MODES,
|
|
20
|
+
AccountAuth,
|
|
21
|
+
AccountConfig,
|
|
22
|
+
CoreSettings,
|
|
23
|
+
Endpoint,
|
|
24
|
+
EnvName,
|
|
25
|
+
Identity,
|
|
26
|
+
Limits,
|
|
27
|
+
NonEmpty,
|
|
28
|
+
PasswordAuth,
|
|
29
|
+
SendMode,
|
|
30
|
+
Slug,
|
|
31
|
+
StrictModel,
|
|
32
|
+
TimeZone,
|
|
33
|
+
TimeZoneName,
|
|
34
|
+
core_settings_or_issues,
|
|
35
|
+
time_zone_name,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"MAX_SAFE_INTEGER",
|
|
40
|
+
"SEND_MODES",
|
|
41
|
+
"AccountAuth",
|
|
42
|
+
"AccountConfig",
|
|
43
|
+
"ConfigError",
|
|
44
|
+
"ConfigIssue",
|
|
45
|
+
"CoreConfig",
|
|
46
|
+
"CoreSettings",
|
|
47
|
+
"Endpoint",
|
|
48
|
+
"EnvName",
|
|
49
|
+
"Identity",
|
|
50
|
+
"Limits",
|
|
51
|
+
"LoadedCore",
|
|
52
|
+
"NonEmpty",
|
|
53
|
+
"PasswordAuth",
|
|
54
|
+
"SendMode",
|
|
55
|
+
"Slug",
|
|
56
|
+
"StrictModel",
|
|
57
|
+
"TimeZone",
|
|
58
|
+
"TimeZoneName",
|
|
59
|
+
"config_problem",
|
|
60
|
+
"core_settings_or_issues",
|
|
61
|
+
"issues_text",
|
|
62
|
+
"loaded_core",
|
|
63
|
+
"sealed_env_secrets",
|
|
64
|
+
"time_zone_name",
|
|
65
|
+
"validation_issues",
|
|
66
|
+
"without_secret_envs",
|
|
67
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Config errors, worded as `src/config/load.ts` words them (zod's `prettifyError`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from pydantic_core import PydanticCustomError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
type _IssuePath = tuple[str | int, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(Exception):
|
|
19
|
+
"""A config or settings document that cannot be used; the message says why, never a secret."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class ConfigIssue:
|
|
24
|
+
"""One problem in a document, at a path like `account[0].smtp.tls`."""
|
|
25
|
+
|
|
26
|
+
path: _IssuePath
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_problem(message: str) -> PydanticCustomError:
|
|
31
|
+
"""A validation error worded exactly `message` (a ValueError would get a prefix)."""
|
|
32
|
+
return PydanticCustomError("config", "{message}", {"message": message})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validation_issues(error: ValidationError, prefix: _IssuePath = ()) -> list[ConfigIssue]:
|
|
36
|
+
"""Pydantic's errors as issues, the unknown keys of one table in one issue as zod has them."""
|
|
37
|
+
details = error.errors(include_url=False, include_input=False)
|
|
38
|
+
paths = [prefix + tuple(detail["loc"]) for detail in details]
|
|
39
|
+
found: list[tuple[_IssuePath, str | list[str]]] = []
|
|
40
|
+
unknown: dict[_IssuePath, list[str]] = {}
|
|
41
|
+
for detail, path in zip(details, paths, strict=True):
|
|
42
|
+
if detail["type"] == "too_short":
|
|
43
|
+
# Pydantic counts the items that passed: an array whose items failed is also short.
|
|
44
|
+
if any(other[: len(path)] == path and other != path for other in paths):
|
|
45
|
+
continue
|
|
46
|
+
minimum = (detail.get("ctx") or {}).get("min_length")
|
|
47
|
+
found.append((path, f"Too small: expected array to have >={minimum} items"))
|
|
48
|
+
elif detail["type"] == "string_too_short":
|
|
49
|
+
minimum = (detail.get("ctx") or {}).get("min_length")
|
|
50
|
+
found.append((path, f"Too small: expected string to have >={minimum} characters"))
|
|
51
|
+
elif detail["type"] != "extra_forbidden":
|
|
52
|
+
found.append((path, _MESSAGES.get(detail["type"], detail["msg"])))
|
|
53
|
+
elif path[:-1] in unknown:
|
|
54
|
+
unknown[path[:-1]].append(str(path[-1]))
|
|
55
|
+
else:
|
|
56
|
+
unknown[path[:-1]] = [str(path[-1])]
|
|
57
|
+
found.append((path[:-1], unknown[path[:-1]]))
|
|
58
|
+
return [
|
|
59
|
+
ConfigIssue(path, message if isinstance(message, str) else _unrecognized(message))
|
|
60
|
+
for path, message in found
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def issues_text(issues: Iterable[ConfigIssue]) -> str:
|
|
65
|
+
"""One `✖ message` line per issue, then ` → at path` unless it is about the whole document."""
|
|
66
|
+
lines: list[str] = []
|
|
67
|
+
for issue in sorted(issues, key=lambda issue: len(issue.path)):
|
|
68
|
+
lines.append(f"✖ {issue.message}")
|
|
69
|
+
if issue.path:
|
|
70
|
+
lines.append(f" → at {_dot_path(issue.path)}")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Pydantic's wording names Python types; the documents are TOML or JSON.
|
|
75
|
+
_MESSAGES = {
|
|
76
|
+
"tuple_type": "Input should be an array",
|
|
77
|
+
"model_type": "Input should be an object",
|
|
78
|
+
"model_attributes_type": "Input should be an object",
|
|
79
|
+
"dict_type": "Input should be an object",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unrecognized(keys: list[str]) -> str:
|
|
84
|
+
quoted = ", ".join(json.dumps(key, ensure_ascii=False) for key in keys)
|
|
85
|
+
return f"Unrecognized key{'s' if len(keys) > 1 else ''}: {quoted}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_PLAIN_KEY = re.compile(r"[\w$]+", re.ASCII)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dot_path(path: _IssuePath) -> str:
|
|
92
|
+
out = ""
|
|
93
|
+
for segment in path:
|
|
94
|
+
if isinstance(segment, int):
|
|
95
|
+
out += f"[{segment}]"
|
|
96
|
+
elif not _PLAIN_KEY.fullmatch(segment):
|
|
97
|
+
out += f"[{json.dumps(segment, ensure_ascii=False)}]"
|
|
98
|
+
else:
|
|
99
|
+
out += f".{segment}" if out else segment
|
|
100
|
+
return out
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Accounts and limits, checked, their secrets opened: core's half of `src/config/load.ts`.
|
|
2
|
+
|
|
3
|
+
mailroom-mcp adds the bootstrap file and the profiles; `CoreConfig` is the config without them,
|
|
4
|
+
which the send gate takes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
from mailroom_core.config.errors import ConfigError
|
|
12
|
+
from mailroom_core.config.schema import (
|
|
13
|
+
AccountConfig,
|
|
14
|
+
CoreSettings,
|
|
15
|
+
Limits,
|
|
16
|
+
TimeZoneName,
|
|
17
|
+
is_env_name,
|
|
18
|
+
)
|
|
19
|
+
from mailroom_core.crypto import SealedSecretError, SecretsSubkey, open_secret, seal_secret
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class CoreConfig:
|
|
24
|
+
"""The running accounts and limits: what the mail layer and the send gate need."""
|
|
25
|
+
|
|
26
|
+
time_zone: TimeZoneName
|
|
27
|
+
"""IANA time zone of the dates shown to the model."""
|
|
28
|
+
limits: Limits
|
|
29
|
+
accounts: tuple[AccountConfig, ...]
|
|
30
|
+
secrets: Mapping[str, str] = field(repr=False)
|
|
31
|
+
"""Mailbox password or token per running account id."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class LoadedCore:
|
|
36
|
+
config: CoreConfig
|
|
37
|
+
warnings: tuple[str, ...]
|
|
38
|
+
"""Problems worth logging that do not stop the server."""
|
|
39
|
+
inactive: tuple[str, ...]
|
|
40
|
+
"""Accounts left out of the config: their stored secret is missing or does not open."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def without_secret_envs(raw: object) -> tuple[object, Mapping[str, str]]:
|
|
44
|
+
"""Take each account's `auth.secret_env` out of raw settings, by account id.
|
|
45
|
+
|
|
46
|
+
Settings may name an account's password by environment variable: config.toml's, being
|
|
47
|
+
imported, or a database's from before 0.2.1. The names come out, for `sealed_env_secrets` to
|
|
48
|
+
store their values; a malformed one stays where it is, for the schema to refuse.
|
|
49
|
+
"""
|
|
50
|
+
secret_envs: dict[str, str] = {}
|
|
51
|
+
if not isinstance(raw, Mapping) or not isinstance(raw.get("account"), list):
|
|
52
|
+
return raw, MappingProxyType(secret_envs)
|
|
53
|
+
accounts: list[object] = []
|
|
54
|
+
for acc in raw["account"]:
|
|
55
|
+
auth = acc.get("auth") if isinstance(acc, Mapping) else None
|
|
56
|
+
if not isinstance(auth, Mapping):
|
|
57
|
+
accounts.append(acc)
|
|
58
|
+
continue
|
|
59
|
+
name = auth.get("secret_env")
|
|
60
|
+
account_id = acc.get("id")
|
|
61
|
+
if not isinstance(account_id, str) or not is_env_name(name):
|
|
62
|
+
accounts.append(acc)
|
|
63
|
+
continue
|
|
64
|
+
secret_envs[account_id] = name
|
|
65
|
+
accounts.append({**acc, "auth": {k: v for k, v in auth.items() if k != "secret_env"}})
|
|
66
|
+
return {**raw, "account": accounts}, MappingProxyType(secret_envs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sealed_env_secrets(
|
|
70
|
+
subkey: SecretsSubkey | None, secret_envs: Mapping[str, str], env: Mapping[str, str]
|
|
71
|
+
) -> dict[str, bytes]:
|
|
72
|
+
"""The passwords `secret_envs` names, read from `env` and sealed for the database.
|
|
73
|
+
|
|
74
|
+
A missing variable or secrets key is a deployment error: nothing is stored until it is fixed.
|
|
75
|
+
"""
|
|
76
|
+
sealed: dict[str, bytes] = {}
|
|
77
|
+
missing: dict[str, None] = {}
|
|
78
|
+
for account_id, name in secret_envs.items():
|
|
79
|
+
value = env.get(name, "")
|
|
80
|
+
if value == "":
|
|
81
|
+
missing[name] = None
|
|
82
|
+
elif subkey is not None:
|
|
83
|
+
sealed[account_id] = seal_secret(subkey, account_id, "password", value)
|
|
84
|
+
if missing:
|
|
85
|
+
raise ConfigError(f"missing secrets in the environment: {', '.join(missing)}")
|
|
86
|
+
if len(sealed) < len(secret_envs):
|
|
87
|
+
raise ConfigError(
|
|
88
|
+
f"storing the passwords of {', '.join(secret_envs)} needs [auth] secrets_key_env"
|
|
89
|
+
" (MAILROOM_SECRETS_KEY)"
|
|
90
|
+
)
|
|
91
|
+
return sealed
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def loaded_core(
|
|
95
|
+
time_zone: TimeZoneName,
|
|
96
|
+
settings: CoreSettings,
|
|
97
|
+
subkey: SecretsSubkey | None,
|
|
98
|
+
stored: Mapping[str, bytes] = MappingProxyType({}),
|
|
99
|
+
) -> LoadedCore:
|
|
100
|
+
"""The running accounts, each with its secret opened from `stored` (sealed blobs by id).
|
|
101
|
+
|
|
102
|
+
An account whose stored secret is missing or does not open (no key, another key) is left out
|
|
103
|
+
with a warning, so the settings page still comes up to fix it.
|
|
104
|
+
"""
|
|
105
|
+
secrets: dict[str, str] = {}
|
|
106
|
+
inactive: list[tuple[str, str]] = []
|
|
107
|
+
for acc in settings.account:
|
|
108
|
+
secret = _stored_secret(subkey, acc.id, stored)
|
|
109
|
+
if isinstance(secret, _Problem):
|
|
110
|
+
inactive.append((acc.id, secret.reason))
|
|
111
|
+
else:
|
|
112
|
+
secrets[acc.id] = secret
|
|
113
|
+
running = tuple(acc for acc in settings.account if acc.id in secrets)
|
|
114
|
+
return LoadedCore(
|
|
115
|
+
config=CoreConfig(
|
|
116
|
+
time_zone=time_zone,
|
|
117
|
+
limits=settings.limits,
|
|
118
|
+
accounts=running,
|
|
119
|
+
secrets=MappingProxyType(secrets),
|
|
120
|
+
),
|
|
121
|
+
warnings=(
|
|
122
|
+
*(
|
|
123
|
+
f'account "{account_id}" is not running: {reason}.'
|
|
124
|
+
" Enter its password on the settings page."
|
|
125
|
+
for account_id, reason in inactive
|
|
126
|
+
),
|
|
127
|
+
*_identity_domain_warnings(running),
|
|
128
|
+
*_auto_approve_warnings(running),
|
|
129
|
+
),
|
|
130
|
+
inactive=tuple(account_id for account_id, _ in inactive),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class _Problem:
|
|
136
|
+
reason: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stored_secret(
|
|
140
|
+
subkey: SecretsSubkey | None, account_id: str, stored: Mapping[str, bytes]
|
|
141
|
+
) -> str | _Problem:
|
|
142
|
+
"""An account's stored secret, or why it cannot be used (never the value)."""
|
|
143
|
+
blob = stored.get(account_id)
|
|
144
|
+
if blob is None:
|
|
145
|
+
return _Problem("it has no stored password")
|
|
146
|
+
if subkey is None:
|
|
147
|
+
return _Problem("its stored password needs [auth] secrets_key_env (MAILROOM_SECRETS_KEY)")
|
|
148
|
+
try:
|
|
149
|
+
return open_secret(subkey, account_id, "password", blob)
|
|
150
|
+
except SealedSecretError:
|
|
151
|
+
return _Problem(
|
|
152
|
+
"its stored password does not open with this secrets key (another key, or altered)"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _domain(address: str) -> str:
|
|
157
|
+
return address[address.rfind("@") + 1 :].lower()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _identity_domain_warnings(accounts: tuple[AccountConfig, ...]) -> list[str]:
|
|
161
|
+
return [
|
|
162
|
+
f'account "{acc.id}": identity {identity.address} is outside the login\'s domain;'
|
|
163
|
+
" the SMTP server may refuse it"
|
|
164
|
+
for acc in accounts
|
|
165
|
+
for identity in acc.identities
|
|
166
|
+
if "@" in acc.login and _domain(identity.address) != _domain(acc.login)
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _auto_approve_warnings(accounts: tuple[AccountConfig, ...]) -> list[str]:
|
|
171
|
+
"""`auto_approve` only means something in `approval` mode; elsewhere it is likely a mistake."""
|
|
172
|
+
return [
|
|
173
|
+
f'account "{acc.id}": auto_approve has no effect with send_mode "{acc.send_mode}"'
|
|
174
|
+
for acc in accounts
|
|
175
|
+
if acc.auto_approve and acc.send_mode != "approval"
|
|
176
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""The account and limit settings: core's half of `src/config/schema.ts` (`settingsSchema`).
|
|
2
|
+
|
|
3
|
+
Profiles, the other half of the settings, and the bootstrap file are mailroom-mcp's. The same
|
|
4
|
+
rules hold for the one-time import from config.toml and for every edit on the settings page.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from functools import cache
|
|
9
|
+
from typing import TYPE_CHECKING, Annotated, Final, Literal, NewType, TypeIs, get_args
|
|
10
|
+
from zoneinfo import available_timezones
|
|
11
|
+
|
|
12
|
+
from pydantic import (
|
|
13
|
+
AfterValidator,
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
Strict,
|
|
18
|
+
StrictBool,
|
|
19
|
+
StrictFloat,
|
|
20
|
+
StrictInt,
|
|
21
|
+
StrictStr,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from mailroom_core.config.errors import ConfigError, ConfigIssue, config_problem, validation_issues
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
|
|
30
|
+
type SendMode = Literal["off", "drafts", "approval", "direct"]
|
|
31
|
+
SEND_MODES: Final[tuple[SendMode, ...]] = get_args(SendMode.__value__)
|
|
32
|
+
|
|
33
|
+
MAX_SAFE_INTEGER: Final = 2**53 - 1
|
|
34
|
+
"""The largest integer a JavaScript number holds exactly: the TypeScript server's bound on
|
|
35
|
+
integers and durations, kept so that both accept the same settings."""
|
|
36
|
+
|
|
37
|
+
TimeZoneName = NewType("TimeZoneName", str)
|
|
38
|
+
"""An IANA time zone known here: made by `time_zone_name` or by a `TimeZone` field only."""
|
|
39
|
+
_TIME_ZONE_PROBLEM = 'must be an IANA time zone, e.g. "Europe/Rome"'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cache
|
|
43
|
+
def _time_zones() -> dict[str, str]:
|
|
44
|
+
return {zone.lower(): zone for zone in available_timezones()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _canonical_time_zone(zone: str) -> str | None:
|
|
48
|
+
"""The IANA name `zone` spells in any letter case, as Intl accepts it; None if unknown."""
|
|
49
|
+
return _time_zones().get(zone.lower()) if zone.isascii() else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def time_zone_name(zone: str) -> TimeZoneName:
|
|
53
|
+
"""`zone` checked as an IANA time zone and given its canonical case; raises `ConfigError`
|
|
54
|
+
otherwise."""
|
|
55
|
+
canonical = _canonical_time_zone(zone)
|
|
56
|
+
if canonical is None:
|
|
57
|
+
raise ConfigError(f"time zone {zone!r} {_TIME_ZONE_PROBLEM}")
|
|
58
|
+
return TimeZoneName(canonical)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _check_time_zone(zone: TimeZoneName) -> TimeZoneName:
|
|
62
|
+
canonical = _canonical_time_zone(zone)
|
|
63
|
+
if canonical is None:
|
|
64
|
+
raise config_problem(_TIME_ZONE_PROBLEM)
|
|
65
|
+
return TimeZoneName(canonical)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
TimeZone = Annotated[TimeZoneName, Strict(), AfterValidator(_check_time_zone)]
|
|
69
|
+
"""A config field holding an IANA time zone, e.g. `Europe/Rome`."""
|
|
70
|
+
|
|
71
|
+
_ENV_NAME = re.compile(r"[A-Z_][A-Z0-9_]*")
|
|
72
|
+
_SLUG = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
73
|
+
# zod's email check, so that both servers accept the same addresses.
|
|
74
|
+
_EMAIL = re.compile(
|
|
75
|
+
r"(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}"
|
|
76
|
+
)
|
|
77
|
+
_RECIPIENT_RULE = re.compile(r"(?:[^\s@]+@|@)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _matching(pattern: re.Pattern[str], message: str) -> Callable[[str], str]:
|
|
81
|
+
def check(text: str) -> str:
|
|
82
|
+
if pattern.fullmatch(text) is None:
|
|
83
|
+
raise config_problem(message)
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
return check
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_env_name(value: object) -> TypeIs[str]:
|
|
90
|
+
"""Whether `value` can name an environment variable: `A-Z`, `0-9` and `_`."""
|
|
91
|
+
return isinstance(value, str) and _ENV_NAME.fullmatch(value) is not None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
EnvName = Annotated[
|
|
95
|
+
StrictStr,
|
|
96
|
+
AfterValidator(_matching(_ENV_NAME, "must be an environment variable name (A-Z, 0-9, _)")),
|
|
97
|
+
]
|
|
98
|
+
Slug = Annotated[
|
|
99
|
+
StrictStr,
|
|
100
|
+
AfterValidator(_matching(_SLUG, "must be lowercase letters, digits and dashes")),
|
|
101
|
+
]
|
|
102
|
+
"""Ids that appear in URLs and tool arguments: `/mcp/<profile>`, `account = "<id>"`."""
|
|
103
|
+
NonEmpty = Annotated[StrictStr, Field(min_length=1)]
|
|
104
|
+
_Email = Annotated[StrictStr, AfterValidator(_matching(_EMAIL, "Invalid email address"))]
|
|
105
|
+
_RecipientRule = Annotated[
|
|
106
|
+
StrictStr,
|
|
107
|
+
AfterValidator(
|
|
108
|
+
_matching(
|
|
109
|
+
_RECIPIENT_RULE,
|
|
110
|
+
"must be an address (ana@example.org) or @ plus a domain (@example.org)",
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
"""An exact recipient or a whole domain: `ana@example.org`, `@example.org`."""
|
|
115
|
+
_Count = Annotated[StrictInt, Field(ge=1, le=MAX_SAFE_INTEGER)]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class StrictModel(BaseModel):
|
|
119
|
+
"""Base of the config models: unknown keys are an error, what is parsed is read-only."""
|
|
120
|
+
|
|
121
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Endpoint(StrictModel):
|
|
125
|
+
host: NonEmpty
|
|
126
|
+
port: Annotated[StrictInt, Field(ge=1, le=65_535)]
|
|
127
|
+
tls: Literal["implicit", "starttls"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Identity(StrictModel):
|
|
131
|
+
address: _Email
|
|
132
|
+
name: NonEmpty | None = None
|
|
133
|
+
default: StrictBool = False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PasswordAuth(StrictModel):
|
|
137
|
+
"""A password or token, stored sealed in the database.
|
|
138
|
+
|
|
139
|
+
config.toml may name it by environment variable instead (`auth.secret_env`):
|
|
140
|
+
`without_secret_envs` takes that out, and the import seals the variable's value.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
type: Literal["password"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# A union discriminated on `type` once there is a second kind (0.4: xoauth2).
|
|
147
|
+
type AccountAuth = PasswordAuth
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AccountConfig(StrictModel):
|
|
151
|
+
id: Slug
|
|
152
|
+
login: NonEmpty
|
|
153
|
+
send_mode: SendMode
|
|
154
|
+
auth: AccountAuth
|
|
155
|
+
imap: Endpoint
|
|
156
|
+
smtp: Endpoint
|
|
157
|
+
identities: Annotated[tuple[Identity, ...], Field(min_length=1)]
|
|
158
|
+
auto_approve: tuple[_RecipientRule, ...] = ()
|
|
159
|
+
"""Recipients (`addr@x` or `@domain`) sent directly in `approval` mode."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Limits(StrictModel):
|
|
163
|
+
send_per_hour: _Count = 20
|
|
164
|
+
max_recipients: _Count = 10
|
|
165
|
+
body_chars: Annotated[StrictInt, Field(ge=500, le=MAX_SAFE_INTEGER)] = 6000
|
|
166
|
+
attachment_max_mb: Annotated[StrictFloat, Field(gt=0, allow_inf_nan=False)] = 10.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class CoreSettings(StrictModel):
|
|
170
|
+
"""Accounts and limits, as stored; only `core_settings_or_issues` makes one with its rules."""
|
|
171
|
+
|
|
172
|
+
limits: Limits = Limits()
|
|
173
|
+
account: tuple[AccountConfig, ...] = ()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def core_settings_or_issues(raw: object) -> CoreSettings | tuple[ConfigIssue, ...]:
|
|
177
|
+
"""The settings, valid and with a lone identity made the default; else what is wrong."""
|
|
178
|
+
try:
|
|
179
|
+
settings = CoreSettings.model_validate({} if raw is None else raw)
|
|
180
|
+
except ValidationError as error:
|
|
181
|
+
return tuple(validation_issues(error))
|
|
182
|
+
issues = _cross_check(settings)
|
|
183
|
+
if issues:
|
|
184
|
+
return issues
|
|
185
|
+
# A single identity is the default one whether or not it says so.
|
|
186
|
+
return settings.model_copy(
|
|
187
|
+
update={
|
|
188
|
+
"account": tuple(
|
|
189
|
+
acc.model_copy(
|
|
190
|
+
update={"identities": (acc.identities[0].model_copy(update={"default": True}),)}
|
|
191
|
+
)
|
|
192
|
+
if len(acc.identities) == 1
|
|
193
|
+
else acc
|
|
194
|
+
for acc in settings.account
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cross_check(settings: CoreSettings) -> tuple[ConfigIssue, ...]:
|
|
201
|
+
issues: list[ConfigIssue] = []
|
|
202
|
+
ids: set[str] = set()
|
|
203
|
+
logins: set[str] = set()
|
|
204
|
+
addresses: dict[str, str] = {}
|
|
205
|
+
for i, acc in enumerate(settings.account):
|
|
206
|
+
if acc.id in ids:
|
|
207
|
+
issues.append(ConfigIssue(("account", i, "id"), f'duplicate account id "{acc.id}"'))
|
|
208
|
+
ids.add(acc.id)
|
|
209
|
+
# Tools accept a login in any case, so two logins differing only in case would be
|
|
210
|
+
# ambiguous.
|
|
211
|
+
login = acc.login.lower()
|
|
212
|
+
if login in logins:
|
|
213
|
+
issues.append(ConfigIssue(("account", i, "login"), f'duplicate login "{acc.login}"'))
|
|
214
|
+
logins.add(login)
|
|
215
|
+
|
|
216
|
+
defaults = sum(identity.default for identity in acc.identities)
|
|
217
|
+
if len(acc.identities) > 1 and defaults != 1:
|
|
218
|
+
issues.append(
|
|
219
|
+
ConfigIssue(
|
|
220
|
+
("account", i, "identities"),
|
|
221
|
+
f"exactly one identity must be marked default (found {defaults})",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
for j, identity in enumerate(acc.identities):
|
|
225
|
+
address = identity.address.lower()
|
|
226
|
+
owner = addresses.get(address)
|
|
227
|
+
if owner is not None:
|
|
228
|
+
issues.append(
|
|
229
|
+
ConfigIssue(
|
|
230
|
+
("account", i, "identities", j, "address"),
|
|
231
|
+
f'identity {address} is already used by account "{owner}"',
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
addresses[address] = acc.id
|
|
235
|
+
return tuple(issues)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Mailroom's cryptography: mailbox secrets sealed at rest."""
|
|
2
|
+
|
|
3
|
+
from mailroom_core.crypto.sealed_secrets import (
|
|
4
|
+
SealedSecretError,
|
|
5
|
+
SecretPurpose,
|
|
6
|
+
SecretsSubkey,
|
|
7
|
+
open_secret,
|
|
8
|
+
seal_secret,
|
|
9
|
+
secrets_subkey,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"SealedSecretError",
|
|
14
|
+
"SecretPurpose",
|
|
15
|
+
"SecretsSubkey",
|
|
16
|
+
"open_secret",
|
|
17
|
+
"seal_secret",
|
|
18
|
+
"secrets_subkey",
|
|
19
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Mailbox secrets sealed at rest, byte-compatible with `src/crypto/sealed-secrets.ts`.
|
|
2
|
+
|
|
3
|
+
The format is in docs/DESIGN.md — *Python port*, *Compatibility*.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from secrets import token_bytes
|
|
7
|
+
from typing import Literal, NewType
|
|
8
|
+
|
|
9
|
+
from cryptography.exceptions import InvalidTag
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
11
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
12
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
13
|
+
|
|
14
|
+
type SecretPurpose = Literal["password"]
|
|
15
|
+
"""What a stored secret is for; the only kind today is a mailbox password or token."""
|
|
16
|
+
|
|
17
|
+
SecretsSubkey = NewType("SecretsSubkey", bytes)
|
|
18
|
+
"""The key that seals stored secrets. Only `secrets_subkey` makes one: never the raw key."""
|
|
19
|
+
|
|
20
|
+
_FORMAT = 1
|
|
21
|
+
_NONCE_BYTES = 12
|
|
22
|
+
_TAG_BYTES = 16
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SealedSecretError(ValueError):
|
|
26
|
+
"""A blob that does not open: wrong key, altered, moved, or not a stored secret at all."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def secrets_subkey(secrets_key: bytes) -> SecretsSubkey:
|
|
30
|
+
"""Derive the sealing key from MAILROOM_SECRETS_KEY (its 32 bytes, decoded) for this one use."""
|
|
31
|
+
hkdf = HKDF(algorithm=SHA256(), length=32, salt=None, info=b"mailroom account secrets")
|
|
32
|
+
return SecretsSubkey(hkdf.derive(secrets_key))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def seal_secret(
|
|
36
|
+
subkey: SecretsSubkey, account_id: str, purpose: SecretPurpose, plaintext: str
|
|
37
|
+
) -> bytes:
|
|
38
|
+
"""AES-256-GCM, as `format ‖ nonce ‖ ciphertext ‖ tag`.
|
|
39
|
+
|
|
40
|
+
The account and purpose are bound in as associated data: a blob copied to another account or
|
|
41
|
+
use does not open.
|
|
42
|
+
"""
|
|
43
|
+
nonce = token_bytes(_NONCE_BYTES)
|
|
44
|
+
sealed = AESGCM(subkey).encrypt(
|
|
45
|
+
nonce, plaintext.encode(), _associated_data(account_id, purpose)
|
|
46
|
+
)
|
|
47
|
+
return bytes([_FORMAT]) + nonce + sealed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def open_secret(subkey: SecretsSubkey, account_id: str, purpose: SecretPurpose, blob: bytes) -> str:
|
|
51
|
+
"""Raise `SealedSecretError` (without the value) unless the blob opens for this account."""
|
|
52
|
+
if len(blob) < 1 + _NONCE_BYTES + _TAG_BYTES or blob[0] != _FORMAT:
|
|
53
|
+
raise SealedSecretError("not a stored secret of a known format")
|
|
54
|
+
nonce = blob[1 : 1 + _NONCE_BYTES]
|
|
55
|
+
try:
|
|
56
|
+
plaintext = AESGCM(subkey).decrypt(
|
|
57
|
+
nonce, blob[1 + _NONCE_BYTES :], _associated_data(account_id, purpose)
|
|
58
|
+
)
|
|
59
|
+
except InvalidTag:
|
|
60
|
+
raise SealedSecretError("stored secret does not open") from None
|
|
61
|
+
return plaintext.decode()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _associated_data(account_id: str, purpose: SecretPurpose) -> bytes:
|
|
65
|
+
return f"mailroom/v1/{account_id}/{purpose}".encode()
|
mailroom_core/py.typed
ADDED
|
File without changes
|