diff --git a/packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql b/packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql new file mode 100644 index 000000000..daf54c973 --- /dev/null +++ b/packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql @@ -0,0 +1,29 @@ +-- Normalise stored email addresses (trimmed, lower case). +-- +-- Addresses used to be stored exactly as typed and looked up the same way, so +-- `Name@Example.com` and `name@example.com` could become two accounts. The +-- application now writes addresses normalised and looks them up ignoring +-- case; this brings the existing rows in line. +-- +-- A row is only rewritten when no OTHER row has the same address in another +-- case. Rows that do collide are left exactly as they are: they are separate +-- accounts that an operator has to merge by hand (list them with +-- scripts/report-duplicate-emails.sql). Nothing is merged or deleted here, +-- and `updated_at` is not touched. +-- +-- The unique index on "users"."email" stays case-sensitive for now; a unique +-- index on lower("email") can only be added once the duplicates are merged. +UPDATE "users" AS u +SET "email" = lower(btrim(u."email")) +WHERE u."email" <> lower(btrim(u."email")) + AND NOT EXISTS ( + SELECT 1 + FROM "users" AS o + WHERE o."id" <> u."id" + AND lower(btrim(o."email")) = lower(btrim(u."email")) + ); + +-- Invitations are not unique per address, so every one can be normalised. +UPDATE "invitation_tokens" +SET "email" = lower(btrim("email")) +WHERE "email" <> lower(btrim("email")); diff --git a/packages/backend/scripts/report-duplicate-emails.sql b/packages/backend/scripts/report-duplicate-emails.sql new file mode 100644 index 000000000..0e644672e --- /dev/null +++ b/packages/backend/scripts/report-duplicate-emails.sql @@ -0,0 +1,39 @@ +-- Accounts whose email addresses differ only in case (or surrounding spaces). +-- +-- Read-only: lists the groups the 20261019120000_normalize_user_emails +-- migration left untouched, so an operator can decide by hand which account +-- to keep and move workspaces, memberships and licences onto it. Nothing here +-- changes data. +-- +-- Run with: +-- psql "$DATABASE_URL" -f packages/backend/scripts/report-duplicate-emails.sql +-- +-- One row per account, grouped by the normalised address; within a group the +-- most recently updated account comes first (the one a sign-in that does not +-- match any spelling exactly will pick). +WITH dup AS ( + SELECT lower(btrim("email")) AS normalized + FROM "users" + GROUP BY lower(btrim("email")) + HAVING count(*) > 1 +) +SELECT + d.normalized, + u."id" AS user_id, + u."email" AS stored_email, + u."email_verified", + u."password_hash" IS NOT NULL AS has_password, + u."created_at", + u."updated_at", + u."first_successful_invocation_at", + u."organization_id" AS active_organization_id, + (SELECT count(*) FROM "organization_members" m WHERE m."user_id" = u."id") AS memberships, + (SELECT count(*) FROM "organization_members" m WHERE m."user_id" = u."id" AND m."role" = 'ADMIN') AS admin_memberships, + ( + SELECT string_agg(l."plan" || ':' || l."status", ', ' ORDER BY l."created_at") + FROM "licenses" l + WHERE l."organization_id" = u."organization_id" + ) AS active_org_licences +FROM dup d +JOIN "users" u ON lower(btrim(u."email")) = d.normalized +ORDER BY d.normalized, u."updated_at" DESC, u."created_at" DESC; diff --git a/packages/backend/src/audit/audit.service.ts b/packages/backend/src/audit/audit.service.ts index 5ea2a8188..80a9d3a8a 100644 --- a/packages/backend/src/audit/audit.service.ts +++ b/packages/backend/src/audit/audit.service.ts @@ -26,6 +26,7 @@ const INVOCATION_FULL_PAYLOADS_PER_HOUR = envInt('INVOCATION_FULL_PAYLOADS_PER_H const INVOCATION_LOG_VOLUME_EXCERPT_BYTES = 512; const REPEAT_KEYS_MAX = 10_000; import { PrismaService } from '../common/prisma.service'; +import { findUserByEmail, normalizeEmail } from '../common/email.util'; import { InvocationStatus, Prisma } from '../generated/prisma/client'; import { AdsActivationService } from './ads-activation.service'; import { FirstSuccessService } from './first-success.service'; @@ -293,14 +294,11 @@ export class AuditService implements OnModuleDestroy { } if (!userEmail) return undefined; - const key = userEmail.toLowerCase(); + const key = normalizeEmail(userEmail); const cached = this.emailToIdCache.get(key); if (cached) return cached; - const byEmail = await this.prisma.user.findUnique({ - where: { email: key }, - select: { id: true }, - }); + const byEmail = await findUserByEmail<{ id: string; email: string }>(this.prisma, userEmail, { id: true }); if (!byEmail) return undefined; this.emailToIdCache.set(key, byEmail.id); diff --git a/packages/backend/src/auth/auth.controller.spec.ts b/packages/backend/src/auth/auth.controller.spec.ts index 60ee63f9f..73c838a6f 100644 --- a/packages/backend/src/auth/auth.controller.spec.ts +++ b/packages/backend/src/auth/auth.controller.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { AuthController, NEUTRAL_PASSWORD_RESET, NEUTRAL_REGISTRATION } from './auth.controller'; import { ProductEventService } from '../audit/product-event.service'; +import { findUserByEmail, normalizeEmail } from '../common/email.util'; /** * Sign-up, password reset and login must not say — by their answer or by how @@ -51,13 +52,18 @@ function makeController({ throw new Error('invalid token'); }), }; + // Looks up and stores addresses the way UsersService does: in any case, and + // normalised on write. const usersService = { - findByEmail: jest.fn(async (email: string) => users.find((u) => u.email === email) ?? null), + findByEmail: jest.fn(async (email: string) => + findUserByEmail({ user: { findMany: async () => users } }, email), + ), + findById: jest.fn(async (id: string) => users.find((u) => u.id === id) ?? null), count: jest.fn(async () => users.length), create: jest.fn(async (data: any) => { const u: Account = { id: `u${users.length + 1}`, - email: data.email, + email: normalizeEmail(data.email), name: data.name, role: data.role, organizationId: data.organizationId, @@ -93,6 +99,11 @@ function makeController({ invitationToken: { findUnique: jest.fn(async ({ where }: any) => invites.find((i) => i.token === where.token) ?? null), findFirst: jest.fn(async () => null), + // Returns the organisation's pending invitations; the controller picks + // the one for the address. + findMany: jest.fn(async ({ where }: any) => + invites.filter((i) => i.organizationId === where.organizationId && !i.usedAt), + ), create: jest.fn(async ({ data }: any) => { invites.push({ ...data }); return data; @@ -123,6 +134,10 @@ function makeController({ sent.push(`reset ${to}`); return true; }), + sendInvitationEmail: jest.fn(async (to: string) => { + sent.push(`invite ${to}`); + return { sent: true }; + }), }; const configService = { get: jest.fn((key: string) => { @@ -576,3 +591,127 @@ describe('AuthController — verified sign-up reported for ChatGPT Ads', () => { expect(res.redirect).toHaveBeenCalled(); }); }); + +/** + * An address is one account whatever its case: a user who registered with + * capitals and later typed the address in lower case could not sign in, signed + * up again and got a second, empty workspace. + */ +describe('AuthController — email addresses ignore case', () => { + const OLD_FLOOR = process.env.AUTH_NEUTRAL_FLOOR_MS; + beforeEach(() => { + process.env.AUTH_NEUTRAL_FLOOR_MS = '0'; + }); + afterAll(() => { + process.env.AUTH_NEUTRAL_FLOOR_MS = OLD_FLOOR; + }); + + /** An account written before addresses were normalised. */ + const LEGACY: Account = { ...EXISTING, id: 'u-legacy', email: 'Legacy.User@Example.COM' }; + + it('cloud sign-up refuses a case variant of an existing address, with the usual neutral answer', async () => { + const { controller, users, sent } = makeController({ mode: 'cloud', accounts: [EXISTING, LEGACY] }); + await expect(controller.register({}, signup('TAKEN@Example.com'))).resolves.toEqual(NEUTRAL_REGISTRATION); + await expect(controller.register({}, signup('legacy.user@example.com'))).resolves.toEqual(NEUTRAL_REGISTRATION); + await flush(); + + expect(users.map((u) => u.id)).toEqual(['u-existing', 'u-legacy']); + // The notice goes to the account's own address. + expect(sent).toEqual(['existing taken@example.com', 'existing Legacy.User@Example.COM']); + }); + + it('cloud sign-up stores a new address normalised', async () => { + const { controller, users, sent } = makeController({ mode: 'cloud' }); + await controller.register({}, signup('New.Person@Example.com')); + await flush(); + expect(users.map((u) => u.email)).toEqual(['new.person@example.com']); + expect(sent).toEqual(['verify new.person@example.com']); + }); + + it('self-hosted sign-up answers a conflict for a case variant, as for the exact address', async () => { + const { controller, users } = makeController({ mode: 'self-hosted', accounts: [EXISTING, LEGACY] }); + await expect(controller.register({}, signup('Taken@Example.com'))).rejects.toBeInstanceOf(ConflictException); + await expect(controller.register({}, signup('legacy.user@example.com'))).rejects.toBeInstanceOf(ConflictException); + expect(users).toHaveLength(2); + }); + + it('signs in with the address typed in another case', async () => { + const { controller } = makeController({ mode: 'self-hosted', accounts: [EXISTING, LEGACY] }); + await expect( + controller.login({}, { email: 'TAKEN@example.com', password: 'Correct#Horse1' }), + ).resolves.toMatchObject({ user: { id: 'u-existing' } }); + await expect( + controller.login({}, { email: 'legacy.user@example.com', password: 'Correct#Horse1' }), + ).resolves.toMatchObject({ user: { id: 'u-legacy', email: 'Legacy.User@Example.COM' } }); + }); + + it('signs in to the exact spelling when legacy duplicates exist, and creates no third account', async () => { + const upper: Account = { ...EXISTING, id: 'u-upper', email: 'DUP@example.com', passwordHash: 'hash:Upper#Pass1' }; + const lower: Account = { ...EXISTING, id: 'u-lower', email: 'dup@example.com', passwordHash: 'hash:Lower#Pass1' }; + const { controller, users } = makeController({ mode: 'cloud', accounts: [upper, lower] }); + + await expect( + controller.login({}, { email: 'DUP@example.com', password: 'Upper#Pass1' }), + ).resolves.toMatchObject({ user: { id: 'u-upper' } }); + await expect( + controller.login({}, { email: 'dup@example.com', password: 'Lower#Pass1' }), + ).resolves.toMatchObject({ user: { id: 'u-lower' } }); + + await expect(controller.register({}, signup('Dup@Example.com'))).resolves.toEqual(NEUTRAL_REGISTRATION); + expect(users).toHaveLength(2); + }); + + it('sends a password reset for the address typed in another case', async () => { + const { controller, sent } = makeController({ mode: 'cloud', accounts: [LEGACY] }); + await expect(controller.forgotPassword({}, { email: 'legacy.user@example.com' })).resolves.toEqual( + NEUTRAL_PASSWORD_RESET, + ); + await flush(); + expect(sent).toEqual(['reset Legacy.User@Example.COM']); + }); + + it('accepts an invitation for an account stored with capitals, proven by a session in any case', async () => { + const a = makeController({ mode: 'cloud', accounts: [LEGACY] }); + a.invites.push({ + id: 'inv1', + token: 'invite-token', + email: 'legacy.user@example.com', + role: 'EDITOR', + mcpRoleId: null, + mcpRoleIds: [], + organizationId: 'org-new', + expiresAt: new Date(Date.now() + 3600_000), + usedAt: null, + }); + expect(await a.controller.verifyInvite('invite-token')).toMatchObject({ exists: true }); + const res = await a.controller.acceptInvite( + { headers: { authorization: 'Bearer sess:LEGACY.user@example.com' } }, + { token: 'invite-token' }, + ); + expect(res.accessToken).toBe('jwt'); + expect(a.organizationsService.addMember).toHaveBeenCalledWith('u-legacy', 'org-new', 'EDITOR'); + expect(a.users).toHaveLength(1); + }); + + it('stores an invitation normalised and reuses a pending one whatever its case', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + const req = { user: { sub: 'u-existing', organizationId: 'org-x' } }; + a.invites.push({ + id: 'inv-old', + token: 'old-token', + email: 'Invitee@Example.com', + organizationId: 'org-x', + expiresAt: new Date(Date.now() + 3600_000), + usedAt: null, + }); + + const reused = await a.controller.inviteUser(req, { email: 'INVITEE@example.com', role: 'EDITOR' as any }); + expect(reused.inviteUrl).toContain('token=old-token'); + expect(a.invites).toHaveLength(1); + + const created = await a.controller.inviteUser(req, { email: 'Other.Person@Example.com', role: 'EDITOR' as any }); + expect(created.inviteUrl).not.toContain('old-token'); + expect(a.invites.map((i) => i.email)).toEqual(['Invitee@Example.com', 'other.person@example.com']); + expect(a.sent).toContain('invite other.person@example.com'); + }); +}); diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 4b59147bf..903341cfe 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -44,6 +44,7 @@ import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; import { EditionService } from '../ee/licensing/edition.service'; import { SignupAttributionDto } from './signup-attribution.dto'; import { DISPOSABLE_EMAIL_MESSAGE, isDisposableEmail } from './disposable-email.util'; +import { emailLookupWhere, normalizeEmail, sameEmail } from '../common/email.util'; /** * Registration and password reset answer the same whether or not the address @@ -259,7 +260,7 @@ export class AuthController { try { const payload = this.authService.verifyToken(header.slice(7).trim()); const email = (payload as any)?.email; - return typeof email === 'string' ? email.toLowerCase() : null; + return typeof email === 'string' ? normalizeEmail(email) : null; } catch { return null; } @@ -784,14 +785,19 @@ export class AuthController { // Reuse an existing pending invitation instead of rejecting: throwing a // 409 here left admins stuck with no way to get the link after a failed // email send (the response with the URL only comes with a NEW invite). - const existingInvite = await this.prisma.invitationToken.findFirst({ + // Matched in any case: invitations written before addresses were + // normalised keep their original spelling. + const inviteEmail = normalizeEmail(dto.email); + const pendingInvites = await this.prisma.invitationToken.findMany({ where: { - email: dto.email, + ...emailLookupWhere(inviteEmail), organizationId: req.user.organizationId, usedAt: null, expiresAt: { gt: new Date() }, }, + orderBy: { createdAt: 'desc' }, }); + const existingInvite = pendingInvites.find((i) => sameEmail(i.email, inviteEmail)) ?? null; const inviteToken = existingInvite ? existingInvite.token @@ -807,7 +813,7 @@ export class AuthController { const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours await this.prisma.invitationToken.create({ data: { - email: dto.email, + email: inviteEmail, token: inviteToken, role: dto.role, mcpRoleId: invitedMcpRoleIds[0] ?? null, @@ -844,7 +850,7 @@ export class AuthController { // Send email const emailResult = await this.emailService.sendInvitationEmail( - dto.email, + inviteEmail, inviteUrl, inviterName, roleName, @@ -855,9 +861,9 @@ export class AuthController { return { message: emailResult.sent ? existingInvite - ? `${created} for ${dto.email} — the email was re-sent.` - : `Invitation sent to ${dto.email}` - : `${created} for ${dto.email}, but the email could not be sent. Share the link manually.`, + ? `${created} for ${inviteEmail} — the email was re-sent.` + : `Invitation sent to ${inviteEmail}` + : `${created} for ${inviteEmail}, but the email could not be sent. Share the link manually.`, inviteUrl, emailSent: emailResult.sent, ...(emailResult.error ? { emailError: emailResult.error } : {}), @@ -918,8 +924,7 @@ export class AuthController { // workspace admin, i.e. every signed-up user) could take over an // arbitrary account by inviting it and accepting the invite themselves. const sessionEmail = this.sessionEmailFromRequest(req); - const provenBySession = - !!sessionEmail && sessionEmail === invite.email.toLowerCase(); + const provenBySession = sameEmail(sessionEmail, invite.email); if (!provenBySession) { if (!existing.passwordHash) { throw new UnauthorizedException( diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts index 85fa85792..b3fa1adce 100644 --- a/packages/backend/src/auth/login.controller.spec.ts +++ b/packages/backend/src/auth/login.controller.spec.ts @@ -66,7 +66,7 @@ function makeReq(overrides: Partial = {}): Request { describe('LoginController', () => { let controller: LoginController; let authService: jest.Mocked>; - let prisma: { user: { findUnique: jest.Mock } }; + let prisma: { user: { findUnique: jest.Mock; findMany: jest.Mock } }; let config: jest.Mocked>; let store: jest.Mocked< Pick @@ -86,7 +86,7 @@ describe('LoginController', () => { throw new Error('invalid'); }), }; - prisma = { user: { findUnique: jest.fn() } }; + prisma = { user: { findUnique: jest.fn(), findMany: jest.fn().mockResolvedValue([]) } }; config = { get: jest.fn().mockReturnValue(undefined) }; store = { getOAuthSession: jest.fn(), getClient: jest.fn() }; sso = { startMcp: jest.fn() }; @@ -283,12 +283,12 @@ describe('LoginController', () => { makeReq({ signedCookies: { login_csrf: 'tok' }, headers: { host: 'mcp.test' } }); it('sets login_user and redirects to /callback on valid credentials', async () => { - prisma.user.findUnique.mockResolvedValue({ + prisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash', - }); + }]); authService.comparePassword.mockResolvedValue(true); const res = makeRes(); @@ -304,12 +304,42 @@ describe('LoginController', () => { expect(res._redirect).toBe('http://mcp.test/callback'); }); + it('signs in an account stored with capitals from the lower-case address', async () => { + prisma.user.findMany.mockResolvedValue([{ + id: 'u1', + email: 'Name@Example.com', + name: 'A', + passwordHash: 'hash', + }]); + authService.comparePassword.mockResolvedValue(true); + + const res = makeRes(); + await controller.handleLogin( + okReq(), + { email: 'name@example.com', password: 'pw', csrf: 'tok', action: 'approve' }, + res, + ); + + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { email: { in: ['name@example.com'] } }, + { email: { equals: 'name@example.com', mode: 'insensitive' } }, + ], + }, + }); + expect(authService.comparePassword).toHaveBeenCalledWith('pw', 'hash'); + const profile = JSON.parse(Buffer.from(res._cookies['login_user'].value, 'base64url').toString()); + expect(profile.id).toBe('u1'); + expect(res._redirect).toBe('http://mcp.test/callback'); + }); + it('redirects with an error on wrong password (no login_user set)', async () => { - prisma.user.findUnique.mockResolvedValue({ + prisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'a@b.com', passwordHash: 'hash', - }); + }]); authService.comparePassword.mockResolvedValue(false); const res = makeRes(); @@ -522,7 +552,7 @@ describe('LoginController', () => { let licenseGuard: { getTrialState: jest.Mock }; let eligibility: { hadSubscription: jest.Mock }; let cloudPrisma: { - user: { findUnique: jest.Mock }; + user: { findUnique: jest.Mock; findMany: jest.Mock }; organizationMember: { findFirst: jest.Mock }; connector: { count: jest.Mock }; }; @@ -545,6 +575,7 @@ describe('LoginController', () => { ? { organizationId: 'org-1', email: 'a@b.com' } : { id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash' }, ); + cloudPrisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash' }]); cloudPrisma.organizationMember.findFirst.mockResolvedValue({ role: opts.role ?? 'ADMIN' }); cloudPrisma.connector.count.mockResolvedValue(opts.connectors ?? 0); authService.comparePassword.mockResolvedValue(true); @@ -566,7 +597,7 @@ describe('LoginController', () => { beforeEach(() => { cloudPrisma = { - user: { findUnique: jest.fn() }, + user: { findUnique: jest.fn(), findMany: jest.fn() }, organizationMember: { findFirst: jest.fn() }, connector: { count: jest.fn() }, }; diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index 98196949b..8bc67120d 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -21,6 +21,8 @@ import { } from './auth.service'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; +import { findUserByEmail } from '../common/email.util'; +import type { User } from '../generated/prisma/client'; import { PrismaOAuthStore } from './prisma-oauth.store'; import { SsoService } from '../identity-providers/sso.service'; import { MCP_RESOURCE_COOKIE } from './resource-indicator.middleware'; @@ -270,10 +272,9 @@ export class LoginController { ); } - // Find user by email - const user = await this.prisma.user.findUnique({ - where: { email }, - }); + // Find user by email, in any case: an address registered with capitals + // must still sign in when typed in lower case, and the other way round. + const user = await findUserByEmail(this.prisma, email); if (!user) { this.logger.warn(`Login attempt for non-existent user: ${email}`); diff --git a/packages/backend/src/common/email.util.spec.ts b/packages/backend/src/common/email.util.spec.ts new file mode 100644 index 000000000..6049e3ced --- /dev/null +++ b/packages/backend/src/common/email.util.spec.ts @@ -0,0 +1,121 @@ +import { + emailLookupWhere, + findUserByEmail, + findUsersByEmail, + normalizeEmail, + pickEmailMatch, + sameEmail, +} from './email.util'; + +describe('normalizeEmail', () => { + it('trims and lower-cases', () => { + expect(normalizeEmail(' Name@Example.COM ')).toBe('name@example.com'); + }); + + it('leaves an already normalised address alone', () => { + expect(normalizeEmail('name@example.com')).toBe('name@example.com'); + }); +}); + +describe('sameEmail', () => { + it('ignores case and surrounding spaces', () => { + expect(sameEmail('NAME@example.com', ' name@EXAMPLE.com')).toBe(true); + }); + + it('is false for different addresses, empty values and non-strings', () => { + expect(sameEmail('a@example.com', 'b@example.com')).toBe(false); + expect(sameEmail('', '')).toBe(false); + expect(sameEmail(null, 'a@example.com')).toBe(false); + expect(sameEmail('a@example.com', undefined)).toBe(false); + }); +}); + +describe('emailLookupWhere', () => { + it('matches the exact spellings and the address in any case', () => { + expect(emailLookupWhere(' Name@Example.com')).toEqual({ + OR: [ + { email: { in: ['Name@Example.com', 'name@example.com'] } }, + { email: { equals: 'name@example.com', mode: 'insensitive' } }, + ], + }); + }); + + it('escapes LIKE wildcards, which Prisma passes to ILIKE as they are', () => { + const where = emailLookupWhere('a_b%c\\d@example.com'); + expect(where.OR[1]).toEqual({ + email: { equals: 'a\\_b\\%c\\\\d@example.com', mode: 'insensitive' }, + }); + }); +}); + +describe('pickEmailMatch', () => { + const older = { id: 'old', email: 'NAME@example.com', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-02-01') }; + const newer = { id: 'new', email: 'name@example.com', createdAt: new Date('2026-03-01'), updatedAt: new Date('2026-03-01') }; + const touched = { id: 'touched', email: 'Name@Example.com', createdAt: new Date('2025-12-01'), updatedAt: new Date('2026-04-01') }; + + it('returns null for no rows and the row for one', () => { + expect(pickEmailMatch([], 'x@example.com')).toBeNull(); + expect(pickEmailMatch([older], 'name@example.com')).toBe(older); + }); + + it('prefers the row spelled exactly as typed', () => { + expect(pickEmailMatch([older, newer, touched], 'NAME@example.com')).toBe(older); + expect(pickEmailMatch([older, newer, touched], ' name@example.com ')).toBe(newer); + }); + + it('otherwise takes the most recently updated row', () => { + expect(pickEmailMatch([older, newer, touched], 'nAmE@example.com')).toBe(touched); + }); + + it('breaks an updatedAt tie on createdAt', () => { + const a = { id: 'a', email: 'X@example.com', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-05-01') }; + const b = { id: 'b', email: 'x@EXAMPLE.com', createdAt: new Date('2026-02-01'), updatedAt: new Date('2026-05-01') }; + expect(pickEmailMatch([a, b], 'x@example.COM')?.id).toBe('b'); + }); +}); + +describe('findUsersByEmail / findUserByEmail', () => { + const db = (rows: Array>) => ({ user: { findMany: jest.fn().mockResolvedValue(rows) } }); + + it('finds a row stored with capitals from a lower-case address', async () => { + const prisma = db([{ id: 'u1', email: 'Name@Example.com' }]); + const user = await findUserByEmail(prisma, 'name@example.com'); + expect(user?.id).toBe('u1'); + expect(prisma.user.findMany).toHaveBeenCalledWith({ where: emailLookupWhere('name@example.com') }); + }); + + it('drops rows that are not the same address (a wildcard match)', async () => { + const prisma = db([ + { id: 'u1', email: 'a_b@example.com' }, + { id: 'u2', email: 'axb@example.com' }, + ]); + const rows = await findUsersByEmail(prisma, 'A_B@example.com'); + expect(rows.map((r) => r.id)).toEqual(['u1']); + }); + + it('adds the columns the tie-break needs to a narrow select', async () => { + const prisma = db([]); + await findUsersByEmail(prisma, 'a@example.com', { name: true }); + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: emailLookupWhere('a@example.com'), + select: { name: true, id: true, email: true, createdAt: true, updatedAt: true }, + }); + }); + + it('does not query for an empty or missing address', async () => { + const prisma = db([{ id: 'u1', email: 'a@example.com' }]); + expect(await findUserByEmail(prisma, ' ')).toBeNull(); + expect(await findUserByEmail(prisma, undefined)).toBeNull(); + expect(await findUserByEmail(prisma, null)).toBeNull(); + expect(prisma.user.findMany).not.toHaveBeenCalled(); + }); + + it('picks the exact spelling among legacy duplicates', async () => { + const prisma = db([ + { id: 'upper', email: 'NAME@example.com', updatedAt: new Date('2026-01-01') }, + { id: 'lower', email: 'name@example.com', updatedAt: new Date('2026-02-01') }, + ]); + expect((await findUserByEmail(prisma, 'NAME@example.com'))?.id).toBe('upper'); + expect((await findUserByEmail(prisma, 'Name@Example.com'))?.id).toBe('lower'); + }); +}); diff --git a/packages/backend/src/common/email.util.ts b/packages/backend/src/common/email.util.ts new file mode 100644 index 000000000..dead53e91 --- /dev/null +++ b/packages/backend/src/common/email.util.ts @@ -0,0 +1,117 @@ +import { Logger } from '@nestjs/common'; +import type { Prisma } from '../generated/prisma/client'; + +/** + * One address, one account. + * + * `users.email` used to be stored exactly as typed and looked up the same way, + * so `Name@Example.com` and `name@example.com` were two different people: a + * user who registered with capitals and later typed the address in lower case + * could not sign in, signed up again, and got a second, empty workspace with a + * second trial, while anything bought on the first one stayed there. + * + * Every address is now normalised before it is written, and every lookup by + * address is case-insensitive, so rows written before the change are still + * found. Rows that only differ in case already exist in the database; they + * are left for an operator to merge (see + * scripts/report-duplicate-emails.sql), and a lookup that meets them picks one + * deterministically (see `pickEmailMatch`) rather than failing. + */ + +/** Trimmed and lower-cased. The only form an address is written in. */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** Whether two addresses are the same address, ignoring case and surrounding spaces. */ +export function sameEmail(a: string | null | undefined, b: string | null | undefined): boolean { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const left = normalizeEmail(a); + return left !== '' && left === normalizeEmail(b); +} + +/** + * Prisma's case-insensitive `equals` is `ILIKE` on PostgreSQL, unescaped: an + * `_` or `%` in the address would act as a wildcard, and `a_b@example.com` + * would also match `axb@example.com`. Escaped here, and the rows are checked + * again in code (`findUsersByEmail`), so a match is always the same address. + */ +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (c) => `\\${c}`); +} + +/** + * `where` for the rows whose `email` column equals `email` in any case (users, + * invitations). The exact forms are matched by plain equality too, so they are + * found even if the insensitive branch ever stops treating the escapes as + * escapes. Callers must still filter the rows with `sameEmail`; + * `findUsersByEmail` does. + */ +export function emailLookupWhere(email: string): { OR: Array<{ email: Prisma.StringFilter }> } { + const trimmed = email.trim(); + const normalized = normalizeEmail(email); + return { + OR: [ + { email: { in: [...new Set([trimmed, normalized])] } }, + { email: { equals: escapeLikePattern(normalized), mode: 'insensitive' } }, + ], + }; +} + +type EmailRow = { email: string; createdAt?: Date | null; updatedAt?: Date | null }; + +/** + * The account an address means when several rows share it in different case + * (written before addresses were normalised): the row spelled exactly as + * typed, otherwise the most recently updated, then the most recently created. + * Never null for a non-empty list. + */ +export function pickEmailMatch(rows: T[], typed: string): T | null { + if (rows.length === 0) return null; + if (rows.length === 1) return rows[0]; + const exact = rows.find((r) => r.email === typed.trim()); + if (exact) return exact; + const time = (d: Date | null | undefined) => (d ? new Date(d).getTime() : 0); + return [...rows].sort( + (a, b) => time(b.updatedAt) - time(a.updatedAt) || time(b.createdAt) - time(a.createdAt), + )[0]; +} + +/** Anything with a `user.findMany`: PrismaService, or a transaction client. */ +type UserFinder = { user: { findMany: (args: any) => Promise } }; + +const logger = new Logger('EmailLookup'); + +/** Every user row whose address is `email`, in any case. */ +export async function findUsersByEmail( + db: UserFinder, + email: string, + select?: Prisma.UserSelect, +): Promise { + if (!normalizeEmail(email)) return []; + const rows = (await db.user.findMany({ + where: emailLookupWhere(email), + // The tie-break in pickEmailMatch and the duplicate warning read these. + ...(select ? { select: { ...select, id: true, email: true, createdAt: true, updatedAt: true } } : {}), + })) as T[]; + return rows.filter((r) => sameEmail(r.email, email)); +} + +/** + * The user an address belongs to, matched case-insensitively, or null. When + * more than one row matches (legacy duplicates), see `pickEmailMatch`. + */ +export async function findUserByEmail( + db: UserFinder, + email: string | null | undefined, + select?: Prisma.UserSelect, +): Promise { + if (typeof email !== 'string') return null; + const rows = await findUsersByEmail(db, email, select); + if (rows.length > 1) { + // Ids only: these are accounts an operator should merge by hand. + const ids = rows.map((r) => (r as { id?: string }).id).filter(Boolean); + logger.warn(`${rows.length} accounts share one address in different case${ids.length ? ` (${ids.join(', ')})` : ''}`); + } + return pickEmailMatch(rows, email); +} diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts index 06d3d5be5..2ed5197ae 100644 --- a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts +++ b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts @@ -57,6 +57,7 @@ describe('ScimUsersService', () => { }, user: { findUnique: jest.fn(async () => null), + findMany: jest.fn(async (): Promise => []), create: jest.fn(async () => ({ id: 'u-new' })), update: jest.fn(async () => ({})), delete: jest.fn(), @@ -123,7 +124,7 @@ describe('ScimUsersService', () => { // The same anti-takeover rule as JIT: a directory never claims a local account. it('refuses to adopt an existing unlinked local account', async () => { - prisma.user.findUnique.mockResolvedValue({ id: 'local' }); + prisma.user.findMany.mockResolvedValue([{ id: 'local', email: 'anna.rossi@x.com' }]); const err = await service.create(provider, body, ctx).catch((e) => e); expect(err).toBeInstanceOf(ScimError); expect(err.status).toBe(409); @@ -131,6 +132,16 @@ describe('ScimUsersService', () => { expect(prisma.user.create).not.toHaveBeenCalled(); }); + it('treats a local account stored with capitals as owning the address', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'local', email: 'ANNA.ROSSI@X.COM' }]); + const err = await service.create(provider, body, ctx).catch((e) => e); + expect(err.status).toBe(409); + expect(prisma.user.findMany.mock.calls[0][0].where.OR).toContainEqual({ + email: { equals: 'anna.rossi@x.com', mode: 'insensitive' }, + }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + it('deactivates immediately when created with active:false', async () => { prisma.userIdentity.findUnique .mockResolvedValueOnce(null) @@ -251,15 +262,22 @@ describe('ScimUsersService', () => { it.each([ ['has_password', { passwordHash: 'x' }, null], ['multi_org', { memberships: [{ organizationId: ORG, deactivatedAt: null }, { organizationId: 'org-2', deactivatedAt: null }] }, null], - ['conflict', {}, { id: 'someone' }], + ['conflict', {}, { id: 'someone', email: 'NEW@x.com' }], ])('is skipped (%s) but the request still succeeds', async (reason, userOver, owner) => { prisma.userIdentity.findUnique.mockResolvedValue(identity({}, userOver as any)); - prisma.user.findUnique.mockResolvedValue(owner); + prisma.user.findMany.mockResolvedValue(owner ? [owner] : []); await service.patch(provider, 'u1', emailOp, ctx); expect(prisma.user.update).not.toHaveBeenCalled(); expect(events[0].metadata.emailChangeSkipped).toBe(reason); }); + it('lower-cases an address stored with capitals: the only match is the user itself', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { email: 'Anna@X.com' })); + prisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'Anna@X.com' }]); + await service.patch(provider, 'u1', patch([{ op: 'replace', path: 'userName', value: 'anna@x.com' }]), ctx); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { email: 'anna@x.com' } }); + }); + it('a deactivation in the same request is never blocked by an email conflict', async () => { prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { passwordHash: 'x' })); await service.patch(provider, 'u1', patch([ @@ -295,10 +313,18 @@ describe('ScimUsersService', () => { })); }); - it('filters by lower-cased email within the provider', async () => { + it('filters by email in any case within the provider', async () => { await service.list(provider, { attr: 'userName', value: 'Anna@X.com' }, { startIndex: 1, count: 100 }, ctx); expect(prisma.userIdentity.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { providerId: 'idp-1', user: { email: 'anna@x.com' } }, + where: { + providerId: 'idp-1', + user: { + OR: [ + { email: { in: ['Anna@X.com', 'anna@x.com'] } }, + { email: { equals: 'anna@x.com', mode: 'insensitive' } }, + ], + }, + }, })); }); diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.ts b/packages/backend/src/identity-providers/scim/scim-users.service.ts index 05096daf5..72b5987bb 100644 --- a/packages/backend/src/identity-providers/scim/scim-users.service.ts +++ b/packages/backend/src/identity-providers/scim/scim-users.service.ts @@ -20,6 +20,7 @@ import { ScimFilter, } from './scim.parser'; import { SCIM_LIST_SCHEMA, SCIM_USER_SCHEMA } from './scim.schemas'; +import { emailLookupWhere, findUserByEmail, findUsersByEmail, normalizeEmail } from '../../common/email.util'; import type { ScimProvider } from './scim-auth.guard'; export interface ScimCtx { @@ -144,16 +145,13 @@ export class ScimUsersService { throw new ScimError(409, 'User already provisioned', 'uniqueness'); } - const email = parsed.primaryEmail ?? parsed.userName.toLowerCase(); + const email = parsed.primaryEmail ?? normalizeEmail(parsed.userName); // The same anti-takeover rule as JIT provisioning: a local account that - // owns this address is never claimed by a directory. Binding this - // provider's object id to it would let whoever controls the tenant sign - // in as that person everywhere they are a member. - const collision = await this.prisma.user.findUnique({ - where: { email }, - select: { id: true }, - }); + // owns this address, in any case, is never claimed by a directory. + // Binding this provider's object id to it would let whoever controls the + // tenant sign in as that person everywhere they are a member. + const collision = await findUserByEmail(this.prisma, email, { id: true }); if (collision) { throw new ScimError( 409, @@ -222,7 +220,7 @@ export class ScimUsersService { const changes: UserChanges = { // PUT with `active` absent must not silently reactivate. ...(body && typeof (body as any).active !== 'undefined' ? { active: parsed.active } : {}), - email: parsed.primaryEmail ?? parsed.userName.toLowerCase(), + email: parsed.primaryEmail ?? normalizeEmail(parsed.userName), name: displayNameOf(parsed), externalId: parsed.externalId, }; @@ -268,7 +266,9 @@ export class ScimUsersService { switch (filter.attr) { case 'userName': case 'emails.value': - return { user: { email: filter.value.toLowerCase() } }; + // In any case: a row written before addresses were normalised keeps + // its capitals, and Entra looks users up by what it holds. + return { user: emailLookupWhere(filter.value) }; case 'externalId': return { externalSubject: filter.value }; case 'id': @@ -294,12 +294,12 @@ export class ScimUsersService { if (lower === 'active') { c.active = op.op === 'remove' ? false : coerceActive(op.value); } else if (lower === 'username') { - if (typeof op.value === 'string' && op.value.trim()) c.email = op.value.trim().toLowerCase(); + if (typeof op.value === 'string' && op.value.trim()) c.email = normalizeEmail(op.value); } else if (lower === 'emails') { const e = pickEmail(op.value); if (e) c.email = e; } else if (/^emails\[.*\]\.value$/i.test(path)) { - if (typeof op.value === 'string' && op.value.trim()) c.email = op.value.trim().toLowerCase(); + if (typeof op.value === 'string' && op.value.trim()) c.email = normalizeEmail(op.value); } else if (lower === 'externalid') { if (typeof op.value === 'string') c.externalId = op.value; } else if (lower === 'displayname') { @@ -370,11 +370,15 @@ export class ScimUsersService { // other workspace, and the address unclaimed. Otherwise keep the old // address and carry on — a deactivation in the same request must never // be blocked by an email conflict. + // The conflict check ignores case and this user's own row, so a change + // that only lower-cases the stored address goes through. const skipped = row.user.passwordHash ? 'has_password' : row.user.memberships.some((m) => m.organizationId !== orgId) ? 'multi_org' - : (await this.prisma.user.findUnique({ where: { email: changes.email }, select: { id: true } })) + : (await findUsersByEmail<{ id: string; email: string }>(this.prisma, changes.email, { id: true })).some( + (u) => u.id !== row.userId, + ) ? 'conflict' : null; if (skipped) metadata.emailChangeSkipped = skipped; diff --git a/packages/backend/src/identity-providers/scim/scim.parser.ts b/packages/backend/src/identity-providers/scim/scim.parser.ts index 13dd34eed..778a0c1ea 100644 --- a/packages/backend/src/identity-providers/scim/scim.parser.ts +++ b/packages/backend/src/identity-providers/scim/scim.parser.ts @@ -1,4 +1,5 @@ import { ScimError } from './scim.errors'; +import { normalizeEmail } from '../../common/email.util'; import { SCIM_ENTERPRISE_USER_SCHEMA, SCIM_PATCH_SCHEMA } from './scim.schemas'; /** @@ -163,7 +164,7 @@ export function pickEmail(emails: unknown): string | undefined { entries.find((e) => e.primary === true || e.primary === 'true') ?? entries.find((e) => String(e.type ?? '').toLowerCase() === 'work') ?? entries[0]; - return chosen ? String(chosen.value).trim().toLowerCase() : undefined; + return chosen ? normalizeEmail(String(chosen.value)) : undefined; } /** A display name for the `users.name` column, from whatever Entra sent. */ diff --git a/packages/backend/src/identity-providers/sso.service.spec.ts b/packages/backend/src/identity-providers/sso.service.spec.ts index 75ccc63ee..cc76c8c91 100644 --- a/packages/backend/src/identity-providers/sso.service.spec.ts +++ b/packages/backend/src/identity-providers/sso.service.spec.ts @@ -39,7 +39,7 @@ describe('SsoService', () => { beforeEach(() => { prisma = { userIdentity: { findUnique: jest.fn(), update: jest.fn(), create: jest.fn() }, - user: { findUnique: jest.fn(), create: jest.fn() }, + user: { findUnique: jest.fn(), findMany: jest.fn().mockResolvedValue([]), create: jest.fn() }, organizationMember: { create: jest.fn() }, ssoLoginAttempt: { updateMany: jest.fn(), findUnique: jest.fn() }, $transaction: jest.fn((fn: any) => fn(prisma)), @@ -130,6 +130,7 @@ describe('SsoService', () => { }); // The users table is never consulted by email on the known-identity path. expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(prisma.user.findMany).not.toHaveBeenCalled(); }); it('refuses an unknown identity when JIT is off', async () => { @@ -145,7 +146,7 @@ describe('SsoService', () => { // tenant could otherwise assert a victim's address and be handed their // account. Linking must be a deliberate, authenticated action. prisma.userIdentity.findUnique.mockResolvedValue(null); - prisma.user.findUnique.mockResolvedValue({ id: 'victim' }); + prisma.user.findMany.mockResolvedValue([{ id: 'victim', email: goodClaims.email }]); await expect(resolve({ ...provider, jitProvisioning: true })).rejects.toThrow( expect.objectContaining({ reason: 'email_belongs_to_existing_account' }), @@ -154,14 +155,38 @@ describe('SsoService', () => { expect(prisma.userIdentity.create).not.toHaveBeenCalled(); }); + it('treats an account stored with capitals as owning the address', async () => { + // The unique index is case-sensitive: without a case-insensitive check, + // a JIT sign-in for `user@…` would create a second account next to a + // local `USER@…` instead of refusing. + prisma.userIdentity.findUnique.mockResolvedValue(null); + prisma.user.findMany.mockResolvedValue([{ id: 'victim', email: goodClaims.email.toUpperCase() }]); + + await expect(resolve({ ...provider, jitProvisioning: true })).rejects.toThrow( + expect.objectContaining({ reason: 'email_belongs_to_existing_account' }), + ); + expect(prisma.user.findMany.mock.calls[0][0].where.OR).toContainEqual({ + email: { equals: goodClaims.email, mode: 'insensitive' }, + }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + it('provisions a passwordless VIEWER when JIT is on', async () => { prisma.userIdentity.findUnique.mockResolvedValue(null); - prisma.user.findUnique.mockResolvedValue(null); prisma.user.create.mockResolvedValue({ id: 'new-user' }); - expect(await resolve({ ...provider, jitProvisioning: true })).toBe('new-user'); + expect( + await service.resolveUser( + { ...provider, jitProvisioning: true }, + 'a-stable-object-id', + goodClaims.tid, + { ...goodClaims, email: ' New.User@Example.com ' }, + {}, + ), + ).toBe('new-user'); const created = prisma.user.create.mock.calls[0][0].data; + expect(created.email).toBe('new.user@example.com'); // No password: the account exists only through the provider, so there is // no second way in that bypasses its MFA and Conditional Access. expect(created.passwordHash).toBeNull(); @@ -184,7 +209,6 @@ describe('SsoService', () => { beforeEach(() => { prisma.userIdentity.findUnique.mockResolvedValue(null); - prisma.user.findUnique.mockResolvedValue(null); prisma.user.create.mockResolvedValue({ id: 'new-user' }); }); diff --git a/packages/backend/src/identity-providers/sso.service.ts b/packages/backend/src/identity-providers/sso.service.ts index 1ca1d5409..346bf535a 100644 --- a/packages/backend/src/identity-providers/sso.service.ts +++ b/packages/backend/src/identity-providers/sso.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { randomBytes } from 'crypto'; import * as client from 'openid-client'; import { PrismaService } from '../common/prisma.service'; +import { findUserByEmail, normalizeEmail } from '../common/email.util'; import { ROLE_SYNC_PROVIDER_SELECT, RoleSyncService } from './role-sync.service'; import { DeploymentService } from '../common/deployment.service'; import { AuthService } from '../auth/auth.service'; @@ -879,13 +880,12 @@ export class SsoService { // Provision. The email is used ONLY to populate a display field on a // brand-new row — never to find or claim an existing account. - const email = String(claims.email ?? claims.preferred_username ?? '').toLowerCase(); + const email = normalizeEmail(String(claims.email ?? claims.preferred_username ?? '')); if (!email) throw new SsoError('jit_without_email'); - const collision = await this.prisma.user.findUnique({ - where: { email }, - select: { id: true }, - }); + // In any case: a local account stored as `Name@Example.com` owns + // `name@example.com` too. + const collision = await findUserByEmail(this.prisma, email, { id: true }); if (!collision) { // Creating accounts on first sign-in needs Enterprise and a free seat. // People who already have an identity here never reach this point, so diff --git a/packages/backend/src/license/license-trial.service.spec.ts b/packages/backend/src/license/license-trial.service.spec.ts index 01faab753..c036232f2 100644 --- a/packages/backend/src/license/license-trial.service.spec.ts +++ b/packages/backend/src/license/license-trial.service.spec.ts @@ -131,6 +131,16 @@ describe('LicenseService — trial acquisition', () => { const [, payload] = mockedAxios.post.mock.calls[0] as any[]; expect(payload).toEqual({ email: 'user@example.com', name: 'User', instanceId: 'instance-1' }); }); + + it('sends the address normalised, so a case variant is not a new address to the licence site', async () => { + mockedAxios.post.mockResolvedValue({ data: TRIAL_RESPONSE } as any); + const { svc } = makeService(); + + await svc.requestTrialLicense(' User@Example.COM', 'User', 'org-1'); + + const [, payload] = mockedAxios.post.mock.calls[0] as any[]; + expect(payload.email).toBe('user@example.com'); + }); }); describe('LicenseService — a trial answer without a key', () => { diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 153c1d762..3a671793d 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/commo import axios from 'axios'; import * as crypto from 'crypto'; import { PrismaService } from '../common/prisma.service'; +import { normalizeEmail } from '../common/email.util'; import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; @@ -320,9 +321,11 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { const instanceId = await this.getInstanceId(); try { + // Normalised: the licence site keys one licence per address, and + // `Name@Example.com` is the same inbox as `name@example.com`. await axios.post( `${this.apiBase}/api/license/register`, - { email, name, instanceId }, + { email: normalizeEmail(email), name, instanceId }, { timeout: 10000 }, ); @@ -402,7 +405,9 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { const instanceId = await this.getInstanceId(); try { - const data = await this.postTrialWithRetry({ email, name, instanceId }); + // Normalised: the licence site grants one trial per address, and an + // account stored with capitals must not count as a different one. + const data = await this.postTrialWithRetry({ email: normalizeEmail(email), name, instanceId }); if (!data?.licenseKey) { // The licence API hands the key back only to this server, by its // service token; anyone else is told it went by email. Without a key diff --git a/packages/backend/src/settings/email.service.spec.ts b/packages/backend/src/settings/email.service.spec.ts index 4685a141a..7390b384b 100644 --- a/packages/backend/src/settings/email.service.spec.ts +++ b/packages/backend/src/settings/email.service.spec.ts @@ -13,11 +13,16 @@ describe('EmailService', () => { const sendMail = jest.fn().mockResolvedValue({}); const ENV = { ...process.env }; - function make(opts: { cloud?: boolean; stats?: any; user?: { id: string } | null } = {}) { + function make(opts: { cloud?: boolean; stats?: any; user?: { id: string; email: string } | null } = {}) { const siteSettings = { getSmtpConfig: jest.fn().mockResolvedValue(null), get: jest.fn() }; const orgSettings = { getJson: jest.fn().mockResolvedValue(null) }; const prisma = { - user: { findUnique: jest.fn().mockResolvedValue(opts.user === undefined ? { id: 'user-1' } : opts.user) }, + // Stored with capitals: the recipient is looked up in any case. + user: { + findMany: jest + .fn() + .mockResolvedValue(opts.user === undefined ? [{ id: 'user-1', email: 'A@b.com' }] : opts.user ? [opts.user] : []), + }, license: { findFirst: jest.fn().mockResolvedValue(null) }, }; const cloud = opts.cloud ?? true; diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index dbc35bdd6..35a18e88f 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import { SiteSettingsService } from './site-settings.service'; import { OrgSettingsService } from './org-settings.service'; import { PrismaService } from '../common/prisma.service'; +import { findUserByEmail } from '../common/email.util'; import { DeploymentService } from '../common/deployment.service'; import { TrustStatsService } from '../public-stats/trust-stats.service'; import { EMPTY_TRUST_STATS, TrustStats, formatTrustStats } from '../public-stats/trust-stats.format'; @@ -105,9 +106,7 @@ export class EmailService { const cloudUrl = this.cloudUrl(); const secret = unsubscribeSecret(); const user = secret - ? await this.prisma.user - .findUnique({ where: { email: to }, select: { id: true } }) - .catch(() => null) + ? await findUserByEmail<{ id: string; email: string }>(this.prisma, to, { id: true }).catch(() => null) : null; if (secret && user) { const url = buildUnsubscribeUrl(cloudUrl, user.id, secret); diff --git a/packages/backend/src/users/users.controller.ts b/packages/backend/src/users/users.controller.ts index 8d1bccb69..1b9603ef6 100644 --- a/packages/backend/src/users/users.controller.ts +++ b/packages/backend/src/users/users.controller.ts @@ -15,6 +15,7 @@ import { BadRequestException, NotFoundException, ForbiddenException, + ConflictException, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; @@ -26,6 +27,7 @@ import { Roles, RolesGuard } from '../auth/roles.guard'; import { OrganizationsService } from '../organizations/organizations.service'; import { UserLifecycleService, LifecycleContext } from './user-lifecycle.service'; import { SEAT_LIMIT, seatLimitMessage } from '../ee/licensing/edition.service'; +import { normalizeEmail, sameEmail } from '../common/email.util'; class RevokeSessionsDto { @ApiPropertyOptional({ @@ -164,13 +166,21 @@ export class UsersController { // keep the old behaviour. if (process.env.DEPLOYMENT_MODE === 'cloud') { const current = await this.usersService.findById(req.user.sub); - if (dto.email.trim().toLowerCase() !== current?.email?.trim().toLowerCase()) { + if (!sameEmail(dto.email, current?.email)) { throw new BadRequestException( 'Your email address cannot be changed here. Contact support@anythingmcp.com to change it.', ); } } else { - data.email = dto.email; + // Stored normalised, and refused when another account already has + // the address in any case: the unique index only sees exact + // spellings, so `Name@Example.com` would otherwise sit next to + // `name@example.com` as a second account. + const owners = await this.usersService.findAllByEmail(dto.email); + if (owners.some((u) => u.id !== req.user.sub)) { + throw new ConflictException('Email already registered'); + } + data.email = normalizeEmail(dto.email); } } diff --git a/packages/backend/src/users/users.service.spec.ts b/packages/backend/src/users/users.service.spec.ts index b05641f68..06e10268d 100644 --- a/packages/backend/src/users/users.service.spec.ts +++ b/packages/backend/src/users/users.service.spec.ts @@ -47,17 +47,36 @@ describe('UsersService', () => { }); describe('findByEmail', () => { - it('should call findUnique with email', async () => { - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - const result = await service.findByEmail('test@example.com'); + it('looks the address up in any case', async () => { + mockPrisma.user.findMany.mockResolvedValue([mockUser]); + const result = await service.findByEmail('Test@Example.com'); expect(result).toBe(mockUser); - expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ - where: { email: 'test@example.com' }, + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { email: { in: ['Test@Example.com', 'test@example.com'] } }, + { email: { equals: 'test@example.com', mode: 'insensitive' } }, + ], + }, }); }); + it('finds a row stored with capitals from the lower-case address', async () => { + const upper = { ...mockUser, email: 'TEST@EXAMPLE.COM' }; + mockPrisma.user.findMany.mockResolvedValue([upper]); + expect(await service.findByEmail('test@example.com')).toBe(upper); + }); + + it('prefers the exact spelling when legacy duplicates exist', async () => { + const upper = { ...mockUser, id: 'upper', email: 'TEST@example.com', updatedAt: new Date('2026-01-01') }; + const lower = { ...mockUser, id: 'lower', email: 'test@example.com', updatedAt: new Date('2026-02-01') }; + mockPrisma.user.findMany.mockResolvedValue([upper, lower]); + expect((await service.findByEmail('TEST@example.com'))?.id).toBe('upper'); + expect((await service.findByEmail('test@example.com'))?.id).toBe('lower'); + }); + it('should return null when not found', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.findMany.mockResolvedValue([]); const result = await service.findByEmail('nobody@example.com'); expect(result).toBeNull(); }); @@ -75,17 +94,17 @@ describe('UsersService', () => { }); describe('create', () => { - it('should call create with provided data', async () => { + it('should call create with provided data, the address normalised', async () => { const data = { - email: 'new@example.com', + email: ' New@Example.com', passwordHash: 'hash', name: 'New User', organizationId: 'org-1', }; mockPrisma.user.create.mockResolvedValue({ ...mockUser, ...data }); const result = await service.create(data); - expect(mockPrisma.user.create).toHaveBeenCalledWith({ data }); - expect(result.email).toBe('new@example.com'); + expect(mockPrisma.user.create).toHaveBeenCalledWith({ data: { ...data, email: 'new@example.com' } }); + expect(result.email).toBe(' New@Example.com'); }); }); diff --git a/packages/backend/src/users/users.service.ts b/packages/backend/src/users/users.service.ts index 62dc1d636..0930487bb 100644 --- a/packages/backend/src/users/users.service.ts +++ b/packages/backend/src/users/users.service.ts @@ -5,6 +5,7 @@ import { OrganizationsService } from '../organizations/organizations.service'; import { UserLifecycleService, LifecycleContext } from './user-lifecycle.service'; import { SecurityEventService, SecurityEvents } from '../audit/security-event.service'; import { LicenseReleaseService } from '../license/license-release.service'; +import { findUserByEmail, findUsersByEmail, normalizeEmail } from '../common/email.util'; @Injectable() export class UsersService { @@ -18,8 +19,14 @@ export class UsersService { private readonly licenseRelease: LicenseReleaseService, ) {} + /** Case-insensitive: rows written before addresses were normalised are still found. */ async findByEmail(email: string): Promise { - return this.prisma.user.findUnique({ where: { email } }); + return findUserByEmail(this.prisma, email); + } + + /** Every account with this address in any case (more than one only for legacy duplicates). */ + async findAllByEmail(email: string): Promise { + return findUsersByEmail(this.prisma, email); } async findById(id: string): Promise { @@ -33,7 +40,7 @@ export class UsersService { role?: UserRole; organizationId: string; }): Promise { - return this.prisma.user.create({ data }); + return this.prisma.user.create({ data: { ...data, email: normalizeEmail(data.email) } }); } async count(): Promise {