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,