aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/hub_settings.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/hub_settings.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/hub_settings.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py66
1 files changed, 66 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
index 280d1e2..1c2d2c4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -19,6 +19,72 @@ _DEFAULTS: dict[str, str] = {
}
+# ── Mail bounds ──────────────────────────────────────────────────────────────
+#
+# Stored here rather than read from `hub.toml` at each use, because an operator
+# has to be able to change them while the hub is serving: the hour the budget
+# runs out is exactly when nobody wants to edit a file and restart. The TOML
+# values are the defaults these fall back to, so an instance that never touches
+# the panel behaves as its configuration file says.
+
+MAIL_KEYS = (
+ "destination_cooldown_seconds",
+ "destination_daily_cap",
+ "hourly_budget",
+ "hourly_reserved_for_recovery",
+ "verification_resend_cooldown",
+ "reset_cooldown",
+ "email_change_cooldown",
+)
+
+# What a value may be. A cooldown of zero disables it, which is a legitimate
+# thing for an operator to want; a budget of zero would stop the hub sending
+# anything at all, which is not, so those start at one. The upper bounds are
+# there because this is a number typed into a web form.
+MAIL_BOUNDS: dict[str, tuple[int, int]] = {
+ "destination_cooldown_seconds": (0, 86_400),
+ "destination_daily_cap": (1, 1_000),
+ "hourly_budget": (1, 100_000),
+ "hourly_reserved_for_recovery": (0, 100_000),
+ "verification_resend_cooldown": (0, 86_400),
+ "reset_cooldown": (0, 86_400),
+ "email_change_cooldown": (0, 2_592_000), # 30 days
+}
+
+_mail_defaults: dict[str, int] = {}
+
+
+def set_mail_defaults(mail_cfg) -> None:
+ """Record what `hub.toml` said. Called once, at startup."""
+ global _mail_defaults
+ _mail_defaults = {k: int(getattr(mail_cfg, k)) for k in MAIL_KEYS}
+
+
+def mail_default(key: str) -> int:
+ return _mail_defaults.get(key, 0)
+
+
+def clamp_mail_value(key: str, value: int) -> int:
+ low, high = MAIL_BOUNDS[key]
+ return max(low, min(high, int(value)))
+
+
+async def get_int(db: AsyncSession, key: str, fallback: int) -> int:
+ raw = await get_raw(db, key)
+ if raw is None:
+ return fallback
+ try:
+ return int(raw)
+ except ValueError:
+ # A row that cannot be read is not a reason to send without a bound.
+ return fallback
+
+
+async def mail_limits(db: AsyncSession) -> dict[str, int]:
+ """Every mail bound, stored value or configured default."""
+ return {k: await get_int(db, f"mail.{k}", mail_default(k)) for k in MAIL_KEYS}
+
+
async def get_raw(db: AsyncSession, key: str) -> str | None:
row = await db.get(HubSetting, key)
return row.value if row else None