summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/MESHBAY_DESIGN.md3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py78
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js50
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css11
-rw-r--r--packages/meshbay-hub/tests/test_mail_is_not_a_relay.py75
16 files changed, 273 insertions, 16 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 05fb318..3027a21 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,7 +122,7 @@ that produced it.
| Looking for | Read |
|---|---|
| What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, `AV4`, …) | `docs/MESHBAY_DESIGN.md` §13 |
-| What one member can cost the others (`AV1`–`AV17`) | §13.5b — the newest category, and the one the first three reviews had no question for |
+| What one member can cost the others (`AV1`–`AV18`) | §13.5b — the newest category, and the one the first three reviews had no question for |
| Trust model, and what the project may and may not claim | §2 |
| Identity, devices, admission, recovery, the keypair bundle | §3 |
| Cryptography, key hierarchy, the group and chat envelopes | §4 |
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index be74e64..577612d 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2531,7 +2531,8 @@ had already been asked.
| **AV11** | **A namespace a client writes into is closed, and its rows are capped.** The preference key space is an allow-list plus `default_tab:<group_id>` checked as a group id, the value is length-bounded, and the row count per account is bounded |
| **AV12** | **Every list has an upper bound on `limit` and a floor under `offset`.** Including the ones that take no authentication at all — the public group directory and the content blocklist |
| **AV16** | **A bound the operator can see and change, and that a restart does not forget.** The mail allowance lives in `mail_quota`, not in a module dict — a deploy used to hand out a fresh budget, and the hub is deployed often. The values are settings with defaults in `hub.toml` and a block in the admin panel, because the hour a budget runs out is not when anyone wants to edit a file and restart; `/v1/admin/mail` says how much of the hour is left, which was previously visible only as an absence of mail |
-| **AV17** | **The hub runs on exactly one worker, and says so at startup.** `_connected_nodes`, `_node_groups`, `_webrtc_answers` and the relay registry are per-process: a second worker makes a node intermittently unreachable for half its members, which is a symptom that describes something else entirely |
+| **AV17** | **A global ceiling being reached is an event, not an absence.** When the hourly budget runs out the administrators are notified — once per hour, because a flood is what spends it and one alert per refusal buries the message under its own cause — and the panel says which of the two ceilings fell: newcomers turned away, or somebody locked out of their account unable to get back in |
+| **AV18** | **The hub runs on exactly one worker, and says so at startup.** `_connected_nodes`, `_node_groups`, `_webrtc_answers` and the relay registry are per-process: a second worker makes a node intermittently unreachable for half its members, which is a symptom that describes something else entirely |
| **AV14** | **MHP binds its audience, and the hub reads its own identity at call time.** A token is minted for one peer and accepted by that peer only. `federation.py` bound `_hub_id` and `_hub_sk_pem` at import, which is before `load_hub_keypair` runs, so it signed with `None` and called itself `meshbay.org` whatever the instance was named — and the verifier named no audience for the `aud` the issuer sets, which PyJWT refuses outright. MHP could not complete one authenticated request between two hubs |
| **AV15** | **A hash is checked for shape before it is a key lookup**, on the unauthenticated blocklist endpoints a node consults |
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index 661a1f3..aa6b74e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -105,6 +105,67 @@ async def _take(db, key: str, window: timedelta, ceiling: int,
row.last_sent = now
+# The two global ceilings, and what each one means to somebody waiting.
+EXHAUSTED_GENERAL = "general" # sign-ups and address changes refused
+EXHAUSTED_ALL = "all" # even a passphrase reset is refused
+
+
+async def _announce_exhaustion(scope: str) -> None:
+ """Tell the administrators, once per hour, that the hub stopped sending.
+
+ In its own session, and never raising: this runs while a request is being
+ refused, and an alert that fails must not turn a refusal into a 500. Same
+ reason `_mark_hosted` opens its own session next door.
+
+ Once per window, keyed on a row rather than a flag in memory, because the
+ thing being reported is exactly the kind of event a restart would erase —
+ and a hub that is refusing mail is a hub somebody is probably restarting.
+ """
+ from sqlalchemy import select
+
+ from meshbay_hub.api.deps import user_is_admin
+ from meshbay_hub.api.notifications import create_notification
+ from meshbay_hub.db.engine import get_session_factory
+ from meshbay_hub.db.models import MailQuota, User
+
+ try:
+ async with get_session_factory()() as db:
+ key = f"alert:{scope}"
+ now = datetime.now(timezone.utc)
+ row = await db.get(MailQuota, key)
+ if row is not None and row.last_sent is not None:
+ last = row.last_sent
+ if last.tzinfo is None:
+ last = last.replace(tzinfo=timezone.utc)
+ if now - last < timedelta(hours=1):
+ return
+ if row is None:
+ row = MailQuota(key=key, window_start=now, count=0)
+ db.add(row)
+ row.last_sent = now
+ row.count += 1
+
+ admins = [u for u in (await db.execute(select(User).where(
+ User.status == "active"))).scalars().all() if user_is_admin(u)]
+ for admin in admins:
+ await create_notification(
+ db, admin.id, f"mail_budget_{scope}",
+ "This hub has stopped sending mail for this hour"
+ if scope == EXHAUSTED_ALL
+ else "This hub has stopped sending sign-up mail for this hour",
+ detail=("Passphrase resets and invitations are refused too."
+ if scope == EXHAUSTED_ALL
+ else "Passphrase resets and invitations still go out."),
+ link="#/admin",
+ aggregate=False,
+ )
+ await db.commit()
+ log.warning("Mail budget exhausted (%s) — %d administrator(s) told",
+ scope, len(admins))
+ except Exception as e:
+ log.warning("Could not announce the mail budget: %s", e)
+
+
async def reserve(db, purpose: str, address: str) -> None:
"""Charge one send, or raise MailRefused. The caller owns the commit."""
from meshbay_hub import hub_settings
@@ -120,7 +181,15 @@ async def reserve(db, purpose: str, address: str) -> None:
budget = limits["hourly_budget"]
if purpose not in RECOVERY_PURPOSES:
budget = max(0, budget - limits["hourly_reserved_for_recovery"])
- await _take(db, "hour", timedelta(hours=1), budget, None)
+ try:
+ await _take(db, "hour", timedelta(hours=1), budget, None)
+ except MailRefused:
+ # An operator finds out here or not at all: a refusal is otherwise a
+ # line in the journal, and a hub that has stopped sending sign-up
+ # codes looks exactly like one nobody is signing up to.
+ await _announce_exhaustion(
+ EXHAUSTED_ALL if purpose in RECOVERY_PURPOSES else EXHAUSTED_GENERAL)
+ raise
# Then the recipient: across every purpose, account and endpoint. This is
# what a person being mail-bombed actually experiences, and the only bound
@@ -153,7 +222,14 @@ async def status(db) -> dict:
.where(MailQuota.key.like("dest:%"))) or 0
general = max(0, limits["hourly_budget"] - limits["hourly_reserved_for_recovery"])
+ # Named rather than left for the reader to compute from two numbers: the
+ # panel draws a warning off these, and "is it still sending" is the
+ # question an operator opens this page to answer.
+ general_exhausted = used >= general
+ all_exhausted = used >= limits["hourly_budget"]
return {
+ "general_exhausted": general_exhausted,
+ "all_exhausted": all_exhausted,
"hourly_budget": limits["hourly_budget"],
"hourly_used": used,
"hour_started_at": window_start,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index 3cc42db..8800615 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -105,7 +105,11 @@ export function AdminPage({ token, role }) {
useEffect(() => {
setError('');
if (tab === 'general') loadSettings();
- else if (tab === 'stats') loadStats();
+ else if (tab === 'stats') {
+ loadStats();
+ hubFetch('/v1/admin/mail', { token })
+ .then(setMailStatus).catch(() => { /* the cards still render */ });
+ }
else if (tab === 'users') loadUsers(userSearch);
else if (tab === 'groups') loadGroups();
else if (tab === 'nodes') {
@@ -225,18 +229,7 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
<h3 class="settings-heading">${t('admin.mail_heading')}</h3>
<p class="settings-hint">${t('admin.mail_hint')}</p>
- ${mailStatus && html`
- <div class="settings-row">
- <span class="settings-label">${t('admin.mail_this_hour')}</span>
- <span>${mailStatus.hourly_used} / ${mailStatus.hourly_budget}</span>
- </div>
- <p class="settings-hint">
- ${t('admin.mail_remaining', {
- general: mailStatus.general_remaining,
- recovery: mailStatus.recovery_remaining,
- })}
- </p>
- `}
+ <p class="settings-hint">${t('admin.mail_state_is_in_stats')}</p>
${mailDraft && MAIL_FIELDS.map(key => html`
<div class="settings-row" key=${key}>
@@ -279,6 +272,37 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
</div>
`)}
</div>
+
+ ${mailStatus && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.mail_heading')}</h3>
+
+ ${mailStatus.all_exhausted && html`
+ <div class="error-msg">${t('admin.mail_alert_all')}</div>`}
+ ${!mailStatus.all_exhausted && mailStatus.general_exhausted && html`
+ <div class="warn-msg">${t('admin.mail_alert_general')}</div>`}
+
+ <div class="admin-stats">
+ <div class="stat-card">
+ <div class="stat-value">${mailStatus.hourly_used} / ${mailStatus.hourly_budget}</div>
+ <div class="stat-label">${t('admin.mail_this_hour')}</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-value">${mailStatus.general_remaining}</div>
+ <div class="stat-label">${t('admin.mail_left_signups')}</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-value">${mailStatus.recovery_remaining}</div>
+ <div class="stat-label">${t('admin.mail_left_recovery')}</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-value">${mailStatus.recipients_tracked}</div>
+ <div class="stat-label">${t('admin.mail_recipients')}</div>
+ </div>
+ </div>
+ <p class="settings-hint">${t('admin.mail_state_hint')}</p>
+ </div>
+ `}
`}
${tab === 'users' && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index e5695f3..f460255 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -499,6 +499,13 @@ export default {
'admin.mail_email_change_cooldown': "Sekunden, bevor ein Konto eine andere Adresse vorschlagen darf",
'admin.mail_save': "Mail-Grenzen speichern",
'admin.mail_reset_defaults': "Standardwerte wiederherstellen",
+ 'admin.mail_state_is_in_stats': "Der Verbrauch der aktuellen Stunde steht unter Statistik.",
+ 'admin.mail_state_hint': "Rücksetzungen und Einladungen dürfen das ganze Budget nutzen; Registrierungen und Adressänderungen nicht den dafür reservierten Anteil. Administratoren werden einmal pro Stunde benachrichtigt, wenn eine der beiden Grenzen erreicht ist.",
+ 'admin.mail_left_signups': "Rest für Registrierungen",
+ 'admin.mail_left_recovery': "Rest für Rücksetzungen und Einladungen",
+ 'admin.mail_recipients': "Heute gezählte Empfänger",
+ 'admin.mail_alert_all': "Dieser Hub versendet in dieser Stunde keine E-Mail mehr. Auch Rücksetzungen und Einladungen werden abgelehnt.",
+ 'admin.mail_alert_general': "Der Anteil für Registrierungen und Adressänderungen ist für diese Stunde aufgebraucht. Rücksetzungen und Einladungen gehen weiterhin raus.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 06b3626..e7ffa64 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -489,6 +489,13 @@ export default {
'admin.mail_email_change_cooldown': "Seconds before an account may propose another address",
'admin.mail_save': "Save mail limits",
'admin.mail_reset_defaults': "Restore defaults",
+ 'admin.mail_state_is_in_stats': "The current hour's usage is shown under Statistics.",
+ 'admin.mail_state_hint': "Resets and invitations may spend the whole budget; sign-ups and address changes may not spend the share reserved for them. Administrators are notified once per hour when either ceiling is reached.",
+ 'admin.mail_left_signups': "Left for sign-ups",
+ 'admin.mail_left_recovery': "Left for resets and invitations",
+ 'admin.mail_recipients': "Recipients counted today",
+ 'admin.mail_alert_all': "This hub has stopped sending mail for this hour. Passphrase resets and invitations are refused too.",
+ 'admin.mail_alert_general': "The share for sign-ups and address changes is spent for this hour. Passphrase resets and invitations still go out.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 0a2ed2b..2641708 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -495,6 +495,13 @@ export default {
'admin.mail_email_change_cooldown': "Segundos antes de que una cuenta pueda proponer otra dirección",
'admin.mail_save': "Guardar límites de correo",
'admin.mail_reset_defaults': "Restaurar valores por defecto",
+ 'admin.mail_state_is_in_stats': "El uso de la hora actual se muestra en Estadísticas.",
+ 'admin.mail_state_hint': "Los restablecimientos y las invitaciones pueden gastar todo el presupuesto; los registros y cambios de dirección no pueden tocar la parte reservada. Se avisa a los administradores una vez por hora cuando se alcanza cualquiera de los dos límites.",
+ 'admin.mail_left_signups': "Restante para registros",
+ 'admin.mail_left_recovery': "Restante para restablecimientos e invitaciones",
+ 'admin.mail_recipients': "Destinatarios contados hoy",
+ 'admin.mail_alert_all': "Este hub ha dejado de enviar correo durante esta hora. También se rechazan los restablecimientos e invitaciones.",
+ 'admin.mail_alert_general': "La parte para registros y cambios de dirección está agotada esta hora. Los restablecimientos e invitaciones siguen saliendo.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 0179c06..0c639b7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -498,6 +498,13 @@ export default {
'admin.mail_email_change_cooldown': "Secondes avant qu'un compte puisse proposer une autre adresse",
'admin.mail_save': "Enregistrer les limites",
'admin.mail_reset_defaults': "Rétablir les valeurs par défaut",
+ 'admin.mail_state_is_in_stats': "La consommation de l'heure en cours est affichée dans Statistiques.",
+ 'admin.mail_state_hint': "Les réinitialisations et invitations peuvent dépenser tout le budget ; les inscriptions et changements d'adresse ne peuvent pas entamer la part qui leur est réservée. Les administrateurs sont prévenus une fois par heure lorsqu'un des deux plafonds est atteint.",
+ 'admin.mail_left_signups': "Restant pour les inscriptions",
+ 'admin.mail_left_recovery': "Restant pour réinitialisations et invitations",
+ 'admin.mail_recipients': "Destinataires comptés aujourd'hui",
+ 'admin.mail_alert_all': "Ce hub n'envoie plus de courrier pour cette heure. Les réinitialisations de passphrase et les invitations sont refusées aussi.",
+ 'admin.mail_alert_general': "La part réservée aux inscriptions et changements d'adresse est épuisée pour cette heure. Les réinitialisations et invitations partent toujours.",
'admin.settings_readonly': "Seul un administrateur peut modifier ces paramètres.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index cd64ffd..efda136 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -498,6 +498,13 @@ export default {
'admin.mail_email_change_cooldown': "Secondi prima che un account possa proporre un altro indirizzo",
'admin.mail_save': "Salva i limiti di posta",
'admin.mail_reset_defaults': "Ripristina i valori predefiniti",
+ 'admin.mail_state_is_in_stats': "Il consumo dell'ora corrente è mostrato in Statistiche.",
+ 'admin.mail_state_hint': "Reimpostazioni e inviti possono spendere l'intero budget; registrazioni e cambi di indirizzo non possono intaccare la quota riservata. Gli amministratori vengono avvisati una volta all'ora quando uno dei due limiti viene raggiunto.",
+ 'admin.mail_left_signups': "Rimanente per le registrazioni",
+ 'admin.mail_left_recovery': "Rimanente per reimpostazioni e inviti",
+ 'admin.mail_recipients': "Destinatari contati oggi",
+ 'admin.mail_alert_all': "Questo hub ha smesso di inviare posta per quest'ora. Anche reimpostazioni e inviti sono rifiutati.",
+ 'admin.mail_alert_general': "La quota per registrazioni e cambi di indirizzo è esaurita per quest'ora. Reimpostazioni e inviti partono ancora.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index c7f2c76..22f5d6b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -491,6 +491,13 @@ export default {
'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数",
'admin.mail_save': "メール制限を保存",
'admin.mail_reset_defaults': "既定値に戻す",
+ 'admin.mail_state_is_in_stats': "現在の 1 時間の使用状況は「統計」に表示されます。",
+ 'admin.mail_state_hint': "再設定と招待は上限全体を使えます。登録とアドレス変更は、確保された分には手を付けられません。いずれかの上限に達すると、管理者に 1 時間に 1 回通知されます。",
+ 'admin.mail_left_signups': "登録に残っている数",
+ 'admin.mail_left_recovery': "再設定・招待に残っている数",
+ 'admin.mail_recipients': "本日カウントした受信者",
+ 'admin.mail_alert_all': "このハブはこの 1 時間、メール送信を停止しました。パスフレーズ再設定と招待も拒否されます。",
+ 'admin.mail_alert_general': "登録とアドレス変更のための分はこの 1 時間で使い切りました。再設定と招待は引き続き送信されます。",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 519acdc..88aebfa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -499,6 +499,13 @@ export default {
'admin.mail_email_change_cooldown': "Seconden voordat een account een ander adres mag voorstellen",
'admin.mail_save': "E-maillimieten opslaan",
'admin.mail_reset_defaults': "Standaardwaarden herstellen",
+ 'admin.mail_state_is_in_stats': "Het verbruik van dit uur staat onder Statistieken.",
+ 'admin.mail_state_hint': "Herstel en uitnodigingen mogen het hele budget gebruiken; registraties en adreswijzigingen niet het gereserveerde deel. Beheerders krijgen één keer per uur bericht wanneer een van beide grenzen is bereikt.",
+ 'admin.mail_left_signups': "Resterend voor registraties",
+ 'admin.mail_left_recovery': "Resterend voor herstel en uitnodigingen",
+ 'admin.mail_recipients': "Vandaag getelde ontvangers",
+ 'admin.mail_alert_all': "Deze hub verstuurt dit uur geen e-mail meer. Ook herstel en uitnodigingen worden geweigerd.",
+ 'admin.mail_alert_general': "Het deel voor registraties en adreswijzigingen is dit uur op. Herstel en uitnodigingen gaan nog wel uit.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index d2ae19d..bed39d2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -511,6 +511,13 @@ export default {
'admin.mail_email_change_cooldown': "Sekundy, zanim konto może zaproponować inny adres",
'admin.mail_save': "Zapisz limity poczty",
'admin.mail_reset_defaults': "Przywróć domyślne",
+ 'admin.mail_state_is_in_stats': "Zużycie w bieżącej godzinie pokazano w Statystykach.",
+ 'admin.mail_state_hint': "Resety i zaproszenia mogą wykorzystać cały budżet; rejestracje i zmiany adresu nie mogą naruszyć zarezerwowanej części. Administratorzy są powiadamiani raz na godzinę, gdy któryś z limitów zostanie osiągnięty.",
+ 'admin.mail_left_signups': "Pozostało na rejestracje",
+ 'admin.mail_left_recovery': "Pozostało na resety i zaproszenia",
+ 'admin.mail_recipients': "Odbiorcy policzeni dzisiaj",
+ 'admin.mail_alert_all': "Ten hub przestał wysyłać pocztę w tej godzinie. Resety i zaproszenia również są odrzucane.",
+ 'admin.mail_alert_general': "Część na rejestracje i zmiany adresu wyczerpała się w tej godzinie. Resety i zaproszenia nadal są wysyłane.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 6518d9a..cd5da7d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -497,6 +497,13 @@ export default {
'admin.mail_email_change_cooldown': "Segundos até uma conta poder propor outro endereço",
'admin.mail_save': "Salvar limites de correio",
'admin.mail_reset_defaults': "Restaurar padrões",
+ 'admin.mail_state_is_in_stats': "O consumo da hora atual aparece em Estatísticas.",
+ 'admin.mail_state_hint': "Redefinições e convites podem gastar todo o orçamento; cadastros e trocas de endereço não podem usar a parte reservada. Os administradores são avisados uma vez por hora quando qualquer um dos limites é atingido.",
+ 'admin.mail_left_signups': "Restante para cadastros",
+ 'admin.mail_left_recovery': "Restante para redefinições e convites",
+ 'admin.mail_recipients': "Destinatários contados hoje",
+ 'admin.mail_alert_all': "Este hub parou de enviar e-mail nesta hora. Redefinições e convites também são recusados.",
+ 'admin.mail_alert_general': "A parte para cadastros e trocas de endereço acabou nesta hora. Redefinições e convites continuam saindo.",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 1f6881b..06aa011 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -483,6 +483,13 @@ export default {
'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数",
'admin.mail_save': "保存邮件限制",
'admin.mail_reset_defaults': "恢复默认值",
+ 'admin.mail_state_is_in_stats': "本小时的用量显示在「统计」中。",
+ 'admin.mail_state_hint': "重置与邀请可动用全部额度;注册与更换地址不得占用为前者保留的份额。任一上限达到时,每小时通知管理员一次。",
+ 'admin.mail_left_signups': "注册剩余",
+ 'admin.mail_left_recovery': "重置与邀请剩余",
+ 'admin.mail_recipients': "今日计入的收件人",
+ 'admin.mail_alert_all': "本 Hub 在本小时已停止发送邮件,密码重置与邀请同样被拒绝。",
+ 'admin.mail_alert_general': "本小时用于注册与更换地址的份额已用尽,密码重置与邀请仍会发送。",
'admin.settings_readonly': "Only an admin can change these settings.",
// Admin stats
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index c375884..871ca0a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -846,6 +846,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: 0.85em;
}
+/* The middle state: the hub is still sending what somebody is waiting on,
+ but not what a newcomer needs. `--warn` already exists in both themes. */
+.warn-msg {
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
+ color: var(--warn);
+ border: 1px solid var(--warn);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 0.85em;
+}
+
.success-msg {
background: #16a34a20;
color: var(--success);
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"]