diff --git a/lib/search-query.js b/lib/search-query.js index 279621e6..0ede923c 100644 --- a/lib/search-query.js +++ b/lib/search-query.js @@ -143,6 +143,7 @@ const getMongoDBQuery = async (db, user, queryStr) => { const parsed = parseSearchQuery(queryStr); let hasTextFilter = false; + const isSingleTextQuery = entry => typeof entry?.$text?.$search === 'string' && Object.keys(entry).length === 1; let walkTree = async node => { if (Array.isArray(node)) { @@ -163,6 +164,26 @@ const getMongoDBQuery = async (db, user, queryStr) => { branch.$and = branch.$and.concat(subBranch || []); } + // MongoDB allows a single $text expression per query. If this AND branch + // has multiple direct text clauses, merge them into one. + // Used for fulltext AND query (default) (example: `q=term1 term2`) + if (branch.$and.length > 1) { + let textTerms = []; + let nonTextTerms = []; + + for (let entry of branch.$and) { + if (isSingleTextQuery(entry)) { + textTerms.push(entry.$text.$search); + } else { + nonTextTerms.push(entry); + } + } + + if (textTerms.length > 1) { + branch.$and = [{ $text: { $search: textTerms.join(' ') } }].concat(nonTextTerms); + } + } + return branch; } else if (node.$or && node.$or.length) { let branch = { @@ -178,7 +199,7 @@ const getMongoDBQuery = async (db, user, queryStr) => { // MongoDB allows a single $text expression per query. If this OR branch // only contains text queries, merge them into one $text search. // Used for fulltext OR query search - if (branch.$or.length && branch.$or.every(entry => entry?.$text && typeof entry.$text.$search === 'string' && Object.keys(entry).length === 1)) { + if (branch.$or.length && branch.$or.every(isSingleTextQuery)) { return { $text: { $search: branch.$or.map(entry => entry.$text.$search).join(' ') diff --git a/test/api/messages-test.js b/test/api/messages-test.js index 8e7537b1..c97071cd 100644 --- a/test/api/messages-test.js +++ b/test/api/messages-test.js @@ -27,6 +27,8 @@ describe('Messages tests', function () { subjectAttachment: 'Search Query Attachment Marker', body: 'searchquerybodytoken', attachmentBody: 'searchqueryattachmenttoken', + multiTerm1: 'searchquerymultiterm1token', + multiTerm2: 'searchquerymultiterm2token', toAddress: 'search.query.to@to.com', ccAddress: 'search.query.cc@to.com', fromAddress: 'messagestestsuser@web.zone.test' @@ -89,7 +91,7 @@ describe('Messages tests', function () { cc: [{ address: queryFixture.ccAddress }], bcc: [{ address: queryFixture.ccAddress }], subject: queryFixture.subjectKeyword, - text: `${queryFixture.body} keyword marker` + text: `${queryFixture.body} keyword marker ${queryFixture.multiTerm1} ${queryFixture.multiTerm2}` }) .expect(200); @@ -262,6 +264,27 @@ describe('Messages tests', function () { expect(search.body.results.map(entry => entry.subject)).to.include(queryFixture.subjectExcluded); }); + it('should GET /users/:user/search expect success / q with two terms and searchable=1', async () => { + const q = `${queryFixture.multiTerm1} ${queryFixture.multiTerm2} in:${queryMailbox}`; + + const search = await server + .get(`/users/${user}/search?q=${encodeURIComponent(q)}&limit=50`) + .send({}) + .expect(200); + + const searchWithSearchable = await server + .get(`/users/${user}/search?q=${encodeURIComponent(q)}&searchable=1&limit=50`) + .send({}) + .expect(200); + + expect(search.body.success).to.be.true; + expect(searchWithSearchable.body.success).to.be.true; + expect(searchWithSearchable.body.query).to.equal(q); + expect(search.body.results.map(entry => entry.subject)).to.include(queryFixture.subjectKeyword); + expect(searchWithSearchable.body.results.map(entry => entry.subject)).to.include(queryFixture.subjectKeyword); + expect(searchWithSearchable.body.results).to.deep.equal(search.body.results); + }); + it('should GET /users/:user/search expect success / q supports OR groups', async () => { const q = `(${queryFixture.body} OR ${queryFixture.attachmentBody}) in:${queryMailbox}`; const search = await server