Repository navigation
Treat email addresses case-insensitively - #1159
Merged
Merged
Conversation
Addresses were stored exactly as typed and looked up the same way, so an account registered with capitals could not sign in when the address was typed in lower case, and a second registration created a separate account with its own workspace and trial. - Add common/email.util: normalizeEmail, sameEmail and a case-insensitive user lookup. Prisma's insensitive equals is an unescaped ILIKE, so LIKE wildcards are escaped and rows are re-checked in code. When legacy duplicates match, the exact spelling wins, then the most recently updated row. - Use it for register, password and MCP OAuth login, recovery login, forgot password, invite and accept-invite, self-hosted email change, SSO JIT and SCIM create/update/filter, audit user resolution and the unsubscribe lookup. New addresses and invitations are stored normalised; trial and community licence requests send the normalised address. - Migration lower-cases users.email only where no other row collides, and normalises invitation emails. Colliding rows are left untouched. - scripts/report-duplicate-emails.sql lists the colliding groups for manual merging (read-only).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
User emails were stored exactly as typed, and registration and login looked them up the same way.
Name@Example.comandname@example.comwere two different accounts. Someone who registered with an uppercase address and later typed it in lower case could not sign in. If they registered again, they got a second, empty workspace with a new trial, and anything bought on the first account stayed there.Production currently has 269 users with uppercase letters in their email, and at least 9 addresses that exist twice when lower-cased.
Changes
src/common/email.util.ts:normalizeEmail(trim + lower case),sameEmail,emailLookupWhere,findUserByEmail/findUsersByEmailandpickEmailMatch.mode: 'insensitive'equals compiles to an unescapedILIKEon PostgreSQL. I checked this against a real database:a_b@x.comalso matchedaxb@x.com, anda%@x.commatched everything starting witha. The helper escapes\,%and_, also matches the exact spellings with plain equality, and filters the rows in code withsameEmail. A match is therefore always the same address.POST /api/auth/register(cloud and self-hosted): an existing case variant gets today's answer (neutral answer plus existing-account notice on cloud,409 Email already registeredon self-hosted). New accounts are stored normalised (UsersService.create).POST /api/auth/loginandPOST /api/auth/login/recovery(viaUsersService.findByEmail).POST /auth/login, the MCP OAuth sign-in / consent form (LoginController.handleLogin).POST /api/auth/forgot-password(reset-password and email verification are keyed by token / user id and need no change).POST /api/auth/invite: the invitation is stored normalised, and a pending invitation is reused whatever its case.GET /api/auth/invite/verifyandPOST /api/auth/accept-invite: account lookup ignores case, session ownership is compared withsameEmail, and new accounts are stored normalised.PUT /api/users/meemail change: cloud comparison usessameEmail. Self-hosted stores the address normalised and refuses with409if another account has it in any case.SsoService.resolveUser): the address is normalised, and the collision check ignores case, so a JIT sign-in can no longer create a lower-case twin next to a localName@…account.userName/emailsnormalised on create, PUT and PATCH. The create collision check ignores case. The PATCH email-change conflict check ignores case and the user's own row, so lower-casing one's own stored address goes through.filter=userName eq "…"/emails.valuematches in any case.AuditService.resolveUserId) and the unsubscribe-link lookup inEmailService.20261019120000_normalize_user_emails:users.emailonly for rows whose normalised value does not collide with another row. Colliding rows are left exactly as they are. Nothing is merged or deleted, andupdated_atis not touched.invitation_tokens.email, which has no uniqueness constraint.packages/backend/scripts/report-duplicate-emails.sql: a read-only report of the colliding groups. Per account it shows id, stored spelling, verified, has password, created/updated, first successful call, active org, membership counts and the active org's licences.Migration behaviour
users.emailhas a case-sensitive unique index (@unique). The migration only rewrites a row when no other row has the same address in another case, so the unique index cannot be violated and the migration cannot fail on the existing duplicates. On a scratch database withMixed@Example.com,DUP@x.com/dup@x.com/Dup@X.com,lower@x.comandUnder_Score@x.com/underxscore@x.com, it lower-casedMixed@…andUnder_Score@…. The threeduprows were left alone, and the report listed exactly that group.Listing duplicates
psql "$DATABASE_URL" -f packages/backend/scripts/report-duplicate-emails.sqlOr the core query:
Cloud and self-hosted
The behaviour is the same in both modes. Only the existing mode-specific answers differ: the neutral sign-up answer on cloud versus
409on self-hosted, and email change blocked on cloud versus allowed on self-hosted.Follow-ups (not in this PR)
lower(email). That index is left out on purpose here, because the existing duplicates would make the migration fail.ILIKEwithout an index (sequential scan onusers), which is fine at the current table size.Testing
Run in
packages/backendafterprisma generate:npx tsc --noEmit -p tsconfig.json: clean.npm run lint:check: 0 errors, 14 warnings. All warnings are pre-existing; none are on changed lines.npx jest: 681 suites passed, 4 skipped; 20521 tests passed, 1497 skipped, 0 failed.rest.engine.spec.tsandadapters/catalog-summary.spec.tseach hit one 5 s timeout. That was under load, and both pass in this run.common/email.util.spec.ts: normalisation, wildcard escaping, duplicate tie-break, select widening.auth.controller.spec.ts: sign-up refuses case variants on cloud and self-hosted; login, reset and accept-invite work across case; legacy duplicates sign in to the exact spelling and no third account is created; invitations are reused and stored normalised.login.controller.spec.ts: MCP OAuth login with a different case.sso.service.spec.tsandscim-users.service.spec.ts: case-insensitive collision, normalised create, self-normalising PATCH, filter.users.service.spec.ts,email.service.spec.ts,license-trial.service.spec.ts.ILIKEbehaviour, the helper on real rows (exact-spelling preference, no wildcard matches),prisma migrate deployof the new migration, and the report script.Type