Skip to content

feat(email): key limits by mailbox and account - #41

Open
johnhooks wants to merge 1 commit into
feature/client-ipfrom
feature/email-canonicalizer
Open

feat(email): key limits by mailbox and account#41
johnhooks wants to merge 1 commit into
feature/client-ipfrom
feature/email-canonicalizer

Conversation

@johnhooks

Copy link
Copy Markdown
Collaborator

Branch: feature/email-canonicalizerfeature/client-ip. Stacked on #40, which is itself stacked on #39; review those first.

Per-recipient limits counted the address as typed, so one inbox was handed a fresh allowance for every variant a caller could think of. alice@gmail.com, alice+1@gmail.com, and a.l.i.c.e@gmail.com are three strings and one mailbox, and each of them got its own budget, which is enough to undo the caps in #39 by typing a different tag each time. This branch counts against the mailbox an address actually lands in, and separately fixes what that broke for accounts.

Changes

  • Anything whose effect is mail counts the mailbox: both *_recipient buckets plus the per-request limits on the routes that send (magic_send_email, pw_reset_email, signup_email). The address handed to WorkOS is untouched; the reduced form is a counter key and never a destination.
  • Password and code guessing (password_email, magic_verify_email) count the address as typed. WorkOS keys accounts on the exact string, so alice@ and alice+work@ can both be registered with their own passwords, and counting them together would let an attacker hammering one lock out the other. The two subjects now differ inside a single request on purpose.
  • Which rewrites apply is decided per provider. Tags are dropped only where they are known to be tags, and dots ignored only where they are known to be ignored. The lists are fixed rather than configurable; a self-hosted server that does treat + as a tag gets a counter per tag, which is the milder failure and still bounded by the per-IP limits.
  • Magic verify rejects malformed addresses instead of accepting any non-empty string, matching every sibling route.

Why the provider list, rather than stripping everywhere

Guessing wrong is not symmetric. Collapsing two real mailboxes into one counter lets either exhaust the other's allowance, and on corporate or self-hosted mail business+brian@corp.com is routinely its own mailbox with its own owner, so a blanket rule turns a spam control into a way to lock a colleague out. Splitting one mailbox across counters is the milder failure, because the per-IP limits from #39 still cap what any single caller can cause.

The convention is also less universal than it looks. RFC 5233 states that the encoding of detailed addresses is "site and/or implementation specific" and declines to standardise a separator, RFC 5321 leaves local-part semantics to the destination host, and among large providers Yahoo and iCloud offer disposable addresses rather than tags while Proton uses a different separator.

Behavior changes to note

  • Counter keys for every email-keyed bucket change shape, so those counters reset once on deploy.
  • Gmail users get tighter send limits than before, since tag and dot variants now share one allowance. That is the intended fix, but it is a real change for anyone who was relying on variants to receive more mail.
  • Addresses on providers not in the list are unaffected, including every corporate and self-hosted domain.

Not included: Proton's - separator. Treating - as a tag marker needs its own list and better evidence, since john-smith@corp.com is an ordinary mailbox name. Fastmail's subdomain form (tag@user.fastmail.com) is likewise unhandled.

Details in the "Which mailbox" section of docs/rate-limiting.md, including which buckets must stay canonical if anyone extends the exact-address keying.

@johnhooks johnhooks self-assigned this Aug 4, 2026
Per-recipient limits counted the address as typed, so alice@gmail.com,
alice+1@gmail.com and a.l.i.c.e@gmail.com each earned a separate budget
against one inbox: enough to type past the cap. Anything whose effect is
mail now counts the mailbox an address lands in. The address sent to
WorkOS is untouched; the reduced form is a counter key, never a
destination.

Rewrites apply per provider rather than everywhere. Merging two real
mailboxes lets either starve the other, and business+brian@corp.com is
routinely its own mailbox with its own owner; the opposite error costs
only a counter per tag, still capped per IP. RFC 5233 calls the encoding
site-specific, and Yahoo, iCloud and Proton each disagree with the
convention, so both lists stay fixed and conservative.

Guessing goes the other way: WorkOS keys accounts on the exact string, so
alice@ and alice+work@ can both exist with their own passwords, and
merging them would let an attacker on one lock out the other. Password
and code attempts count the address as typed, so the two subjects differ
within one request.

Magic verify also rejects malformed addresses now, instead of any
non-empty string.
@johnhooks
johnhooks force-pushed the feature/email-canonicalizer branch from 70b7987 to aa04862 Compare August 4, 2026 22:19
Comment on lines +102 to +107
if ( in_array( $domain, self::PLUS_TAG_DOMAINS, true ) ) {
$plus = strpos( $local, '+' );
if ( false !== $plus ) {
$local = substr( $local, 0, $plus );
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right @redscar thanks for calling it out. #38 (comment)

There are still some edge cases, though they are not inadvertently modified, but come through without modification, possibly targeting multiple email addresses but not on the common platforms.

@johnhooks
johnhooks marked this pull request as ready for review August 4, 2026 22:22
@johnhooks
johnhooks requested review from bordoni and redscar August 4, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant