Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/27503-mention-email-case-insensitive.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
Expand Down Expand Up @@ -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<string, unknown> }> = [];
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<string, unknown> }> = [];
const input = { mentions: [CAROL.id], threadId: `${TASK}:${openTask}`, actorId: ALICE.id, organizationId: ORG_A };
Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/plugin-audit/src/mention-recipient-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
*/

import {
normalizePlatformAdminEmail,
resolveReadableParentIds,
resolveUserAuthzGrants,
vetOrganizationClaim,
Expand Down Expand Up @@ -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<MentionedUser> {
let value = raw.trim();
Expand All @@ -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,
Expand Down
Loading