Skip to content

Treat email addresses case-insensitively - #1159

Merged
helpcodeai merged 1 commit into
mainfrom
fix/email-case-insensitive
Oct 19, 2026
Merged

helpcodeai merged 1 commit into
mainfrom
fix/email-case-insensitive

Conversation

@helpcodeai

Copy link
Copy Markdown
Contributor

Summary

User emails were stored exactly as typed, and registration and login looked them up the same way. Name@Example.com and name@example.com were 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

  • New helper src/common/email.util.ts: normalizeEmail (trim + lower case), sameEmail, emailLookupWhere, findUserByEmail / findUsersByEmail and pickEmailMatch.
    • Prisma's mode: 'insensitive' equals compiles to an unescaped ILIKE on PostgreSQL. I checked this against a real database: a_b@x.com also matched axb@x.com, and a%@x.com matched everything starting with a. The helper escapes \, % and _, also matches the exact spellings with plain equality, and filters the rows in code with sameEmail. A match is therefore always the same address.
    • When several legacy rows match, the row spelled exactly as typed wins. Otherwise the most recently updated row wins, then the most recently created one. A warning with the user ids (not the address) is logged.
  • Entry points now normalised and/or looked up case-insensitively:
    • 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 registered on self-hosted). New accounts are stored normalised (UsersService.create).
    • POST /api/auth/login and POST /api/auth/login/recovery (via UsersService.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/verify and POST /api/auth/accept-invite: account lookup ignores case, session ownership is compared with sameEmail, and new accounts are stored normalised.
    • PUT /api/users/me email change: cloud comparison uses sameEmail. Self-hosted stores the address normalised and refuses with 409 if another account has it in any case.
    • SSO JIT (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 local Name@… account.
    • SCIM: userName/emails normalised 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.value matches in any case.
    • Audit user resolution by email (AuditService.resolveUserId) and the unsubscribe-link lookup in EmailService.
    • Trial and community licence requests send the normalised address to the licence site.
  • Migration 20261019120000_normalize_user_emails:
    • Lower-cases (and trims) users.email only for rows whose normalised value does not collide with another row. Colliding rows are left exactly as they are. Nothing is merged or deleted, and updated_at is not touched.
    • Normalises 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.email has 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 with Mixed@Example.com, DUP@x.com / dup@x.com / Dup@X.com, lower@x.com and Under_Score@x.com / underxscore@x.com, it lower-cased Mixed@… and Under_Score@…. The three dup rows were left alone, and the report listed exactly that group.

Listing duplicates

psql "$DATABASE_URL" -f packages/backend/scripts/report-duplicate-emails.sql

Or the core query:

SELECT lower(btrim(email)) AS normalized, array_agg(id ORDER BY updated_at DESC) AS user_ids, array_agg(email ORDER BY updated_at DESC) AS spellings
FROM users GROUP BY lower(btrim(email)) HAVING count(*) > 1;

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 409 on self-hosted, and email change blocked on cloud versus allowed on self-hosted.

Follow-ups (not in this PR)

  • Merge the duplicate groups by hand (report above). Then normalise the remaining rows and add a unique index on lower(email). That index is left out on purpose here, because the existing duplicates would make the migration fail.
  • Optionally switch the lookup to that index afterwards. Today's case-insensitive lookup is an ILIKE without an index (sequential scan on users), which is fine at the current table size.
  • Checkout intents still send the session email as stored. They were left alone so we don't change how the licence site matches existing Stripe customers.

Testing

Run in packages/backend after prisma 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.
    • On a baseline run started before these changes, rest.engine.spec.ts and adapters/catalog-summary.spec.ts each hit one 5 s timeout. That was under load, and both pass in this run.
  • New and updated specs:
    • 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.ts and scim-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.
  • Manual check against a scratch PostgreSQL database: the Prisma ILIKE behaviour, the helper on real rows (exact-spelling preference, no wildcard matches), prisma migrate deploy of the new migration, and the report script.

Type

  • Bug fix

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).
@helpcodeai
helpcodeai merged commit 3ba863a into main Oct 19, 2026
15 checks passed
@helpcodeai
helpcodeai deleted the fix/email-case-insensitive branch October 19, 2026 16:40
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant