diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 13:48:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 16:36:54 +0200 |
| commit | 98e27c8f251022320020716a9ef7a5b892ad2b61 (patch) | |
| tree | 7cfb3f397c3f001768413676a739fe147743c824 /packages/meshbay-hub/src/meshbay_hub | |
| parent | e671b931fd594a39fc840916c81b5d4b1f1e3227 (diff) | |
| download | meshbay-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')
16 files changed, 396 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 219e8a9..087c221 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -38,12 +38,23 @@ class GroupPatchRequest(BaseModel): class SettingsPatchRequest(BaseModel): allow_public_groups: bool | None = None + # Every mail bound, each optional: the panel sends only what changed. + mail: dict[str, int] | None = None # ── Instance settings ──────────────────────────────────────────────────────── -def _settings_payload(allow_public_groups: bool) -> dict: - return {"allow_public_groups": allow_public_groups} +def _settings_payload(allow_public_groups: bool, mail: dict) -> dict: + return { + "allow_public_groups": allow_public_groups, + "mail": mail, + # So the panel can show what a field falls back to, and label the + # bounds it will refuse — rather than the operator finding out by + # having a value silently clamped. + "mail_defaults": {k: hub_settings.mail_default(k) + for k in hub_settings.MAIL_KEYS}, + "mail_bounds": {k: list(v) for k, v in hub_settings.MAIL_BOUNDS.items()}, + } @router.get("/settings") @@ -52,7 +63,9 @@ async def admin_get_settings( db: AsyncSession = Depends(get_db), ): """Instance-wide policy an admin controls from the panel. Moderators may read.""" - return _settings_payload(await hub_settings.public_groups_allowed(db)) + return _settings_payload( + await hub_settings.public_groups_allowed(db), + await hub_settings.mail_limits(db)) @router.patch("/settings") @@ -82,7 +95,45 @@ async def admin_patch_settings( )) await db.commit() - return _settings_payload(await hub_settings.public_groups_allowed(db)) + if body.mail: + unknown = sorted(set(body.mail) - set(hub_settings.MAIL_KEYS)) + if unknown: + raise HTTPException( + status_code=422, detail=f"Unknown mail setting(s): {unknown}") + changed = [] + for key, value in body.mail.items(): + clamped = hub_settings.clamp_mail_value(key, value) + await hub_settings.set_raw(db, f"mail.{key}", str(clamped)) + changed.append(f"{key}={clamped}") + log.info("Mail bounds changed by %s: %s", + current_user.username, ", ".join(changed)) + db.add(IPLog( + user_id=current_user.id, + event="admin_mail_limits_update", + ip_address="admin", + detail=", ".join(changed)[:255], + )) + await db.commit() + + return _settings_payload( + await hub_settings.public_groups_allowed(db), + await hub_settings.mail_limits(db)) + + +@router.get("/mail") +async def admin_mail_status( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), +): + """Is the hub still sending, and how much of the hour is left. + + There was no way to see this at all: a refusal was a line in the journal, + so an instance that had stopped sending registration codes looked, from the + panel, exactly like one that had no sign-ups. + """ + from meshbay_hub import mail + + return await mail.status(db) # ── Stats ──────────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 7b187df..82ae110 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -107,8 +107,14 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: await _backfill_email_hashes() + from meshbay_hub import hub_settings as _hub_settings from meshbay_hub import mail as _mail _mail.configure(cfg.identity.id) + # What `hub.toml` says the mail bounds are. The live values are read + # from `hub_settings` at each use, so an admin can change them from the + # panel while the hub is serving; these are what a missing row falls + # back to. + _hub_settings.set_mail_defaults(cfg.mail) if cfg.identity.admin_usernames: await _sync_admin_roles(cfg.identity.admin_usernames) 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) 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 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 dbe26ab..3cc42db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -12,6 +12,10 @@ export function AdminPage({ token, role }) { const [stats, setStats] = useState(null); const [settings, setSettings] = useState(null); const [settingsSaving, setSettingsSaving] = useState(false); + const [mailStatus, setMailStatus] = useState(null); + // Edited values live here until Save, so a half-typed number is never sent + // and a rejected one never looks applied. + const [mailDraft, setMailDraft] = useState(null); const [users, setUsers] = useState([]); const [usersTotal, setUsersTotal] = useState(0); const [userSearch, setUserSearch] = useState(''); @@ -38,7 +42,11 @@ export function AdminPage({ token, role }) { try { const data = await hubFetch('/v1/admin/settings', { token }); setSettings(data); + setMailDraft({ ...data.mail }); } catch (e) { setError(e.message); } + try { + setMailStatus(await hubFetch('/v1/admin/mail', { token })); + } catch { /* the panel still works without the live figure */ } }, [token]); const saveSettings = useCallback(async (patch) => { @@ -50,6 +58,14 @@ export function AdminPage({ token, role }) { const data = await hubFetch('/v1/admin/settings', { method: 'PATCH', body: patch, token }); setSettings(data); + // The hub clamps what it was given, so the draft is reset from the + // answer rather than left showing a number that was not stored. + setMailDraft({ ...data.mail }); + if (patch.mail) { + try { + setMailStatus(await hubFetch('/v1/admin/mail', { token })); + } catch { /* leave the previous figure rather than blanking it */ } + } } catch (e) { setError(e.message); } finally { setSettingsSaving(false); } }, [token]); @@ -160,7 +176,20 @@ export function AdminPage({ token, role }) { } catch (e) { setError(e.message); } }, [token]); - const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; + // The order the fields are shown in, and the only keys the panel will send. +// The hub refuses anything outside its own list as well — a form is not where +// that decision belongs. +const MAIL_FIELDS = [ + 'hourly_budget', + 'hourly_reserved_for_recovery', + 'destination_daily_cap', + 'destination_cooldown_seconds', + 'verification_resend_cooldown', + 'reset_cooldown', + 'email_change_cooldown', +]; + +const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; const canEditSettings = role === 'admin'; return html` @@ -191,6 +220,53 @@ export function AdminPage({ token, role }) { ${!canEditSettings && html` <p class="settings-hint">${t('admin.settings_readonly')}</p>`} </div> + + <div class="settings-section"> + <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> + `} + + ${mailDraft && MAIL_FIELDS.map(key => html` + <div class="settings-row" key=${key}> + <span class="settings-label">${t('admin.mail_' + key)}</span> + <input type="number" class="settings-number" + min=${(settings.mail_bounds?.[key] || [0])[0]} + max=${(settings.mail_bounds?.[key] || [0, 0])[1]} + value=${mailDraft[key]} + disabled=${!canEditSettings || settingsSaving} + onInput=${e => setMailDraft(d => ({ ...d, [key]: e.target.value }))} /> + </div> + `)} + + ${canEditSettings && mailDraft && html` + <div class="settings-row"> + <button class="btn" disabled=${settingsSaving} + onClick=${() => saveSettings({ + mail: Object.fromEntries(MAIL_FIELDS + // Only what changed, and only what is a number: an empty + // field is someone mid-edit, not a request to set zero. + .filter(k => mailDraft[k] !== '' && mailDraft[k] !== null + && Number(mailDraft[k]) !== settings.mail[k]) + .map(k => [k, Number(mailDraft[k])])), + })}>${t('admin.mail_save')}</button> + <button class="btn btn-secondary" disabled=${settingsSaving} + onClick=${() => setMailDraft({ ...settings.mail_defaults })} + >${t('admin.mail_reset_defaults')}</button> + </div> + `} + </div> `} ${tab === 'stats' && stats && 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 2d67bb2..e5695f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -486,6 +486,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Ausgehende E-Mail", + 'admin.mail_hint': "Was dieser Hub versendet und wie viel. Entscheidend sind das Stundenbudget, das durch keine Zahl von Konten steigt, und die Tagesgrenze pro Empfänger — das ist, was jemand tatsächlich erlebt, der zugeschüttet wird.", + 'admin.mail_this_hour': "In dieser Stunde gesendet", + 'admin.mail_remaining': "{general} übrig für Registrierungen und Adressänderungen, {recovery} für Passphrase-Rücksetzungen und Einladungen.", + 'admin.mail_hourly_budget': "Nachrichten pro Stunde (gesamte Instanz)", + 'admin.mail_hourly_reserved_for_recovery': "Davon reserviert für Rücksetzungen und Einladungen", + 'admin.mail_destination_daily_cap': "Nachrichten pro Tag an einen Empfänger", + 'admin.mail_destination_cooldown_seconds': "Sekunden zwischen Nachrichten an einen Empfänger", + 'admin.mail_verification_resend_cooldown': "Sekunden zwischen zwei Registrierungscodes", + 'admin.mail_reset_cooldown': "Sekunden zwischen zwei Rücksetzungscodes", + '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.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 88ee3da..06b3626 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -476,6 +476,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Outgoing mail", + 'admin.mail_hint': "What this hub will send, and how much. The two that matter are the hourly budget, which no number of accounts can raise, and the daily cap per recipient, which is what someone being flooded actually experiences.", + 'admin.mail_this_hour': "Sent this hour", + 'admin.mail_remaining': "{general} left for sign-ups and address changes, {recovery} for passphrase resets and invitations.", + 'admin.mail_hourly_budget': "Messages per hour (whole instance)", + 'admin.mail_hourly_reserved_for_recovery': "Of those, reserved for resets and invitations", + 'admin.mail_destination_daily_cap': "Messages per day to one recipient", + 'admin.mail_destination_cooldown_seconds': "Seconds between messages to one recipient", + 'admin.mail_verification_resend_cooldown': "Seconds between two sign-up codes", + 'admin.mail_reset_cooldown': "Seconds between two passphrase-reset codes", + '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.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 8dabc10..0a2ed2b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -482,6 +482,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Correo saliente", + 'admin.mail_hint': "Lo que este hub envía, y cuánto. Los dos que importan son el presupuesto por hora, que ningún número de cuentas puede subir, y el límite diario por destinatario, que es lo que vive realmente alguien acosado.", + 'admin.mail_this_hour': "Enviados esta hora", + 'admin.mail_remaining': "Quedan {general} para registros y cambios de dirección, {recovery} para restablecimientos de contraseña e invitaciones.", + 'admin.mail_hourly_budget': "Mensajes por hora (toda la instancia)", + 'admin.mail_hourly_reserved_for_recovery': "De ellos, reservados para restablecimientos e invitaciones", + 'admin.mail_destination_daily_cap': "Mensajes por día a un mismo destinatario", + 'admin.mail_destination_cooldown_seconds': "Segundos entre mensajes al mismo destinatario", + 'admin.mail_verification_resend_cooldown': "Segundos entre dos códigos de registro", + 'admin.mail_reset_cooldown': "Segundos entre dos códigos de restablecimiento", + '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.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 3cc1348..0179c06 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -485,6 +485,19 @@ export default { 'admin.general_groups_heading': "Groupes", 'admin.allow_public_groups_label': "Autoriser les membres à créer des groupes publics", 'admin.allow_public_groups_hint': "Désactivé, les nouveaux groupes ne peuvent être que privés et sur invitation. Les groupes publics existants ne sont pas affectés — suspendez-les un par un depuis l'onglet Groupes.", + 'admin.mail_heading': "Courrier sortant", + 'admin.mail_hint': "Ce que ce hub envoie, et en quelle quantité. Les deux qui comptent sont le budget horaire, qu'aucun nombre de comptes ne fait monter, et le plafond quotidien par destinataire, qui est ce que vit réellement une personne harcelée.", + 'admin.mail_this_hour': "Envoyés cette heure", + 'admin.mail_remaining': "{general} restants pour les inscriptions et changements d'adresse, {recovery} pour les réinitialisations de passphrase et les invitations.", + 'admin.mail_hourly_budget': "Messages par heure (instance entière)", + 'admin.mail_hourly_reserved_for_recovery': "Dont réservés aux réinitialisations et invitations", + 'admin.mail_destination_daily_cap': "Messages par jour vers un même destinataire", + 'admin.mail_destination_cooldown_seconds': "Secondes entre deux messages au même destinataire", + 'admin.mail_verification_resend_cooldown': "Secondes entre deux codes d'inscription", + 'admin.mail_reset_cooldown': "Secondes entre deux codes de réinitialisation", + '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.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 83fe150..cd64ffd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -485,6 +485,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Posta in uscita", + 'admin.mail_hint': "Cosa invia questo hub, e quanto. I due che contano sono il budget orario, che nessun numero di account può alzare, e il limite giornaliero per destinatario, che è ciò che vive davvero chi viene sommerso.", + 'admin.mail_this_hour': "Inviati in quest'ora", + 'admin.mail_remaining': "{general} rimasti per registrazioni e cambi di indirizzo, {recovery} per reimpostazioni della passphrase e inviti.", + 'admin.mail_hourly_budget': "Messaggi all'ora (intera istanza)", + 'admin.mail_hourly_reserved_for_recovery': "Di cui riservati a reimpostazioni e inviti", + 'admin.mail_destination_daily_cap': "Messaggi al giorno verso uno stesso destinatario", + 'admin.mail_destination_cooldown_seconds': "Secondi tra due messaggi allo stesso destinatario", + 'admin.mail_verification_resend_cooldown': "Secondi tra due codici di registrazione", + 'admin.mail_reset_cooldown': "Secondi tra due codici di reimpostazione", + '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.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 75728f8..c7f2c76 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -478,6 +478,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "送信メール", + 'admin.mail_hint': "このハブが送信する内容と量です。重要なのは 2 つ、アカウント数では上げられない 1 時間あたりの上限と、受信者 1 人あたりの 1 日の上限です。後者が、大量に送りつけられる人が実際に受け取る数です。", + 'admin.mail_this_hour': "この 1 時間の送信数", + 'admin.mail_remaining': "登録とアドレス変更に残り {general} 件、パスフレーズ再設定と招待に {recovery} 件。", + 'admin.mail_hourly_budget': "1 時間あたりの通数(インスタンス全体)", + 'admin.mail_hourly_reserved_for_recovery': "うち再設定と招待のために確保する数", + 'admin.mail_destination_daily_cap': "同一受信者への 1 日あたりの通数", + 'admin.mail_destination_cooldown_seconds': "同一受信者への送信間隔(秒)", + 'admin.mail_verification_resend_cooldown': "登録コード再送の間隔(秒)", + 'admin.mail_reset_cooldown': "再設定コード再送の間隔(秒)", + 'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数", + 'admin.mail_save': "メール制限を保存", + 'admin.mail_reset_defaults': "既定値に戻す", '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 ae1c168..519acdc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -486,6 +486,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Uitgaande e-mail", + 'admin.mail_hint': "Wat deze hub verstuurt, en hoeveel. De twee die ertoe doen zijn het uurbudget, dat geen enkel aantal accounts verhoogt, en de daglimiet per ontvanger — dat is wat iemand die wordt bestookt daadwerkelijk merkt.", + 'admin.mail_this_hour': "Dit uur verstuurd", + 'admin.mail_remaining': "{general} over voor registraties en adreswijzigingen, {recovery} voor wachtwoordherstel en uitnodigingen.", + 'admin.mail_hourly_budget': "Berichten per uur (hele instantie)", + 'admin.mail_hourly_reserved_for_recovery': "Daarvan gereserveerd voor herstel en uitnodigingen", + 'admin.mail_destination_daily_cap': "Berichten per dag naar één ontvanger", + 'admin.mail_destination_cooldown_seconds': "Seconden tussen berichten naar één ontvanger", + 'admin.mail_verification_resend_cooldown': "Seconden tussen twee registratiecodes", + 'admin.mail_reset_cooldown': "Seconden tussen twee herstelcodes", + '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.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 b3d1a1d..d2ae19d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -498,6 +498,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Poczta wychodząca", + 'admin.mail_hint': "Co ten hub wysyła i ile. Liczą się dwa: budżet godzinowy, którego nie podniesie żadna liczba kont, oraz dzienny limit na odbiorcę — to właśnie odczuwa ktoś zasypywany wiadomościami.", + 'admin.mail_this_hour': "Wysłane w tej godzinie", + 'admin.mail_remaining': "Pozostało {general} na rejestracje i zmiany adresu, {recovery} na resetowanie hasła i zaproszenia.", + 'admin.mail_hourly_budget': "Wiadomości na godzinę (cała instancja)", + 'admin.mail_hourly_reserved_for_recovery': "Z tego zarezerwowane na resety i zaproszenia", + 'admin.mail_destination_daily_cap': "Wiadomości dziennie do jednego odbiorcy", + 'admin.mail_destination_cooldown_seconds': "Sekundy między wiadomościami do jednego odbiorcy", + 'admin.mail_verification_resend_cooldown': "Sekundy między dwoma kodami rejestracji", + 'admin.mail_reset_cooldown': "Sekundy między dwoma kodami resetu", + '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.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 e0ec3fe..6518d9a 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 @@ -484,6 +484,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "Correio de saída", + 'admin.mail_hint': "O que este hub envia, e quanto. Os dois que importam são o orçamento por hora, que nenhum número de contas aumenta, e o limite diário por destinatário, que é o que alguém sendo inundado realmente sente.", + 'admin.mail_this_hour': "Enviados nesta hora", + 'admin.mail_remaining': "Restam {general} para cadastros e trocas de endereço, {recovery} para redefinições de senha e convites.", + 'admin.mail_hourly_budget': "Mensagens por hora (instância inteira)", + 'admin.mail_hourly_reserved_for_recovery': "Destas, reservadas para redefinições e convites", + 'admin.mail_destination_daily_cap': "Mensagens por dia para um mesmo destinatário", + 'admin.mail_destination_cooldown_seconds': "Segundos entre mensagens ao mesmo destinatário", + 'admin.mail_verification_resend_cooldown': "Segundos entre dois códigos de cadastro", + 'admin.mail_reset_cooldown': "Segundos entre dois códigos de redefinição", + '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.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 01b54c3..1f6881b 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 @@ -470,6 +470,19 @@ export default { 'admin.general_groups_heading': "Groups", 'admin.allow_public_groups_label': "Allow members to create public groups", 'admin.allow_public_groups_hint': "When off, new groups can only be private and invite-only. Existing public groups are left as they are — suspend them one by one from the Groups tab.", + 'admin.mail_heading': "外发邮件", + 'admin.mail_hint': "本 Hub 会发送什么、发送多少。真正起作用的是两项:每小时总量,再多账号也无法提高;以及每位收件人每日上限,这正是被大量骚扰的人实际收到的数量。", + 'admin.mail_this_hour': "本小时已发送", + 'admin.mail_remaining': "注册与更换地址还剩 {general} 封,密码重置与邀请还剩 {recovery} 封。", + 'admin.mail_hourly_budget': "每小时邮件数(整个实例)", + 'admin.mail_hourly_reserved_for_recovery': "其中为重置与邀请保留", + 'admin.mail_destination_daily_cap': "每日发往同一收件人的邮件数", + 'admin.mail_destination_cooldown_seconds': "发往同一收件人的间隔(秒)", + 'admin.mail_verification_resend_cooldown': "两次注册验证码的间隔(秒)", + 'admin.mail_reset_cooldown': "两次重置验证码的间隔(秒)", + 'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数", + 'admin.mail_save': "保存邮件限制", + 'admin.mail_reset_defaults': "恢复默认值", '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 2b35088..c375884 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1592,6 +1592,22 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: var(--text); font-size: 0.95em; } +/* A number that sits beside its label rather than under it — the mail bounds + are a column of short values, and one input per line reads as a table. */ +.settings-number { + width: 110px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text); + font-size: 0.95em; + text-align: right; +} +.settings-number:disabled { + opacity: 0.6; +} + .settings-label input[type="password"], .settings-label input[type="text"], .settings-label select { |