From 2114a54eb6335f97b0c276c4f1f224d45f46fd1a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 14:21:12 +0200 Subject: feat(hub): the mail state is a panel section, and a ceiling falling is an event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The figure was a line beside the settings form, which is where it is changed and not where it is watched. It sits with the other live figures under Statistics now — four cards and, above them, a banner saying which of the two ceilings has fallen. The two states are not the same to whoever is reading: one means newcomers are turned away, the other means somebody locked out of their account cannot get back in. The settings block keeps a line pointing at it. And an operator no longer has to be looking. When a global ceiling is reached the administrators are notified — in `mail.py`, in its own session, never raising, because this runs while a request is being refused and an alert that fails must not turn a refusal into a 500. Once per hour, keyed on a row rather than a flag in memory: a flood is what spends the budget, so one alert per refusal would bury the message under its own cause, and a hub that is refusing mail is a hub somebody is about to restart. `/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than leaving the panel to compare two numbers. Labels in all ten catalogues; `.warn-msg` for the middle state, on the `--warn` token both themes already define. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- .../meshbay-hub/tests/test_mail_is_not_a_relay.py | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py index 7c1a9f4..4e5bd6d 100644 --- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py +++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py @@ -542,3 +542,78 @@ def test_the_hub_refuses_to_start_with_more_than_one_worker(): with pytest.raises(SystemExit) as exit_: single_worker_or_exit(workers) assert exit_.value.code == 2 + + +# ── The operator is told, not left to notice ───────────────────────────────── + +@pytest.mark.asyncio +async def test_spending_the_sign_up_share_notifies_the_administrators( + client, db_session): + """ + A refusal is otherwise a line in the journal. A hub that has stopped + sending sign-up codes looks, from every screen anyone opens, exactly like + one nobody is signing up to. + """ + from meshbay_hub.config import MailConfig + + headers = await _admin(client, db_session, "mailwatcher") + await client.delete("/v1/notifications", headers=headers) + + cfg = MailConfig() + general = cfg.hourly_budget - cfg.hourly_reserved_for_recovery + for i in range(general): + await _charge(db_session, "registration", f"ceiling{i}@example.test") + assert not await _charge(db_session, "registration", "over@example.test") + + r = await client.get("/v1/notifications", headers=headers) + assert r.status_code == 200, r.text + kinds = [n["kind"] for n in r.json()["notifications"]] + assert "mail_budget_general" in kinds, ( + "the sign-up ceiling fell and nobody was told") + assert "mail_budget_all" not in kinds, ( + "recovery still has its share; saying otherwise would be alarming and " + "wrong") + + +@pytest.mark.asyncio +async def test_the_administrators_are_told_once_an_hour_not_once_a_refusal( + client, db_session): + """A flood is what spends the budget, so a notification per refusal would + bury the one that matters under the thing that caused it.""" + from meshbay_hub.config import MailConfig + + headers = await _admin(client, db_session, "mailwatcher2") + await client.delete("/v1/notifications", headers=headers) + + cfg = MailConfig() + general = cfg.hourly_budget - cfg.hourly_reserved_for_recovery + for i in range(general): + await _charge(db_session, "registration", f"burst{i}@example.test") + for i in range(5): + assert not await _charge(db_session, "registration", f"over{i}@example.test") + + r = await client.get("/v1/notifications", headers=headers) + general_alerts = [n for n in r.json()["notifications"] + if n["kind"] == "mail_budget_general"] + assert len(general_alerts) == 1, f"{len(general_alerts)} alerts for one hour" + + +@pytest.mark.asyncio +async def test_the_panel_says_which_ceiling_has_fallen(client, db_session): + """Two states, and the difference matters to whoever is reading: one means + newcomers are turned away, the other means somebody locked out of their + account cannot get back in.""" + from meshbay_hub.config import MailConfig + + headers = await _admin(client, db_session, "mailwatcher3") + cfg = MailConfig() + + for i in range(cfg.hourly_budget - cfg.hourly_reserved_for_recovery): + await _charge(db_session, "registration", f"state{i}@example.test") + body = (await client.get("/v1/admin/mail", headers=headers)).json() + assert body["general_exhausted"] and not body["all_exhausted"] + + for i in range(cfg.hourly_reserved_for_recovery): + await _charge(db_session, "password_reset", f"rec{i}@example.test") + body = (await client.get("/v1/admin/mail", headers=headers)).json() + assert body["all_exhausted"] -- cgit v1.2.3