aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/config.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 13:48:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit98e27c8f251022320020716a9ef7a5b892ad2b61 (patch)
tree7cfb3f397c3f001768413676a739fe147743c824 /packages/meshbay-hub/src/meshbay_hub/config.py
parente671b931fd594a39fc840916c81b5d4b1f1e3227 (diff)
downloadmeshbay-98e27c8f251022320020716a9ef7a5b892ad2b61.tar.gz
feat(hub): the mail bounds are settings, with a panel to change them
They were constants in two modules, so an operator could not touch them without editing code and redeploying — and the hour a budget runs out is not when anyone wants to do that. `[mail]` in hub.toml carries the defaults; the live values live in `hub_settings`, read at each use. A missing row falls back to what the configuration file says, so an instance that never opens the panel behaves as its file describes. The panel sends only what changed, the hub clamps each value to a stated range and refuses a key it does not know, and the response is what gets rendered — so a clamped value is never shown as stored. `GET /v1/admin/mail` is the other half. There was no way to see any of this: a refusal was a line in the journal, so an instance that had stopped sending sign-up codes looked, from the panel, exactly like one with no sign-ups. It reports the hour's use, what is left for sign-ups, and what is left for recovery — the difference between those two being the reserved share made visible. Labels in all ten catalogues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/config.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py46
1 files changed, 46 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index e61e69e..827538c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -93,12 +93,50 @@ class CaptchaConfig:
@dataclass
+class MailConfig:
+ """What the hub will send, and how much of it.
+
+ Defaults, not the live values: an admin changes these from the panel and
+ the change is stored in `hub_settings`, so what is written here is the
+ instance's starting point and what it falls back to if a row is missing.
+
+ The two that matter are per **recipient** and per **instance**. A limit
+ counted per account or per IP bounds a caller, and registration is open, so
+ a caller is something an attacker buys more of.
+ """
+
+ # Between two messages to one address, across every purpose and account.
+ destination_cooldown_seconds: int = 120
+ # And how many that address may receive in a day.
+ destination_daily_cap: int = 10
+ # Everything this instance sends, per hour.
+ hourly_budget: int = 200
+ # Of that budget, the share kept back for the two purposes a person is
+ # waiting on: a passphrase reset and a group invitation. Without it a flood
+ # of sign-ups spends the hour's allowance and locks out the people who
+ # actually need a message to arrive.
+ hourly_reserved_for_recovery: int = 50
+
+ # Between two sign-up codes to one pending account.
+ verification_resend_cooldown: int = 120
+ # Between two passphrase-reset codes for one account, whoever asks. The
+ # code lives an hour, so this stays far below its lifetime.
+ reset_cooldown: int = 300
+ # Between two *different* addresses proposed by one account. Re-asking for
+ # a code for the address already pending is exempt — it reaches no new
+ # recipient, and without the exemption a typo locks the account out for the
+ # whole window.
+ email_change_cooldown: int = 172800 # 48 hours
+
+
+@dataclass
class HubConfig:
db: DatabaseConfig = field(default_factory=DatabaseConfig)
server: ServerConfig = field(default_factory=ServerConfig)
identity: HubIdentityConfig = field(default_factory=HubIdentityConfig)
jwt: JWTConfig = field(default_factory=JWTConfig)
captcha: CaptchaConfig = field(default_factory=CaptchaConfig)
+ mail: MailConfig = field(default_factory=MailConfig)
def load_config(path: Path | None = None) -> HubConfig:
@@ -124,6 +162,14 @@ def load_config(path: Path | None = None) -> HubConfig:
if jwt := raw.get("jwt", {}):
cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl)
cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl)
+ if ml := raw.get("mail", {}):
+ for name in (
+ "destination_cooldown_seconds", "destination_daily_cap",
+ "hourly_budget", "hourly_reserved_for_recovery",
+ "verification_resend_cooldown", "reset_cooldown",
+ "email_change_cooldown",
+ ):
+ setattr(cfg.mail, name, ml.get(name, getattr(cfg.mail, name)))
if cap := raw.get("captcha", {}):
cfg.captcha.site_key = cap.get("site_key", cfg.captcha.site_key)
cfg.captcha.secret_key = cap.get("secret_key", cfg.captcha.secret_key)