From 998f9c69308ee88fac36cfb77dfb6d07c6fa926a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 17:46:48 +0200 Subject: feat(hub): invitation-link tickets bound to a verified address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit group_invite_links holds sha256(ticket) and the invitee's address blind index; redeeming grants membership to that account only. Owner-only create/list/cancel (a node token may create, never mail), 20 outstanding per group, optional mail written by the hub itself and capped at 10 per sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now carries the whole link design. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/src/meshbay_hub/mail.py | 54 ++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/mail.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index 1eb7c51..d2659e0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -43,13 +43,14 @@ class MailRefused(Exception): # registration confirm an address at sign-up # password_reset a code to the address already on file for that account # invite tell a registered member they were invited to a group +# invite_link send an invitation link to an address, account or not # email_change confirm a new address before it replaces the old one # -# Only `registration` and `email_change` can reach an address this hub has no -# prior relationship with. Both are necessary; both are why the bound that +# Only `registration`, `email_change` and `invite_link` can reach an address this +# hub has no prior relationship with. Both are necessary; both are why the bound that # matters is keyed on the recipient rather than on who asked. ALLOWED_PURPOSES = frozenset({ - "registration", "password_reset", "invite", "email_change"}) + "registration", "password_reset", "invite", "email_change", "invite_link"}) # The two a person is actively waiting on. They may spend the whole hourly # budget; the other two may not spend the reserved share of it, so a flood of @@ -166,7 +167,7 @@ async def _announce_exhaustion(scope: str) -> None: log.warning("Could not announce the mail budget: %s", e) -async def reserve(db, purpose: str, address: str) -> None: +async def reserve(db, purpose: str, address: str, *, sender: str = "") -> None: """Charge one send, or raise MailRefused. The caller owns the commit.""" from meshbay_hub import hub_settings @@ -175,6 +176,18 @@ async def reserve(db, purpose: str, address: str) -> None: limits = await hub_settings.mail_limits(db) + # An invitation link goes to an address that may belong to nobody here, at + # the request of anyone who owns a group — which is anyone, since + # registration is open. So the account asking is counted too, per day + # (docs/MESHBAY_DESIGN.md §3.4, AV29). Charged first: of the three, it is the one + # bound on the person causing the mail, and a refusal further down leaves + # it spent, which errs towards less mail. No sender, no link mail. + if purpose == "invite_link": + if not sender: + raise MailRefused("an invitation link mail names the account sending it") + await _take(db, f"invite_link:{sender}", timedelta(days=1), + limits["invite_link_daily_cap"], None) + # The instance ceiling first, and the recovery share is subtracted for the # purposes that are not one. Registration is open, so a limit counted per # account or per IP is one an attacker buys more of; this one is not. @@ -283,7 +296,7 @@ def _send(msg: EmailMessage, *, purpose: str) -> bool: return False -async def send_off_loop(db, fn, *args, purpose: str, **kwargs) -> bool: +async def send_off_loop(db, fn, *args, purpose: str, sender: str = "", **kwargs) -> bool: """Run one of the `send_*` functions below in a worker thread. `smtplib` is synchronous and this one waits up to ten seconds. Called @@ -304,7 +317,7 @@ async def send_off_loop(db, fn, *args, purpose: str, **kwargs) -> bool: nobody received. """ try: - await reserve(db, purpose, args[0]) + await reserve(db, purpose, args[0], sender=sender) except MailRefused as refusal: # Never the address: this line goes to the journal. log.warning("Mail refused (%s): %s", purpose, refusal) @@ -421,6 +434,35 @@ def send_invite_notification( log.info("Invite notification sent to %s", _mask_email(to)) +def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None: + """An invitation link, to an address that may have no account yet. + + Nothing in it comes from the request but the link, and the link is checked + by the caller to be this hub's own invitation URL before it gets here. + """ + msg = EmailMessage() + msg["From"] = f"noreply@{_hub_domain}" + msg["To"] = to + msg["Subject"] = f"MeshBay — {inviter} invited you to {group_name}" + msg.set_content( + f"{inviter} invited you to the group \"{group_name}\" on MeshBay.\n" + "\n" + "Open this link to create your account, or to sign in, and join the group:\n" + "\n" + f"{link}\n" + "\n" + "It works once, and only for an account registered with this address.\n" + "If you did not expect it, you can ignore this message.\n" + ) + _send(msg, purpose="invite_link") + log.info("Invitation link sent to %s", _mask_email(to)) + + +def hub_url() -> str: + """This hub's own address, as every mail it sends names it.""" + return _hub_url + + def _mask_email(email: str) -> str: local, _, domain = email.partition("@") if len(local) <= 2: -- cgit v1.2.3