aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/mail.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:46:48 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:46:48 +0200
commit998f9c69308ee88fac36cfb77dfb6d07c6fa926a (patch)
tree445ba68e566e6672f8334103488d32ba2b52c5c5 /packages/meshbay-hub/src/meshbay_hub/mail.py
parent5bce0acad6d10f9b952874f1359406b4eae3a8f9 (diff)
downloadmeshbay-998f9c69308ee88fac36cfb77dfb6d07c6fa926a.tar.gz
feat(hub): invitation-link tickets bound to a verified address
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/mail.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py54
1 files changed, 48 insertions, 6 deletions
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: