From 3a27a5cd3ae1e752b17d27844cee3d18a35d4498 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 19:01:37 +0200 Subject: fix(hub): an invitation no longer holds back its invitee's sign-up code The per-recipient cooldown was shared by every purpose, so the code a person asked for by registering within two minutes of an invitation link was refused, silently. The cooldown is now per family (invitations vs the account's own steps); the daily cap still counts everything. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/src/meshbay_hub/mail.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index d2659e0..d36a829 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -57,6 +57,21 @@ ALLOWED_PURPOSES = frozenset({ # sign-ups cannot lock out someone trying to recover their passphrase. RECOVERY_PURPOSES = frozenset({"password_reset", "invite"}) +# Which messages share a recipient's cooldown. Within a family the cooldown is +# shared, so a burst cannot walk around it by switching purpose. Across the two +# it is not, because they are two halves of one conversation: an invitation +# arrives, the invitee registers a minute later, and the code they asked for +# was refused — silently — by the cooldown the invitation had just armed. An +# invitation link made that the normal case rather than a rare one. The daily +# cap per recipient still counts every message, whichever family. +# Someone else writes to you about a group; everything else is a step of your +# own account's (a sign-up code, a reset, an address change). +_INVITATIONS = frozenset({"invite", "invite_link"}) + + +def _cooldown_family(purpose: str) -> str: + return "invitation" if purpose in _INVITATIONS else "account" + def destination_key(address: str) -> str: """A stable handle for one recipient that is not the address itself. @@ -204,13 +219,16 @@ async def reserve(db, purpose: str, address: str, *, sender: str = "") -> None: EXHAUSTED_ALL if purpose in RECOVERY_PURPOSES else EXHAUSTED_GENERAL) raise - # Then the recipient: across every purpose, account and endpoint. This is - # what a person being mail-bombed actually experiences, and the only bound - # that describes it. + # Then the recipient. The cooldown per family (above) first, so a message + # refused for coming too soon does not also spend the day's allowance; then + # the daily cap, across every purpose, account and endpoint — what a person + # being mail-bombed actually experiences, and the only bound that says so. + dest = destination_key(address) await _take( - db, destination_key(address), timedelta(days=1), - limits["destination_daily_cap"], + db, f"cool:{_cooldown_family(purpose)}:{dest[len('dest:'):]}", + timedelta(days=1), 10**9, timedelta(seconds=limits["destination_cooldown_seconds"])) + await _take(db, dest, timedelta(days=1), limits["destination_daily_cap"], None) async def status(db) -> dict: -- cgit v1.2.3