aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 14:21:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit2114a54eb6335f97b0c276c4f1f224d45f46fd1a (patch)
treedbb2aa53f1a82d652f7aeb03e29dee80b6e8e0a5 /packages/meshbay-hub/src/meshbay_hub
parentef4842644a207c3d1b6d6f06c1ad1055270ae283 (diff)
downloadmeshbay-2114a54eb6335f97b0c276c4f1f224d45f46fd1a.tar.gz
feat(hub): the mail state is a panel section, and a ceiling falling is an event
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-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
13 files changed, 195 insertions, 14 deletions
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);