aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py28
1 files changed, 23 insertions, 5 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: