Skip to content

Commit 06569f6

Browse files
authored
fix(fulltext-filter): ZMS-257: Add boolean logic to fulltext filter (#863)
* feat: add boolean logic to fulltext filter * add support for exact matches (must be in double quotes) * add tests for new filtering system, fix issues * add more edge case tests * tests remove .only, remove console log. Fix parseFilterQueryText OR handling logic * filterQueryTermMatches normalize exact match to lowercase too, filter-handler actually use exactPhrases * normalize input text in filterQueryTermMatches, update filter schema
1 parent d857762 commit 06569f6

File tree

4 files changed

+477
-4
lines changed

4 files changed

+477
-4
lines changed

lib/filter-handler.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -935,9 +935,23 @@ async function checkFilter(filterData, prepared, maildata) {
935935
}
936936
}
937937

938-
if (query.text && maildata.text.toLowerCase().replace(/\s+/g, ' ').indexOf(query.text.toLowerCase()) < 0) {
939-
// message plaintext does not match the text field value
940-
return false;
938+
if (query.text) {
939+
const { andTerms, orTerms, exactPhrases } = tools.parseFilterQueryText(query.text);
940+
941+
const normalizedEmailText = maildata.text.toLowerCase().replace(/\s+/g, ' ');
942+
943+
if (!andTerms.length && !orTerms.length && normalizedEmailText.indexOf(query.text.toLowerCase()) < 0) {
944+
// message plaintext does not match the text field value
945+
return false;
946+
}
947+
948+
const andMatches = !andTerms.length || andTerms.every(term => tools.filterQueryTermMatches(normalizedEmailText, term, exactPhrases));
949+
950+
const orMatches = !orTerms.length || orTerms.some(term => tools.filterQueryTermMatches(normalizedEmailText, term, exactPhrases));
951+
952+
if (!(andMatches && orMatches)) {
953+
return false; // Filter not satisfied
954+
}
941955
}
942956

943957
log.silly('Filter', 'Filter %s matched message %s', filterData.id, prepared.id);

lib/schemas/request/filters-schemas.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ const FilterQuery = Joi.object({
3232
to: Joi.string().trim().max(255).empty('').description('Partial match for the To:/Cc: headers (case insensitive)'),
3333
subject: Joi.string().trim().max(255).empty('').description('Partial match for the Subject: header (case insensitive)'),
3434
listId: Joi.string().trim().max(255).empty('').description('Partial match for the List-ID: header (case insensitive)'),
35-
text: Joi.string().trim().max(255).empty('').description('Fulltext search against message text'),
35+
text: Joi.string()
36+
.trim()
37+
.max(255)
38+
.empty('')
39+
.description(
40+
'Fulltext search against message text. Implements boolean logic where terms like OR and AND are treated as boolean operators. Space and commas are to be treated as AND terms as there is no separate "AND" term. Supports exact matches enclosed in double quotes "exact match text".'
41+
)
42+
.example('urgent,immediate OR deadline OR meeting standup'),
3643
ha: booleanSchema.description('Does a message have to have an attachment or not'),
3744
size: Joi.number()
3845
.empty('')

lib/tools.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,81 @@ function buildCertChain(cert, ca) {
597597
.join('\n');
598598
}
599599

600+
function parseFilterQueryText(queryText) {
601+
if (!queryText || typeof queryText !== 'string') {
602+
return { andTerms: [], orTerms: [] };
603+
}
604+
605+
const normalized = queryText.trim();
606+
607+
const { cleanQuery, phrases: exactPhrases } = extractQuotedPhrases(normalized);
608+
609+
const replaceRegex = /^\s*OR\s*|\s*OR\s*$/g;
610+
611+
const orParts = cleanQuery.split(/\s+OR\s+/).map(part => (part ? part.replace(replaceRegex, '') : '')); // Has to be uppercase
612+
613+
const orTerms = [];
614+
const andTerms = [];
615+
616+
if (orParts.length === 1) {
617+
const terms = cleanQuery.split(/[,\s]+/).filter(term => term.length > 0);
618+
andTerms.push(...terms);
619+
} else {
620+
for (const part of orParts) {
621+
const trimmedPart = part.trim();
622+
if (trimmedPart) {
623+
orTerms.push(trimmedPart);
624+
}
625+
}
626+
}
627+
628+
return { andTerms, orTerms, exactPhrases };
629+
}
630+
631+
function filterQueryTermMatches(text, term, exactPhrases = []) {
632+
text = text.toLowerCase();
633+
const phraseMatch = term.match(/__PHRASE_(\d+)__/);
634+
635+
if (phraseMatch) {
636+
const phraseIndex = parseInt(phraseMatch[1], 10);
637+
const exactPhrase = exactPhrases[phraseIndex];
638+
639+
if (exactPhrase) {
640+
return text.includes(exactPhrase.trim().toLowerCase());
641+
}
642+
return false;
643+
}
644+
645+
term = term.toLowerCase();
646+
647+
if (term.includes(' ') || term.includes(',')) {
648+
const words = term.split(/[,\s]+/).filter(term => term.length > 0);
649+
return words.every(word => text.includes(word));
650+
}
651+
652+
return text.includes(term);
653+
}
654+
655+
function extractQuotedPhrases(query) {
656+
const phrases = [];
657+
let cleanQuery = query;
658+
659+
const quoteRegex = /"([^"]*)"/g;
660+
let match;
661+
let index = 0;
662+
663+
while ((match = quoteRegex.exec(query)) !== null) {
664+
const phrase = match[1].trim();
665+
if (phrase) {
666+
phrases.push(phrase);
667+
cleanQuery = cleanQuery.replace(match[0], `__PHRASE_${index}__`);
668+
index++;
669+
}
670+
}
671+
672+
return { cleanQuery, phrases };
673+
}
674+
600675
module.exports = {
601676
normalizeAddress,
602677
normalizeDomain,
@@ -622,6 +697,9 @@ module.exports = {
622697
roundTime,
623698
parsePemBundle,
624699
buildCertChain,
700+
parseFilterQueryText,
701+
filterQueryTermMatches,
702+
extractQuotedPhrases,
625703

626704
formatMetaData: metaData => {
627705
if (typeof metaData === 'string') {

0 commit comments

Comments
 (0)