Skip to content

Commit b2b4972

Browse files
fix(plugin-audit): match an @mention email case-insensitively (#27751)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DNCXw1AehBds7qLHmy85L4E)_ Fixes #27503 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:评论里按邮箱 @提及同事时,邮箱改为不区分大小写匹配(先去首尾空格再转小写),与登录查找用户的方式一致。 - 为什么改:以前按原样精确匹配,大小写不同的地址(例如 `Carol@Example.com`)评论照样 201 成功,但被提及的人永远收不到通知,作者也看不到任何报错。 - 风险与回滚:风险低,只改了一处查询条件;谁能收到通知仍由记录与评论的读权限决定,没有放宽。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-audit/src/mention-recipient-gate.ts` 里 `resolveMentionedUser` 的 `where` 那一行;然后看 `mention-recipient-gate.integration.test.ts` 新增的两个用例。 - 两分钟验证:`pnpm --filter @objectstack/plugin-audit exec vitest run src/mention-recipient-gate.integration.test.ts`,预期 Tests 17 passed (17)。 - 需要你做的:无,席位落地。 ## What changes `resolveMentionedUser` (plugin-audit's @mention recipient gate) looked an email-shaped mention up with `where: { email: value }`, an exact match on `sys_user.email`. Identity stores that column lowercased, so `G4B.MEMBER@EXAMPLE.COM` resolved nobody. The comment was still accepted with 201, and the gate logged `mention notification withheld` with reason `unknown_email`. The lookup now passes the address through `normalizePlatformAdminEmail` from `@objectstack/core`. That is the one exported email normalisation in core (trim, then lowercase), and it is already the comparison the platform-admin derivation and the Layer 0 wall use. For an address that has already been trimmed, it gives exactly the spelling sign-in queries: - `findSignInAccount` (plugin-auth) queries `where: { email: email.toLowerCase() }`; - better-auth's `/sign-in/email` calls `internalAdapter.findUserByEmail(email.toLowerCase())`, and `createUser` / `updateUser` write `email.toLowerCase()`, so stored addresses are already normalised. No second matcher is added. Who may be notified does not change: the record read and the comment read in the mentioned user's own envelope still decide. ## Tests `packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts` (real kernel, ObjectQL, SQLite driver, AuditPlugin) gets two new cases: - **isolated:** a comment mentioning `Carol@A.Test`, and one mentioning ` CAROL@A.TEST ` with spaces around it, each notify the user stored as `carol@a.test`. Both go through the real `afterInsert` mention writer. - **withheld-recipient log:** `admitMentionRecipients` with `CAROL@A.TEST` admits CAROL and logs nothing, so there is no `unknown_email`. ## Acceptance notes - **Normaliser name.** `normalizePlatformAdminEmail` carries a platform-admin name, but its docblock calls it the one email normalisation, and it is the only exported one in `@objectstack/core`. Renaming it is a naming question only, so no card was filed. - **Rows outside identity.** A `sys_user` row written with a mixed-case email outside the identity write paths (for example a raw system-context insert) cannot be found by sign-in. After this change it cannot be found by a mention either, so a mention now reaches exactly the users sign-in reaches. - **Unknown user ids.** The card's diagnostic-only note says an unknown user id (`mentions: ["nosuchuser123"]`) is logged as `cannot_read_thread`. It is unchanged here: it affects only the log text, and nobody is notified who should not be. This is an observation, not filed. - **Fresh `origin/main`.** `origin/main` moved after the single merge (to `34dc09b68`). The only family file that changed in that range is `scripts/check-route-envelope.mjs`, and it is not one this diff derives. CI runs on the merged head. ## Verification All readings are on head `e7119109` (fix commit `5febd38e` plus one merge of `origin/main` `ef359b95`). <details><summary>Red before the fix (built closure, test only)</summary> ``` × resolves an email mention whatever its casing, the way sign-in finds the user × logs no unknown_email for a differently-cased address of a stored user AssertionError: expected [] to deeply equal [ [ 'u_carol' ] ] Tests 2 failed | 15 passed (17) ``` </details> <details><summary>Green after the fix</summary> ``` Test Files 1 passed (1) Tests 17 passed (17) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablation (fix committed first; scripts/ablation-replace.mjs; suite resolves core and this package from src, so no dist leg)</summary> ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 8f07337 -> e29091174dd9 × resolves an email mention whatever its casing, the way sign-in finds the user × logs no unknown_email for a differently-cased address of a stored user Tests 2 failed | 15 passed (17) ablation-replace: ok restored: blob == HEAD (8f07337) and `git diff HEAD` is empty ``` </details> <details><summary>Package test, typecheck, lint (head e711910)</summary> ``` pnpm --filter @objectstack/plugin-audit test -> Test Files 65 passed (65) · Tests 1051 passed (1051) · VERDICT command-exit 0 pnpm --filter @objectstack/plugin-audit typecheck -> VERDICT command-exit 0 (check:test-typecheck OK, 0 debt) eslint --no-inline-config (the two changed .ts files) -> exit 0, no output ``` </details> <details><summary>Derived gate families (dispatch-gates --commands, 61 commands, head e711910)</summary> ``` 61 run, 61 exit 0 dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED ``` On the first pass three commands exited 3 (PREREQUISITE NOT MET, nothing was measured). Each was re-run after its prerequisite was met: - `check-plugin-teardown-shape --self-test`: fetched the pinned fixture commit, then exit 0 (48 cases). - `check:i18n`: built its stated closure, then exit 0 (10 packages in sync). - `check:dual-build-cjs-loads`: full `pnpm build` (114 tasks), then exit 0 (112 entries across 69 packages). The PR's check-runs cover all other CI families. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czuTWNgKR22fKv7jia Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d9419b commit b2b4972

3 files changed

Lines changed: 40 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): an @mention by email matches the user whatever the address's casing
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,13 @@ describe('@mention notifications reach only mentioned users who can read the com
279279
expect(audiences(await comment(openTask, [CAROL.email]))).toEqual([[CAROL.id]]);
280280
});
281281

