diff options
Diffstat (limited to 'packages/meshbay-hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/mail.py | 28 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_mail_is_not_a_relay.py | 50 |
2 files changed, 66 insertions, 12 deletions
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: diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py index a3f9a21..157dbd5 100644 --- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py +++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py @@ -148,21 +148,57 @@ async def test_one_recipient_has_a_cooldown_and_a_daily_allowance(db_session): "a second purpose walked around the cooldown") cap = MailConfig().destination_daily_cap - key = mail_mod.destination_key(victim) + cool = "cool:invitation:" + mail_mod.destination_key(victim)[len("dest:"):] + + async def past_the_cooldown(): + # Step past it without waiting it out. The daily count is deliberately + # left alone. + row = await db_session.get(MailQuota, cool) + if row: + row.last_sent = None + await db_session.commit() + for _ in range(cap - 1): - # Step past the cooldown without waiting it out. The daily count is - # deliberately left alone. - (await db_session.get(MailQuota, key)).last_sent = None - await db_session.commit() + await past_the_cooldown() assert await _charge(db_session, "invite", victim) - (await db_session.get(MailQuota, key)).last_sent = None - await db_session.commit() + await past_the_cooldown() assert not await _charge(db_session, "invite", victim), ( f"the {cap}-a-day allowance for one recipient was not enforced") @pytest.mark.asyncio +async def test_an_invitation_does_not_stop_the_code_its_invitee_asks_for(db_session): + """ + The regression: an invitation link arrives, the invitee registers a minute + later, and the verification code was refused — silently — by the cooldown + the invitation had just armed. Nobody invited by link could sign up without + waiting two minutes and then asking again, with nothing on screen saying so. + + The two are one conversation, so they do not share a cooldown; each family + still keeps its own, and the daily cap still counts both. + """ + async def charge(purpose, addr): + try: + await mail_mod.reserve(db_session, purpose, addr, sender="owner-1") + except mail_mod.MailRefused: + await db_session.rollback() + return False + await db_session.commit() + return True + + for invitation in ("invite", "invite_link"): + addr = f"newcomer-{invitation}@example.test" + assert await charge(invitation, addr) + assert await charge("registration", addr), ( + f"a verification code was refused after an {invitation} mail") + assert not await charge("password_reset", addr), ( + "within the account family the cooldown must still hold") + assert not await charge("invite", addr), ( + "a second invitation in the same burst must still wait") + + +@pytest.mark.asyncio async def test_the_same_recipient_is_one_recipient_however_it_is_written(db_session): """Case and surrounding space are not a new person.""" assert await _charge(db_session, "registration", "Victim@Example.Test") |