From 5febd38e8002d45370810de031e5bfd26f35e715 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:31:28 +0000 Subject: [PATCH] fix(plugin-audit): match an @mention email case-insensitively An email-shaped mention was looked up with an exact match on sys_user.email, so a differently-cased address resolved nobody and the colleague was silently never notified. Normalise it with the shared email normalisation (trim, then lowercase) before the lookup, the same spelling identity stores and sign-in queries. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czuTWNgKR22fKv7jia --- .../27503-mention-email-case-insensitive.md | 10 +++++++++ ...mention-recipient-gate.integration.test.ts | 21 +++++++++++++++++++ .../src/mention-recipient-gate.ts | 10 ++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .changeset/27503-mention-email-case-insensitive.md diff --git a/.changeset/27503-mention-email-case-insensitive.md b/.changeset/27503-mention-email-case-insensitive.md new file mode 100644 index 00000000000..80313cb246d --- /dev/null +++ b/.changeset/27503-mention-email-case-insensitive.md @@ -0,0 +1,10 @@ +--- +'@objectstack/plugin-audit': patch +--- + +fix(plugin-audit): an @mention by email matches the user whatever the address's casing + +Clause-②: no +Metadata-protocol: none + +A comment that @-mentioned a colleague by email in a different case than the stored address (`Carol@Example.com` for `carol@example.com`) was accepted, but the mention resolved no user and the colleague was never notified; the server logged `mention notification withheld` with reason `unknown_email`. The mention lookup now normalises the address the way identity and sign-in do (trim, then lowercase, through the shared `normalizePlatformAdminEmail`), so it names the same user sign-in would. Who may be notified is unchanged: the record and comment read checks still decide. diff --git a/packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts b/packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts index 7089c410001..d992a8476f1 100644 --- a/packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts +++ b/packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts @@ -279,6 +279,13 @@ describe('@mention notifications reach only mentioned users who can read the com expect(audiences(await comment(openTask, [CAROL.email]))).toEqual([[CAROL.id]]); }); + it('resolves an email mention whatever its casing, the way sign-in finds the user', async () => { + // The stored address is the lowercased one identity writes; sign-in + // looks it up lowercased, and so must a mention. + expect(audiences(await comment(openTask, ['Carol@A.Test']))).toEqual([[CAROL.id]]); + expect(audiences(await comment(openTask, [` ${CAROL.email.toUpperCase()} `]))).toEqual([[CAROL.id]]); + }); + it('never notifies the author, by id or by email', async () => { expect(audiences(await comment(openTask, [ALICE.id, ALICE.email]))).toEqual([]); }); @@ -367,6 +374,20 @@ describe('@mention notifications reach only mentioned users who can read the com expect(JSON.stringify(warned)).not.toContain(String(row.body)); }); + it('logs no unknown_email for a differently-cased address of a stored user', async () => { + const commentId = String((await post(openTask, [])).id); + const warned: Array<{ message: string; meta?: Record }> = []; + const admitted = await admitMentionRecipients(gate(warned), { + mentions: ['CAROL@A.TEST'], + threadId: `${TASK}:${openTask}`, + actorId: ALICE.id, + organizationId: ORG_A, + commentId, + }); + expect(admitted).toEqual([CAROL.id]); + expect(warned).toEqual([]); + }); + it('withholds every recipient when the comment cannot be read back by its id', async () => { const warned: Array<{ message: string; meta?: Record }> = []; const input = { mentions: [CAROL.id], threadId: `${TASK}:${openTask}`, actorId: ALICE.id, organizationId: ORG_A }; diff --git a/packages/plugins/plugin-audit/src/mention-recipient-gate.ts b/packages/plugins/plugin-audit/src/mention-recipient-gate.ts index 1ee01f81f18..8f07337134c 100644 --- a/packages/plugins/plugin-audit/src/mention-recipient-gate.ts +++ b/packages/plugins/plugin-audit/src/mention-recipient-gate.ts @@ -54,6 +54,7 @@ */ import { + normalizePlatformAdminEmail, resolveReadableParentIds, resolveUserAuthzGrants, vetOrganizationClaim, @@ -254,6 +255,13 @@ type MentionedUser = { ok: true; id: string } | { ok: false; reason: MentionWith * user may be notified is the read check's question, never this lookup's). * An audience expansion (`role:` / `team:` / `owner_of:`) names no single * person and is not a mention. + * + * An email is looked up the way sign-in looks one up: identity stores + * `sys_user.email` lowercased, and sign-in queries the lowercased address, so + * a mention normalises with the platform's one email normalisation + * (`normalizePlatformAdminEmail`: trim, then lowercase) before the exact + * match. ⛔ No second email matcher here — a differently-cased address must + * name the same user sign-in would. */ async function resolveMentionedUser(engine: MentionRecipientGateEngine, raw: string): Promise { let value = raw.trim(); @@ -263,7 +271,7 @@ async function resolveMentionedUser(engine: MentionRecipientGateEngine, raw: str if (!value.includes('@')) return { ok: true, id: value }; try { const rows = await engine.find(USER_OBJECT, { - where: { email: value }, + where: { email: normalizePlatformAdminEmail(value) }, fields: ['id'], limit: 1, context: SYSTEM_CONTEXT,