282+
it('resolves an email mention whatever its casing, the way sign-in finds the user', async () => {
283+
// The stored address is the lowercased one identity writes; sign-in
284+
// looks it up lowercased, and so must a mention.
285+
expect(audiences(await comment(openTask, ['Carol@A.Test']))).toEqual([[CAROL.id]]);
286+
expect(audiences(await comment(openTask, [` ${CAROL.email.toUpperCase()} `]))).toEqual([[CAROL.id]]);
287+
});
288+
282289
it('never notifies the author, by id or by email', async () => {
283290
expect(audiences(await comment(openTask, [ALICE.id, ALICE.email]))).toEqual([]);
284291
});
@@ -367,6 +374,20 @@ describe('@mention notifications reach only mentioned users who can read the com
367374
expect(JSON.stringify(warned)).not.toContain(String(row.body));
368375
});
369376

377+
it('logs no unknown_email for a differently-cased address of a stored user', async () => {
378+
const commentId = String((await post(openTask, [])).id);
379+
const warned: Array<{ message: string; meta?: Record<string, unknown> }> = [];
380+
const admitted = await admitMentionRecipients(gate(warned), {
381+
mentions: ['CAROL@A.TEST'],
382+
threadId: `${TASK}:${openTask}`,
383+
actorId: ALICE.id,
384+
organizationId: ORG_A,
385+
commentId,
386+
});
387+
expect(admitted).toEqual([CAROL.id]);
388+
expect(warned).toEqual([]);
389+
});
390+
370391
it('withholds every recipient when the comment cannot be read back by its id', async () => {
371392
const warned: Array<{ message: string; meta?: Record<string, unknown> }> = [];
372393
const input = { mentions: [CAROL.id], threadId: `${TASK}:${openTask}`, actorId: ALICE.id, organizationId: ORG_A };

‎packages/plugins/plugin-audit/src/mention-recipient-gate.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
*/
5555

5656
import {
57+
normalizePlatformAdminEmail,
5758
resolveReadableParentIds,
5859
resolveUserAuthzGrants,
5960
vetOrganizationClaim,
@@ -254,6 +255,13 @@ type MentionedUser = { ok: true; id: string } | { ok: false; reason: MentionWith
254255
* user may be notified is the read check's question, never this lookup's).
255256
* An audience expansion (`role:` / `team:` / `owner_of:`) names no single
256257
* person and is not a mention.
258+
*
259+
* An email is looked up the way sign-in looks one up: identity stores
260+
* `sys_user.email` lowercased, and sign-in queries the lowercased address, so
261+
* a mention normalises with the platform's one email normalisation
262+
* (`normalizePlatformAdminEmail`: trim, then lowercase) before the exact
263+
* match. ⛔ No second email matcher here — a differently-cased address must
264+
* name the same user sign-in would.
257265
*/
258266
async function resolveMentionedUser(engine: MentionRecipientGateEngine, raw: string): Promise<MentionedUser> {
259267
let value = raw.trim();
@@ -263,7 +271,7 @@ async function resolveMentionedUser(engine: MentionRecipientGateEngine, raw: str
263271
if (!value.includes('@')) return { ok: true, id: value };
264272
try {
265273
const rows = await engine.find(USER_OBJECT, {
266-
where: { email: value },
274+
where: { email: normalizePlatformAdminEmail(value) },
267275
fields: ['id'],
268276
limit: 1,
269277
context: SYSTEM_CONTEXT,

0 commit comments

Comments
 (0)