From cd85808c13926c89a97987d320ac26391eae3267 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 17:01:07 +0200 Subject: feat(hub): make mailing an invitation a remembered choice A "Send the invitation by e-mail" box under the Invite member field, checked by default and stored as the invite_email preference. Unchecked, invite-notify is never called and the hub never sees the code. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/src/meshbay_hub/api/users.py | 3 + .../src/meshbay_hub/static/group-settings.js | 69 ++++++++++++++++------ .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../meshbay-hub/tests/test_invite_email_choice.py | 67 +++++++++++++++++++++ 13 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_invite_email_choice.py (limited to 'packages/meshbay-hub') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 2666e86..9b9a189 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1208,6 +1208,9 @@ ALLOWED_PREF_KEYS = frozenset([ "default_tab", "music_keep_screen_on", "media_page_size", + # Whether the Members tab asks the hub to mail an invitation. Remembered + # because unticking it on every invitation is what nobody would keep doing. + "invite_email", ]) # `default_tab:`, which is what the SPA writes (group-page.js). The diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 5f811e8..64ea3fa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -9,6 +9,9 @@ import { hubFetch, navigate } from './hub-client.js'; import { availableApps, configurableApps } from './apps.js'; import * as platform from './platform.js'; +// The account preference behind the "send by e-mail" box. The hub's +// ALLOWED_PREF_KEYS must list it, or every toggle snaps back. +export const INVITE_EMAIL_PREF = 'invite_email'; // ── Shared Directories Table ──────────────────────────────────────────── @@ -410,6 +413,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); + // Whether the hub mails the invitation. Checked, the hub is handed the code + // to write it into the mail — so it is the inviter's choice, remembered per + // account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it. + const [inviteByEmail, setInviteByEmail] = useState(true); const [error, setError] = useState(''); // Node loopback state (Electron-only) @@ -801,6 +808,23 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, useEffect(() => { loadMembers(); }, [loadMembers]); + useEffect(() => { + hubFetch('/v1/users/me/preferences', { token }) + .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false')) + .catch(() => {}); + }, [token]); + + const toggleInviteByEmail = useCallback(async (next) => { + setInviteByEmail(next); + try { + await hubFetch(`/v1/users/me/preferences/${INVITE_EMAIL_PREF}`, { + method: 'PUT', token, body: { value: next ? 'true' : 'false' }, + }); + } catch { + setInviteByEmail(!next); + } + }, [token]); + const isAdmin = group && group.is_admin; const doInvite = useCallback(async (e) => { @@ -838,23 +862,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, method: 'POST', token, body: {}, }); - // Send an email notification to the invitee with the code. - // The hub decrypts their email server-side — the inviter never sees it. - let emailStatus = 'no_email'; - try { - const notif = await hubFetch(`/v1/groups/${groupId}/invite-notify`, { - method: 'POST', token, - // No group_name: the hub reads it from the group row it has already - // loaded. Sending one offered a second answer to a settled question, - // and that answer was the subject line of an email the hub signs. - body: { username, code: result.code }, - }); - emailStatus = notif.status; - } catch { /* best effort */ } + // Send an email notification to the invitee with the code, if the inviter + // asked for it. The hub decrypts their email server-side — the inviter + // never sees it — and so the hub reads the code; without the box ticked + // nothing below runs and the code is shown on this page and nowhere else. + let emailStatus = inviteByEmail ? 'no_email' : 'not_requested'; + if (inviteByEmail) { + try { + const notif = await hubFetch(`/v1/groups/${groupId}/invite-notify`, { + method: 'POST', token, + // No group_name: the hub reads it from the group row it has already + // loaded. Sending one offered a second answer to a settled question, + // and that answer was the subject line of an email the hub signs. + body: { username, code: result.code }, + }); + emailStatus = notif.status; + } catch { /* best effort */ } + } setInviteCode({ username, code: result.code, expires: result.expires_at, - emailSent: emailStatus === 'sent', + emailStatus, }); setInviteUser(''); loadMembers(); @@ -863,7 +891,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } finally { setInviting(false); } - }, [groupId, token, inviteUser, loadMembers, transportRef]); + }, [groupId, token, inviteUser, inviteByEmail, loadMembers, transportRef]); if (loading) return html`

${t('explore.loading')}

`; @@ -893,9 +921,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,

${t('members.invite_code_ready', { user: inviteCode.username })}

${inviteCode.code}

- ${inviteCode.emailSent + ${inviteCode.emailStatus === 'sent' ? html`

${t('members.invite_email_sent')}

` - : html`

${t('members.invite_email_failed')}

` + : inviteCode.emailStatus !== 'not_requested' + && html`

${t('members.invite_email_failed')}

` }

${t('members.invite_code_hint')}

@@ -909,6 +938,12 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${inviting ? '...' : t('members.invite_btn')} + `} 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 730c75b..81a6689 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -685,6 +685,7 @@ export default { 'members.invite_code_ready': 'Einladungscode für {user}:', 'members.invite_email_sent': 'Eine E-Mail mit dem Code wurde an dieses Mitglied gesendet.', 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.', + 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)', 'members.invite_code_hint': 'Sie können diesen Code auch über einen anderen Kanal teilen (z. B. SMS). ' + 'Die eingeladene Person gibt ihn ein, wenn sie diese Gruppe zum ersten Mal öffnet.', 'transfers.title': 'Übertragungen', 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 daa378b..4dee9f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -801,6 +801,7 @@ export default { 'members.invite_code_ready': 'Invitation code for {user}:', 'members.invite_email_sent': 'An email with the code has been sent to this member.', 'members.invite_email_failed': 'Could not send the email — share the code manually.', + 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)', 'members.invite_code_hint': 'You can also share this code via another channel (e.g. SMS). ' + 'They enter it the first time they open this group.', 'transfers.title': 'Transfers', 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 3f8261c..567838d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -680,6 +680,7 @@ export default { 'members.invite_code_ready': 'Código de invitación para {user}:', 'members.invite_email_sent': 'Se ha enviado un correo con el código a este miembro.', 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.', + 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)', 'members.invite_code_hint': 'También puede compartir este código por otro canal (ej. SMS). ' + 'Lo introduce la primera vez que abre este grupo.', 'transfers.title': 'Transferencias', 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 7a0209e..0cc176f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -683,6 +683,7 @@ export default { 'members.invite_code_ready': "Code d’invitation pour {user} :", 'members.invite_email_sent': "Un e-mail avec le code a été envoyé à ce membre.", 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.", + 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)', 'members.invite_code_hint': "Vous pouvez aussi transmettre ce code par un autre canal (ex. SMS). " + "La personne le saisit la première fois qu’elle ouvre ce groupe.", 'transfers.title': 'Transferts', 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 5b99839..03a1762 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -683,6 +683,7 @@ export default { 'members.invite_code_ready': 'Codice di invito per {user}:', 'members.invite_email_sent': "Un'e-mail con il codice è stata inviata a questo membro.", 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.", + 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)', 'members.invite_code_hint': 'Può anche condividere questo codice tramite un altro canale (es. SMS). ' + 'Lo inserirà la prima volta che aprirà questo gruppo.', 'transfers.title': 'Trasferimenti', 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 b825412..f5447bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -675,6 +675,7 @@ export default { 'members.invite_code_ready': '{user} 宛ての招待コード:', 'members.invite_email_sent': 'このメンバーにコード付きのメールが送信されました。', 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。', + 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)', 'members.invite_code_hint': 'このコードは別の手段(SMSなど)でも共有できます。' + '相手がこのグループを初めて開いたときに入力します。', 'transfers.title': '転送', 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 9b6070b..b051a87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -684,6 +684,7 @@ export default { 'members.invite_code_ready': 'Uitnodigingscode voor {user}:', 'members.invite_email_sent': 'Er is een e-mail met de code naar dit lid gestuurd.', 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.', + 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)', 'members.invite_code_hint': 'U kunt deze code ook via een ander kanaal delen (bijv. sms). ' + 'De code werkt één keer en gaat nooit via de hub.', 'transfers.title': 'Overdrachten', 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 20e721f..1e29669 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -696,6 +696,7 @@ export default { 'members.invite_code_ready': 'Kod zaproszenia dla użytkownika {user}:', 'members.invite_email_sent': 'E-mail z kodem został wysłany do tego członka.', 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.', + 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)', 'members.invite_code_hint': 'Możesz też przekazać ten kod innym kanałem (np. SMS). ' + 'Kod działa jednorazowo i nigdy nie przechodzi przez hub.', 'transfers.title': 'Transfery', 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 5506b60..a0ae557 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 @@ -682,6 +682,7 @@ export default { 'members.invite_code_ready': 'Código de convite para {user}:', 'members.invite_email_sent': 'Um e-mail com o código foi enviado a este membro.', 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.', + 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)', 'members.invite_code_hint': 'Você também pode compartilhar este código por outro canal (ex: SMS). ' + 'O código funciona uma única vez e nunca passa pelo hub.', 'transfers.title': 'Transferências', 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 21ccd04..8d3e426 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 @@ -664,6 +664,7 @@ export default { 'members.invite_code_ready': '给 {user} 的邀请码:', 'members.invite_email_sent': '包含验证码的邮件已发送给该成员。', 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。', + 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)', 'members.invite_code_hint': '您也可以通过其他渠道(如短信)分享此验证码。' + '验证码仅可使用一次,且绝不会经过 hub。', 'transfers.title': '传输', diff --git a/packages/meshbay-hub/tests/test_invite_email_choice.py b/packages/meshbay-hub/tests/test_invite_email_choice.py new file mode 100644 index 0000000..e04c31f --- /dev/null +++ b/packages/meshbay-hub/tests/test_invite_email_choice.py @@ -0,0 +1,67 @@ +""" +Whether the hub mails an invitation is the inviter's choice, and it is remembered. + +Mailing it hands the hub the code — `invite-notify` writes it into the message — +which is exactly what §3.4 says the code is for not doing. So the Members tab +offers it as a box, checked by default, and an unchecked box must mean the hub +is never asked. The choice lives in an account preference; a key the hub does +not list is refused, and the box would snap back on every click with nothing on +screen to say why. +""" + +import base64 +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" +SETTINGS = ROOT / "static" / "group-settings.js" +USERS = ROOT / "api" / "users.py" + + +def _pref_key() -> str: + m = re.search(r"^export const INVITE_EMAIL_PREF = '([^']+)';", + SETTINGS.read_text(encoding="utf-8"), re.M) + assert m, "group-settings.js no longer declares INVITE_EMAIL_PREF" + return m.group(1) + + +def test_hub_accepts_the_key_the_client_writes(): + allowed = re.search(r"ALLOWED_PREF_KEYS = frozenset\(\[(.*?)\]\)", + USERS.read_text(encoding="utf-8"), re.S).group(1) + assert f'"{_pref_key()}"' in allowed + + +def test_an_unchecked_box_never_reaches_invite_notify(): + """The only call to `invite-notify` sits inside the branch the box opens.""" + src = SETTINGS.read_text(encoding="utf-8") + calls = [m.start() for m in re.finditer(r"/invite-notify`", src)] + assert len(calls) == 1, "expected exactly one invite-notify call in group-settings.js" + guard = src.rfind("if (inviteByEmail) {", 0, calls[0]) + assert guard != -1, "invite-notify is called without checking the box" + # The guarded block must still be open where the call is: no closing brace + # at the guard's own indentation between the two. + indent = src[src.rfind("\n", 0, guard) + 1:guard] + assert f"\n{indent}}}" not in src[guard:calls[0]], ( + "the box's branch closes before the invite-notify call") + + +@pytest.mark.asyncio +async def test_the_choice_is_remembered(client): + auth_key = base64.b64encode(b"k" * 32).decode() + r = await client.post("/v1/users/register", json={ + "username": "invite_mailer", "email": "invite_mailer@example.test", + "auth_key": auth_key}) + assert r.status_code == 201, r.text + r = await client.post("/v1/users/login", json={ + "username": "invite_mailer", "auth_key": auth_key}) + assert r.status_code == 200, r.text + headers = {"Authorization": f"Bearer {r.json()['access_token']}"} + + key = _pref_key() + r = await client.put(f"/v1/users/me/preferences/{key}", + headers=headers, json={"value": "false"}) + assert r.status_code == 200, r.text + r = await client.get("/v1/users/me/preferences", headers=headers) + assert r.json().get(key) == "false" -- cgit v1.2.3