From 27c6c4e969d0eeaeeb2ff4393de5bdcd6fd28804 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 30 Mar 2026 18:20:32 -0300 Subject: [PATCH 01/10] add email filtering for users.info and users.list --- apps/meteor/app/api/server/v1/users.ts | 21 +++++++++++---- .../lib/server/functions/getFullUserData.ts | 27 ++++++++++++------- .../src/v1/users/UsersListParamsGET.ts | 23 ++++++++++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 packages/rest-typings/src/v1/users/UsersListParamsGET.ts diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 50c65abcd8d12..ac398470f64a4 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -18,6 +18,7 @@ import { isUsersSetPreferencesParamsPOST, isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, + isUsersListParamsGET, ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, @@ -52,7 +53,7 @@ import { } from '../../../lib/server/functions/checkUsernameAvailability'; import { deleteUser } from '../../../lib/server/functions/deleteUser'; import { getAvatarSuggestionForUser } from '../../../lib/server/functions/getAvatarSuggestionForUser'; -import { getFullUserDataByIdOrUsernameOrImportId, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData'; +import { getFullUserDataByIdOrUsernameOrImportIdOrEmail, defaultFields, fullFields } from '../../../lib/server/functions/getFullUserData'; import { generateUsernameSuggestion } from '../../../lib/server/functions/getUsernameSuggestion'; import { saveCustomFields } from '../../../lib/server/functions/saveCustomFields'; import { saveCustomFieldsWithoutValidation } from '../../../lib/server/functions/saveCustomFieldsWithoutValidation'; @@ -432,16 +433,17 @@ API.v1.addRoute( { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { async get() { - const searchTerms: [string, 'id' | 'username' | 'importId'] | false = + const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | false = ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || - ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']); + ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']) || + ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']); if (!searchTerms) { return API.v1.failure('Invalid search query.'); } - const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); + const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(this.userId, ...searchTerms); if (!user) { return API.v1.failure('User not found.'); @@ -482,6 +484,7 @@ API.v1.addRoute( authRequired: true, queryOperations: ['$or', '$and'], permissionsRequired: ['view-d-room'], + query: isUsersListParamsGET, }, { async get() { @@ -491,6 +494,7 @@ API.v1.addRoute( ) { return API.v1.forbidden(); } + const canViewFullOtherUserInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields, query } = await this.parseJsonQuery(); @@ -501,7 +505,14 @@ API.v1.addRoute( const inclusiveFieldsKeys = Object.keys(inclusiveFields); - const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info')); + const nonEmptyQuery = getNonEmptyQuery(query, canViewFullOtherUserInfo); + + if ('email' in this.queryParams && this.queryParams.email) { + if (!canViewFullOtherUserInfo) { + return API.v1.forbidden(); + } + nonEmptyQuery['emails.address'] = this.queryParams.email; + } // if user provided a query, validate it with their allowed operators // otherwise we use the default query (with $regex and $options) diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 4270ad6960fc3..d46ddd3864e57 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, IUserEmail } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; @@ -75,23 +75,24 @@ const getFields = (canViewAllInfo: boolean): Record => ({ ...getCustomFields(canViewAllInfo), }); -export async function getFullUserDataByIdOrUsernameOrImportId( +export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail( userId: string, searchValue: string, - searchType: 'id' | 'username' | 'importId', + searchType: 'id' | 'username' | 'importId' | 'email', ): Promise { - const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1 } }); + const caller = await Users.findOneById(userId, { projection: { username: 1, importIds: 1, emails: 1 } }); if (!caller) { return null; } const myself = (searchType === 'id' && searchValue === userId) || (searchType === 'username' && searchValue === caller.username) || - (searchType === 'importId' && caller.importIds?.includes(searchValue)); + (searchType === 'importId' && caller.importIds?.includes(searchValue)) || + (searchType === 'email' && caller.emails?.some((email: IUserEmail) => email.address === searchValue)); const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); - // Only search for importId if the user has permission to view the import id - if (searchType === 'importId' && !canViewAllInfo) { + // Only search for importId/email if the user has permission to view them + if ((searchType === 'importId' && !canViewAllInfo) || (searchType === 'email' && !canViewAllInfo)) { return null; } @@ -104,9 +105,15 @@ export async function getFullUserDataByIdOrUsernameOrImportId( }, }; - const user = await (searchType === 'importId' - ? Users.findOneByImportId(searchValue, options) - : Users.findOneByIdOrUsername(searchValue, options)); + let user; + if (searchType === 'importId') { + user = await Users.findOneByImportId(searchValue, options); + } else if (searchType === 'email') { + user = await Users.findOneByEmailAddress(searchValue, options); + } else { + user = await Users.findOneByIdOrUsername(searchValue, options); + } + if (!user) { return null; } diff --git a/packages/rest-typings/src/v1/users/UsersListParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListParamsGET.ts new file mode 100644 index 0000000000000..ae26ca8f66fc2 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListParamsGET.ts @@ -0,0 +1,23 @@ +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajvQuery } from '../Ajv'; + +export type UsersListParamsGET = PaginatedRequest<{ + fields?: string; + query?: string; + email?: string; +}>; + +const UsersListParamsGetSchema = { + type: 'object', + properties: { + fields: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + email: { type: 'string', minLength: 1, nullable: true }, + }, + additionalProperties: false, +}; + +export const isUsersListParamsGET = ajvQuery.compile(UsersListParamsGetSchema); From ae9097c591cd099dd1ba6947ee41b991bf53c375 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 30 Mar 2026 18:24:19 -0300 Subject: [PATCH 02/10] modify users.info validation schema for email filtering --- packages/rest-typings/src/index.ts | 1 + .../src/v1/users/UsersInfoParamsGet.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index deb8359b1f896..c5950ea114b3c 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -238,6 +238,7 @@ export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST'; export * from './v1/users/UsersUpdateParamsPOST'; export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET'; export * from './v1/users/UsersSendConfirmationEmailParamsPOST'; +export * from './v1/users/UsersListParamsGET'; export * from './v1/moderation'; export * from './v1/server-events'; diff --git a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts index 30dd4e9131c62..3b82a2b908215 100644 --- a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts +++ b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts @@ -1,6 +1,6 @@ import { ajvQuery } from '../Ajv'; -export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & { +export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string } | { email: string }) & { fields?: string; includeUserRooms?: string; }; @@ -58,6 +58,23 @@ const UsersInfoParamsGetSchema = { required: ['importId'], additionalProperties: false, }, + { + type: 'object', + properties: { + email: { + type: 'string', + }, + includeUserRooms: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['email'], + additionalProperties: false, + }, ], }; From c86240146783e29326af01eba0b8add6ec28995a Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 30 Mar 2026 20:13:14 -0300 Subject: [PATCH 03/10] remove obsolete users.list test --- apps/meteor/tests/end-to-end/api/users.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 049429291babe..cf137e29b9077 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -1443,28 +1443,6 @@ describe('[Users]', () => { .end(done); }); - it('should query all users in the system by name', (done) => { - // filtering user list - void request - .get(api('users.list')) - .set(credentials) - .query({ - name: { $regex: 'g' }, - sort: JSON.stringify({ - createdAt: -1, - }), - }) - .field('username', 1) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count'); - expect(res.body).to.have.property('total'); - }) - .end(done); - }); - it('should query all users in the system when logged as normal user and `view-outside-room` not granted', async () => { await updatePermission('view-outside-room', ['admin']); await request From 1fd9f853211fdef97367d7dde46147b597aa5282 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 30 Mar 2026 20:32:18 -0300 Subject: [PATCH 04/10] add changeset --- .changeset/soft-rats-behave.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/soft-rats-behave.md diff --git a/.changeset/soft-rats-behave.md b/.changeset/soft-rats-behave.md new file mode 100644 index 0000000000000..d657151c07f01 --- /dev/null +++ b/.changeset/soft-rats-behave.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds email search filter to `users.list` and `users.info` endpoints. From 4217e599d95e1c0e0707e4430f49407ea70398f7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 31 Mar 2026 13:20:58 -0300 Subject: [PATCH 05/10] add users.list tests --- apps/meteor/tests/end-to-end/api/users.ts | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index cf137e29b9077..a8d417dfec7db 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -1527,6 +1527,73 @@ describe('[Users]', () => { }); }); }); + + it('should return the specific user with the "emails" property when querying by email and having "view-full-other-user-info" permission', async () => { + const targetEmail = user.emails[0].address; + + await request + .get(api('users.list')) + .query({ email: targetEmail }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); + + const returnedUser = res.body.users[0]; + expect(returnedUser).to.have.property('_id', user._id); + expect(returnedUser).to.have.property('emails').that.is.an('array'); + expect(returnedUser).to.have.nested.property('emails[0].address', targetEmail); + }); + }); + + it('should return 403 Forbidden when querying by email and the user DOES NOT have "view-full-other-user-info" permission', async () => { + const targetEmail = user.emails[0].address; + + await request + .get(api('users.list')) + .query({ email: targetEmail }) + .set(user2Credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + + it('should return an empty array when querying by an email that does not exist', async () => { + await request + .get(api('users.list')) + .query({ email: 'this_email_does_not_exist@invalid.com' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.property('count', 0); + }); + }); + + it('should NOT return the "emails" property for users when the caller lacks "view-full-other-user-info" permission', async () => { + await updatePermission('view-full-other-user-info', ['admin']); + + await request + .get(api('users.list')) + .set(user2Credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array'); + + res.body.users.forEach((u: IUser) => { + expect(u).to.not.have.property('emails'); + }); + }); + }); }); describe('Avatars', () => { From 53f4d2dd38ba1c2f14ff5718b9ff3dc5fe75a140 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 31 Mar 2026 14:13:09 -0300 Subject: [PATCH 06/10] add users.info tests --- apps/meteor/tests/end-to-end/api/users.ts | 85 ++++++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index a8d417dfec7db..ad29e2230dfba 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -12,7 +12,7 @@ import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait, import { imgURL } from '../../data/interactions'; import { createAgent, makeAgentAvailable } from '../../data/livechat/rooms'; import { removeAgent, getAgent } from '../../data/livechat/users'; -import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { updatePermission, updateSetting, restorePermissionToRoles } from '../../data/permissions.helper'; import type { ActionRoomParams } from '../../data/rooms.helper'; import { actionRoom, createRoom, deleteRoom } from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; @@ -181,8 +181,8 @@ const updateUserInDb = async (userId: IUser['_id'], userData: Partial) => await connection.close(); }; -describe('[Users]', () => { - let targetUser: { _id: IUser['_id']; username: string }; +describe.only('[Users]', () => { + let targetUser: { _id: IUser['_id']; username: string; emails: { address: string }[] }; let userCredentials: Credentials; before((done) => getCredentials(done)); @@ -197,6 +197,7 @@ describe('[Users]', () => { targetUser = { _id: user._id, username: user.username, + emails: user.emails, }; userCredentials = await login(user.username, password); }); @@ -1166,6 +1167,84 @@ describe('[Users]', () => { }) .end(done); }); + + describe('fetch by user email', () => { + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + }); + + describe("with 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); + }); + + it('should query information about a user by email', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user._id', targetUser._id); + }); + }); + it('should return an error when querying by an email that does not exist', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: 'this_is_a_fake_email_that_does_not_exist@invalid.com', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + }); + + describe("without 'view-full-other-user-info' permission", () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); + }); + + it('should return an error when querying another user by email and lacking "view-full-other-user-info" permission', async () => { + await request + .get(api('users.info')) + .set(credentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User not found.'); + }); + }); + it('should query information about myself by email', async () => { + await request + .get(api('users.info')) + .set(userCredentials) + .query({ + email: targetUser.emails[0].address, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', targetUser.username); + expect(res.body).to.have.nested.property('user.emails[0].address', targetUser.emails[0].address); + }); + }); + }); + }); }); describe('[/users.getPresence]', () => { it("should query a user's presence by userId", (done) => { From d4c321e13ce3a85e10ad5738a141d5f36f78799c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 31 Mar 2026 14:33:49 -0300 Subject: [PATCH 07/10] enhance users.list tests --- apps/meteor/tests/end-to-end/api/users.ts | 113 ++++++++++------------ 1 file changed, 53 insertions(+), 60 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index ad29e2230dfba..d4f6424ac8eb4 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -181,7 +181,7 @@ const updateUserInDb = async (userId: IUser['_id'], userData: Partial) => await connection.close(); }; -describe.only('[Users]', () => { +describe('[Users]', () => { let targetUser: { _id: IUser['_id']; username: string; emails: { address: string }[] }; let userCredentials: Credentials; @@ -1607,71 +1607,64 @@ describe.only('[Users]', () => { }); }); - it('should return the specific user with the "emails" property when querying by email and having "view-full-other-user-info" permission', async () => { - const targetEmail = user.emails[0].address; - - await request - .get(api('users.list')) - .query({ email: targetEmail }) - .set(credentials) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); - - const returnedUser = res.body.users[0]; - expect(returnedUser).to.have.property('_id', user._id); - expect(returnedUser).to.have.property('emails').that.is.an('array'); - expect(returnedUser).to.have.nested.property('emails[0].address', targetEmail); + describe('querying by user email', async () => { + after(async () => { + await restorePermissionToRoles('view-full-other-user-info'); + }); + describe("with 'view-full-other-user-info' permission", async () => { + before(async () => { + await updatePermission('view-full-other-user-info', ['admin']); }); - }); + it('should return the specific user with the "emails" property', async () => { + const targetEmail = targetUser.emails[0].address; - it('should return 403 Forbidden when querying by email and the user DOES NOT have "view-full-other-user-info" permission', async () => { - const targetEmail = user.emails[0].address; + await request + .get(api('users.list')) + .query({ email: targetEmail }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); - await request - .get(api('users.list')) - .query({ email: targetEmail }) - .set(user2Credentials) - .expect('Content-Type', 'application/json') - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'unauthorized'); + const returnedUser = res.body.users[0]; + expect(returnedUser).to.have.property('_id', targetUser._id); + expect(returnedUser).to.have.property('emails').that.is.an('array'); + expect(returnedUser).to.have.nested.property('emails[0].address', targetEmail); + }); }); - }); - - it('should return an empty array when querying by an email that does not exist', async () => { - await request - .get(api('users.list')) - .query({ email: 'this_email_does_not_exist@invalid.com' }) - .set(credentials) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(0); - expect(res.body).to.have.property('count', 0); + it('should return an empty array when querying by an email that does not exist', async () => { + await request + .get(api('users.list')) + .query({ email: 'this_email_does_not_exist@invalid.com' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(0); + expect(res.body).to.have.property('count', 0); + }); }); - }); - - it('should NOT return the "emails" property for users when the caller lacks "view-full-other-user-info" permission', async () => { - await updatePermission('view-full-other-user-info', ['admin']); - - await request - .get(api('users.list')) - .set(user2Credentials) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('users').that.is.an('array'); - - res.body.users.forEach((u: IUser) => { - expect(u).to.not.have.property('emails'); - }); + }); + describe("without 'view-full-other-user-info' permission", async () => { + before(async () => { + await updatePermission('view-full-other-user-info', []); }); + it('should return 403 Forbidden', async () => { + await request + .get(api('users.list')) + .query({ email: targetUser.emails[0].address }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); + }); + }); + }); }); }); From d67a345a24bbe08a1b766aae9000b4ef7d0e1d21 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 31 Mar 2026 14:38:16 -0300 Subject: [PATCH 08/10] enhance users.info tests --- apps/meteor/tests/end-to-end/api/users.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index d4f6424ac8eb4..fade0321b5250 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -1168,7 +1168,7 @@ describe('[Users]', () => { .end(done); }); - describe('fetch by user email', () => { + describe('querying by user email', () => { after(async () => { await restorePermissionToRoles('view-full-other-user-info'); }); @@ -1179,11 +1179,13 @@ describe('[Users]', () => { }); it('should query information about a user by email', async () => { + const targetEmail = targetUser.emails[0].address; + await request .get(api('users.info')) .set(credentials) .query({ - email: targetUser.emails[0].address, + email: targetEmail, }) .expect('Content-Type', 'application/json') .expect(200) @@ -1191,6 +1193,7 @@ describe('[Users]', () => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('user.username', targetUser.username); expect(res.body).to.have.nested.property('user._id', targetUser._id); + expect(res.body).to.have.nested.property('user.emails[0].address', targetEmail); }); }); it('should return an error when querying by an email that does not exist', async () => { From e246999de5c53863ca4c69dbc217f59d06eeee77 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 31 Mar 2026 17:04:21 -0300 Subject: [PATCH 09/10] make email comparison and lookup case insensitive --- apps/meteor/app/api/server/v1/users.ts | 7 ++++++- apps/meteor/app/lib/server/functions/getFullUserData.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index ac398470f64a4..85e76035fa358 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -23,6 +23,7 @@ import { validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; @@ -511,7 +512,11 @@ API.v1.addRoute( if (!canViewFullOtherUserInfo) { return API.v1.forbidden(); } - nonEmptyQuery['emails.address'] = this.queryParams.email; + const escapedEmail = escapeRegExp(this.queryParams.email as string); + nonEmptyQuery['emails.address'] = { + $regex: `^${escapedEmail}$`, + $options: 'i', + }; } // if user provided a query, validate it with their allowed operators diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index d46ddd3864e57..e5464c6303ff6 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -88,7 +88,8 @@ export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail( (searchType === 'id' && searchValue === userId) || (searchType === 'username' && searchValue === caller.username) || (searchType === 'importId' && caller.importIds?.includes(searchValue)) || - (searchType === 'email' && caller.emails?.some((email: IUserEmail) => email.address === searchValue)); + (searchType === 'email' && + caller.emails?.some((email: IUserEmail) => email.address.trim().toLowerCase() === searchValue.trim().toLowerCase())); const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); // Only search for importId/email if the user has permission to view them From aae0aff5058a284c16315b1da9eb686d434ae1fa Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 1 Apr 2026 15:22:48 -0300 Subject: [PATCH 10/10] add findTargetUser function to use const instead of let --- .../app/lib/server/functions/getFullUserData.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index e5464c6303ff6..c3c035e16211b 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -75,6 +75,12 @@ const getFields = (canViewAllInfo: boolean): Record => ({ ...getCustomFields(canViewAllInfo), }); +const findTargetUser = (type: string, value: string, opts: any) => { + if (type === 'importId') return Users.findOneByImportId(value, opts); + if (type === 'email') return Users.findOneByEmailAddress(value, opts); + return Users.findOneByIdOrUsername(value, opts); +}; + export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail( userId: string, searchValue: string, @@ -106,14 +112,7 @@ export async function getFullUserDataByIdOrUsernameOrImportIdOrEmail( }, }; - let user; - if (searchType === 'importId') { - user = await Users.findOneByImportId(searchValue, options); - } else if (searchType === 'email') { - user = await Users.findOneByEmailAddress(searchValue, options); - } else { - user = await Users.findOneByIdOrUsername(searchValue, options); - } + const user = await findTargetUser(searchType, searchValue, options); if (!user) { return null;