mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-11-10 17:47:07 +08:00
589 lines
22 KiB
JavaScript
589 lines
22 KiB
JavaScript
'use strict';
|
|
|
|
const SearchString = require('search-string');
|
|
const parser = require('logic-query-parser');
|
|
const { escapeRegexStr } = require('./tools');
|
|
const { ObjectId } = require('mongodb');
|
|
|
|
function parseSearchQuery(queryStr) {
|
|
const queryTree = parser.parse(queryStr);
|
|
|
|
let result = [];
|
|
|
|
let walkQueryTree = (node, branch, opts) => {
|
|
switch (node.lexeme && node.lexeme.type) {
|
|
case 'and': {
|
|
let leafNode;
|
|
if (opts.condType === 'and') {
|
|
leafNode = branch;
|
|
} else {
|
|
let node = { $and: [] };
|
|
branch.push(node);
|
|
leafNode = node.$and;
|
|
}
|
|
|
|
if (node.left) {
|
|
if (
|
|
node.left?.lexeme?.type === 'string' &&
|
|
typeof node.left.lexeme.value === 'string' &&
|
|
node.left.lexeme.value.length > 1 &&
|
|
node.left.lexeme.value.at(-1) === ':' &&
|
|
node.right?.lexeme?.type === 'and' &&
|
|
node.right.left?.lexeme?.type === 'string' &&
|
|
node.right.left.lexeme.value
|
|
) {
|
|
//
|
|
node.left.lexeme.value += `"${node.right.left.lexeme.value}"`;
|
|
node.right = node.right.right;
|
|
}
|
|
|
|
walkQueryTree(
|
|
node.left,
|
|
leafNode,
|
|
Object.assign({}, opts, {
|
|
condType: 'and'
|
|
})
|
|
);
|
|
}
|
|
|
|
if (node.right) {
|
|
walkQueryTree(
|
|
node.right,
|
|
leafNode,
|
|
Object.assign({}, opts, {
|
|
condType: 'and'
|
|
})
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
case 'or': {
|
|
let leafNode;
|
|
if (opts.condType === 'or') {
|
|
leafNode = branch;
|
|
} else {
|
|
let node = { $or: [] };
|
|
branch.push(node);
|
|
leafNode = node.$or;
|
|
}
|
|
|
|
if (node.left) {
|
|
walkQueryTree(
|
|
node.left,
|
|
leafNode,
|
|
Object.assign({}, opts, {
|
|
condType: 'or'
|
|
})
|
|
);
|
|
}
|
|
if (node.right) {
|
|
walkQueryTree(
|
|
node.right,
|
|
leafNode,
|
|
Object.assign({}, opts, {
|
|
condType: 'or'
|
|
})
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
case 'string':
|
|
{
|
|
const searchString = SearchString.parse(`${opts.negated ? '-' : ''}${node.lexeme.value}`);
|
|
let parsedQuery = searchString.getParsedQuery();
|
|
|
|
node.parsed = { searchString, parsedQuery };
|
|
|
|
let keywords = {};
|
|
if (parsedQuery) {
|
|
for (let key of Object.keys(parsedQuery)) {
|
|
if (key === 'exclude') {
|
|
for (let subKey of Object.keys(parsedQuery[key])) {
|
|
keywords[subKey] = { value: parsedQuery[key][subKey].flatMap(entry => entry).shift(), negated: true };
|
|
}
|
|
} else if (Array.isArray(parsedQuery[key])) {
|
|
keywords[key] = { value: parsedQuery[key].flatMap(entry => entry).shift(), negated: false };
|
|
}
|
|
}
|
|
}
|
|
|
|
let negated = opts.negated;
|
|
|
|
let textValue =
|
|
searchString
|
|
.getTextSegments()
|
|
.flatMap(entry => {
|
|
negated = entry.negated ? !opts.negated : !!opts.negated;
|
|
|
|
return entry.text;
|
|
})
|
|
.join(' ') || null;
|
|
|
|
const leafNode = {
|
|
text: textValue ? { value: textValue, negated } : null,
|
|
keywords: Object.keys(keywords).length ? keywords : null,
|
|
value: node.lexeme.value
|
|
};
|
|
branch.push(leafNode);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
walkQueryTree(queryTree, result, { condType: 'and' });
|
|
|
|
return result;
|
|
}
|
|
|
|
const getMongoDBQuery = async (db, user, queryStr) => {
|
|
const parsed = parseSearchQuery(queryStr);
|
|
|
|
let hasTextFilter = false;
|
|
|
|
let walkTree = async node => {
|
|
if (Array.isArray(node)) {
|
|
let branches = [];
|
|
for (let entry of node) {
|
|
branches.push(await walkTree(entry));
|
|
}
|
|
return branches;
|
|
}
|
|
|
|
if (node.$and && node.$and.length) {
|
|
let branch = {
|
|
$and: []
|
|
};
|
|
|
|
for (let entry of node.$and) {
|
|
let subBranch = await walkTree(entry);
|
|
branch.$and = branch.$and.concat(subBranch || []);
|
|
}
|
|
|
|
return branch;
|
|
} else if (node.$or && node.$or.length) {
|
|
let branch = {
|
|
$or: []
|
|
};
|
|
|
|
for (let entry of node.$or) {
|
|
let subBranch = await walkTree(entry);
|
|
|
|
branch.$or = branch.$or.concat(subBranch || []);
|
|
}
|
|
|
|
return branch;
|
|
} else if (node.text) {
|
|
let branch = {
|
|
$text: {
|
|
$search: node.text.value
|
|
}
|
|
};
|
|
|
|
if (node.text.negated) {
|
|
branch = { $not: branch };
|
|
}
|
|
|
|
hasTextFilter = true;
|
|
|
|
return branch;
|
|
} else if (node.keywords) {
|
|
let branches = [];
|
|
|
|
let keyword = Object.keys(node.keywords || {}).find(key => key && key !== 'negated');
|
|
if (keyword) {
|
|
let { value, negated } = node.keywords[keyword];
|
|
switch (keyword) {
|
|
case 'from':
|
|
case 'subject':
|
|
{
|
|
let regex = escapeRegexStr(value);
|
|
let branch = {
|
|
headers: {
|
|
$elemMatch: {
|
|
key: keyword,
|
|
value: {
|
|
$regex: regex,
|
|
$options: 'i'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if (negated) {
|
|
branch = { $not: branch };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
break;
|
|
|
|
case 'to':
|
|
{
|
|
let regex = escapeRegexStr(value);
|
|
for (let toKey of ['to', 'cc', 'bcc']) {
|
|
let branch = {
|
|
headers: {
|
|
$elemMatch: {
|
|
key: toKey,
|
|
value: {
|
|
$regex: regex,
|
|
$options: 'i'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if (negated) {
|
|
branch = { $not: branch };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'in': {
|
|
value = (value || '').toString().trim();
|
|
let resolveQuery = { user, $or: [] };
|
|
if (/^[0-9a-f]{24}$/i.test(value)) {
|
|
resolveQuery.$or.push({ _id: new ObjectId(value) });
|
|
} else if (/^Inbox$/i.test(value)) {
|
|
resolveQuery.$or.push({ path: 'INBOX' });
|
|
} else {
|
|
resolveQuery.$or.push({ path: value });
|
|
if (/^\/?(spam|junk)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Junk' });
|
|
} else if (/^\/?(sent)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Sent' });
|
|
} else if (/^\/?(trash|deleted)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Trash' });
|
|
} else if (/^\/?(drafts)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Drafts' });
|
|
}
|
|
}
|
|
|
|
let mailboxEntry = await db.database.collection('mailboxes').findOne(resolveQuery, { project: { _id: -1 } });
|
|
|
|
let branch = { mailbox: mailboxEntry ? mailboxEntry._id : new ObjectId('0'.repeat(24)) };
|
|
if (negated) {
|
|
branch = { $not: branch };
|
|
}
|
|
branches.push(branch);
|
|
|
|
break;
|
|
}
|
|
|
|
case 'thread':
|
|
{
|
|
value = (value || '').toString().trim();
|
|
if (/^[0-9a-f]{24}$/i.test(value)) {
|
|
let branch = { thread: new ObjectId(value) };
|
|
if (negated) {
|
|
branch = { $not: branch };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'has': {
|
|
switch (value) {
|
|
case 'attachment': {
|
|
branches.push({ ha: true });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return branches;
|
|
}
|
|
};
|
|
|
|
if (parsed && parsed.length) {
|
|
let filter = await walkTree(Array.isArray(parsed) ? { $and: parsed } : parsed);
|
|
|
|
let extras = { user };
|
|
if (hasTextFilter) {
|
|
extras.searchable = true;
|
|
}
|
|
|
|
return Object.assign({ user: null }, filter, extras);
|
|
}
|
|
|
|
return { user: false };
|
|
};
|
|
|
|
const getElasticSearchQuery = async (db, user, queryStr) => {
|
|
const parsed = parseSearchQuery(queryStr);
|
|
|
|
let searchQuery = {
|
|
bool: {
|
|
must: [
|
|
{
|
|
term: {
|
|
user: (user || '').toString().trim()
|
|
}
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
let walkTree = async node => {
|
|
if (Array.isArray(node)) {
|
|
let branches = [];
|
|
for (let entry of node) {
|
|
branches.push(await walkTree(entry));
|
|
}
|
|
return branches;
|
|
}
|
|
|
|
if (node.$and && node.$and.length) {
|
|
let branch = {
|
|
bool: { must: [] }
|
|
};
|
|
|
|
for (let entry of node.$and) {
|
|
let subBranch = await walkTree(entry);
|
|
branch.bool.must = branch.bool.must.concat(subBranch || []);
|
|
}
|
|
|
|
return branch;
|
|
} else if (node.$or && node.$or.length) {
|
|
let branch = {
|
|
bool: { should: [], minimum_should_match: 1 }
|
|
};
|
|
|
|
for (let entry of node.$or) {
|
|
let subBranch = await walkTree(entry);
|
|
|
|
branch.bool.should = branch.bool.should.concat(subBranch || []);
|
|
}
|
|
|
|
return branch;
|
|
} else if (node.text) {
|
|
let branch = {
|
|
bool: {
|
|
should: [
|
|
{
|
|
match: {
|
|
subject: {
|
|
query: node.text.value,
|
|
operator: 'and'
|
|
}
|
|
}
|
|
},
|
|
{
|
|
match: {
|
|
text: {
|
|
query: node.text.value,
|
|
operator: 'and'
|
|
}
|
|
}
|
|
},
|
|
{
|
|
match: {
|
|
html: {
|
|
query: node.text.value,
|
|
operator: 'and'
|
|
}
|
|
}
|
|
}
|
|
],
|
|
minimum_should_match: 1
|
|
}
|
|
};
|
|
|
|
if (node.text.negated) {
|
|
branch = { bool: { must_not: branch.bool.should } };
|
|
}
|
|
|
|
return branch;
|
|
} else if (node.keywords) {
|
|
let branches = [];
|
|
|
|
let keyword = Object.keys(node.keywords || {}).find(key => key && key !== 'negated');
|
|
if (keyword) {
|
|
let { value, negated } = node.keywords[keyword];
|
|
switch (keyword) {
|
|
case 'subject':
|
|
{
|
|
let branch = {
|
|
match: {
|
|
subject: {
|
|
query: value,
|
|
operator: 'and'
|
|
}
|
|
}
|
|
};
|
|
if (negated) {
|
|
branch = { bool: { must_not: branch } };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
break;
|
|
|
|
case 'from':
|
|
{
|
|
let branch = {
|
|
bool: {
|
|
should: [
|
|
{
|
|
match: {
|
|
[`from.name`]: {
|
|
query: value,
|
|
operator: 'and'
|
|
}
|
|
}
|
|
},
|
|
{
|
|
term: {
|
|
[`from.address`]: value
|
|
}
|
|
}
|
|
],
|
|
minimum_should_match: 1
|
|
}
|
|
};
|
|
if (negated) {
|
|
branch = { bool: { must_not: branch } };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
break;
|
|
|
|
case 'to':
|
|
{
|
|
let branch = {
|
|
bool: {
|
|
should: [],
|
|
minimum_should_match: 1
|
|
}
|
|
};
|
|
|
|
for (let toKey of ['to', 'cc', 'bcc']) {
|
|
branch.bool.should.push(
|
|
{
|
|
match: {
|
|
[`${toKey}.name`]: {
|
|
query: value,
|
|
operator: 'and'
|
|
}
|
|
}
|
|
},
|
|
{
|
|
term: {
|
|
[`${toKey}.address`]: value
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
if (negated) {
|
|
branch = { bool: { must_not: branch } };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
break;
|
|
|
|
case 'in': {
|
|
value = (value || '').toString().trim();
|
|
let resolveQuery = { user, $or: [] };
|
|
if (/^[0-9a-f]{24}$/i.test(value)) {
|
|
resolveQuery.$or.push({ _id: new ObjectId(value) });
|
|
} else if (/^Inbox$/i.test(value)) {
|
|
resolveQuery.$or.push({ path: 'INBOX' });
|
|
} else {
|
|
resolveQuery.$or.push({ path: value });
|
|
if (/^\/?(spam|junk)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Junk' });
|
|
} else if (/^\/?(sent)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Sent' });
|
|
} else if (/^\/?(trash|deleted)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Trash' });
|
|
} else if (/^\/?(drafts)/i.test(value)) {
|
|
resolveQuery.$or.push({ specialUse: '\\Drafts' });
|
|
}
|
|
}
|
|
|
|
let mailboxEntry = await db.database.collection('mailboxes').findOne(resolveQuery, { project: { _id: -1 } });
|
|
|
|
let branch = { term: { mailbox: (mailboxEntry ? mailboxEntry._id : new ObjectId('0'.repeat(24))).toString() } };
|
|
if (negated) {
|
|
branch = { bool: { must_not: [branch] } };
|
|
}
|
|
branches.push(branch);
|
|
|
|
break;
|
|
}
|
|
|
|
case 'thread':
|
|
{
|
|
value = (value || '').toString().trim();
|
|
if (/^[0-9a-f]{24}$/i.test(value)) {
|
|
let branch = { term: { thread: value } };
|
|
if (negated) {
|
|
branch = { bool: { must_not: [branch] } };
|
|
}
|
|
if (negated) {
|
|
branch = { bool: { must_not: [branch] } };
|
|
}
|
|
branches.push(branch);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'has': {
|
|
switch (value) {
|
|
case 'attachment': {
|
|
branches.push({ term: { ha: true } });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return branches;
|
|
}
|
|
};
|
|
|
|
if (parsed && parsed.length) {
|
|
let filter = await walkTree({ $and: parsed });
|
|
searchQuery.bool.must = searchQuery.bool.must.concat(filter);
|
|
}
|
|
|
|
return searchQuery;
|
|
};
|
|
|
|
module.exports = { parseSearchQuery, getMongoDBQuery, getElasticSearchQuery };
|
|
/*
|
|
if (process.env.DEBUG_TEST_QUERY && process.env.NODE_ENV !== 'production') {
|
|
const util = require('util'); // eslint-disable-line
|
|
let main = () => {
|
|
let db = require('./db'); // eslint-disable-line
|
|
db.connect(() => {
|
|
let run = async () => {
|
|
let queries = ['from:"amy namy" kupi in:spam to:greg has:attachment -subject:"dinner and movie tonight" (jupi OR subject:tere)'];
|
|
|
|
for (let query of queries) {
|
|
console.log('PARSED QUERY');
|
|
console.log(util.inspect({ query, parsed: parseSearchQuery(query) }, false, 22, true));
|
|
console.log('MongoDB');
|
|
console.log(util.inspect({ query, filter: await getMongoDBQuery(db, new ObjectId('64099fff101ca2ef6aad8be7'), query) }, false, 22, true));
|
|
console.log('ElasticSearch');
|
|
console.log(
|
|
util.inspect({ query, filter: await getElasticSearchQuery(db, new ObjectId('64099fff101ca2ef6aad8be7'), query) }, false, 22, true)
|
|
);
|
|
}
|
|
};
|
|
|
|
run()
|
|
.catch(err => console.error(err))
|
|
.finally(() => process.exit());
|
|
});
|
|
};
|
|
main();
|
|
}
|
|
*/
|