summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:01:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:01:07 +0200
commitcd85808c13926c89a97987d320ac26391eae3267 (patch)
treee3e1b12a8ceb8a5ed5b9c9790b6971a2fa2d9d00 /packages
parent3d8c1acf785ff7389a68cc515a5c9324dee8de41 (diff)
downloadmeshbay-cd85808c13926c89a97987d320ac26391eae3267.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js69
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/tests/test_invite_email_choice.py67
13 files changed, 132 insertions, 17 deletions
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:<group_id>`, 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`<p class="page-message">${t('explore.loading')}</p>`;
@@ -893,9 +921,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<div class="success-msg" style="margin-bottom:8px">
<p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
<p class="code-display">${inviteCode.code}</p>
- ${inviteCode.emailSent
+ ${inviteCode.emailStatus === 'sent'
? html`<p style="color:var(--success)">${t('members.invite_email_sent')}</p>`
- : html`<p style="color:var(--text-dim)">${t('members.invite_email_failed')}</p>`
+ : inviteCode.emailStatus !== 'not_requested'
+ && html`<p style="color:var(--text-dim)">${t('members.invite_email_failed')}</p>`
}
<p>${t('members.invite_code_hint')}</p>
</div>
@@ -909,6 +938,12 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${inviting ? '...' : t('members.invite_btn')}
</button>
</div>
+ <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ font-size:0.88em; color:var(--text-secondary)">
+ <input type="checkbox" checked=${inviteByEmail}
+ onChange=${e => toggleInviteByEmail(e.target.checked)} />
+ <span>${t('members.invite_email_opt')}</span>
+ </label>
</form>
</div>
`}
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"