wildduck/lib/filter-handler.js

749 lines
29 KiB
JavaScript
Raw Normal View History

2017-10-18 17:42:51 +08:00
'use strict';
const log = require('npmlog');
const ObjectID = require('mongodb').ObjectID;
const forward = require('./forward');
const autoreply = require('./autoreply');
2017-12-21 20:24:28 +08:00
const Maildropper = require('./maildropper');
2017-12-22 21:10:32 +08:00
const tools = require('./tools');
2017-12-27 21:22:48 +08:00
const consts = require('./consts');
2017-10-18 17:42:51 +08:00
const defaultSpamHeaderKeys = [
{
key: 'X-Spam-Status',
value: '^yes',
target: '\\Junk'
},
{
key: 'X-Rspamd-Spam',
value: '^yes',
target: '\\Junk'
},
2018-01-30 22:14:15 +08:00
/*
2017-10-25 20:58:00 +08:00
{
key: 'X-Rspamd-Bar',
value: '^\\+{6}',
target: '\\Junk'
},
2018-01-30 22:14:15 +08:00
*/
2017-10-18 17:42:51 +08:00
{
key: 'X-Haraka-Virus',
value: '.',
target: '\\Junk'
}
];
2018-01-30 22:14:15 +08:00
const spamScoreHeader = 'X-Rspamd-Score';
2018-09-13 21:00:40 +08:00
const spamScoreValue = 5.1; // everything over this value is spam, under ham
2018-01-30 22:14:15 +08:00
2017-10-18 17:42:51 +08:00
class FilterHandler {
constructor(options) {
2017-12-21 20:24:28 +08:00
this.db = options.db;
2017-10-18 17:42:51 +08:00
this.messageHandler = options.messageHandler;
2018-09-13 21:00:40 +08:00
this.spamScoreValue = options.spamScoreValue || spamScoreValue;
2017-12-22 21:10:32 +08:00
this.spamChecks = options.spamChecks || tools.prepareSpamChecks(defaultSpamHeaderKeys);
this.spamHeaderKeys = options.spamHeaderKeys || this.spamChecks.map(check => check.key);
2017-12-21 20:24:28 +08:00
this.maildrop = new Maildropper({
db: this.db,
zone: options.sender.zone,
collection: options.sender.collection,
gfs: options.sender.gfs
});
2017-10-18 17:42:51 +08:00
}
getUserData(address, callback) {
let query = {};
if (!address) {
return callback(null, false);
}
if (typeof address === 'object' && address._id) {
return callback(null, address);
}
let collection;
2018-04-29 03:44:38 +08:00
if (tools.isId(address)) {
2017-10-18 17:42:51 +08:00
query._id = new ObjectID(address);
collection = 'users';
} else if (typeof address !== 'string') {
return callback(null, false);
} else if (address.indexOf('@') >= 0) {
2018-05-11 19:39:23 +08:00
query.addrview = tools.uview(address);
2017-10-18 17:42:51 +08:00
collection = 'addresses';
} else {
query.unameview = address.replace(/\./g, '');
collection = 'users';
}
let fields = {
name: true,
forwards: true,
2018-01-21 03:38:56 +08:00
targets: true,
2017-10-18 17:42:51 +08:00
autoreply: true,
encryptMessages: true,
2017-10-30 19:41:53 +08:00
encryptForwarded: true,
2018-01-30 22:14:15 +08:00
pubKey: true,
2018-09-18 15:20:06 +08:00
spamLevel: true,
audit: true
2017-10-18 17:42:51 +08:00
};
if (collection === 'users') {
2017-12-21 20:24:28 +08:00
return this.db.users.collection('users').findOne(
2017-10-18 17:42:51 +08:00
query,
{
projection: fields
2017-10-18 17:42:51 +08:00
},
callback
);
}
2017-12-21 20:24:28 +08:00
return this.db.users.collection('addresses').findOne(query, (err, addressData) => {
2017-10-18 17:42:51 +08:00
if (err) {
return callback(err);
}
2017-12-27 19:32:57 +08:00
if (!addressData || !!addressData.user) {
2017-10-18 17:42:51 +08:00
return callback(null, false);
}
2017-12-21 20:24:28 +08:00
return this.db.users.collection('users').findOne(
{
_id: addressData.user
},
2017-10-18 17:42:51 +08:00
{
projection: fields
2017-10-18 17:42:51 +08:00
},
callback
);
});
}
process(options, callback) {
this.getUserData(options.user || options.recipient, (err, userData) => {
if (err) {
return callback(err);
}
if (!userData) {
return callback(null, false);
}
this.storeMessage(userData, options, callback);
});
}
storeMessage(userData, options, callback) {
let sender = options.sender || '';
let recipient = options.recipient || userData.address;
2018-09-21 17:44:07 +08:00
let filterResults = [];
2017-10-18 17:42:51 +08:00
// create Delivered-To and Return-Path headers
let extraHeader = Buffer.from(['Delivered-To: ' + recipient, 'Return-Path: <' + sender + '>'].join('\r\n') + '\r\n');
let chunks = options.chunks;
let chunklen = options.chunklen;
if (!chunks && options.raw) {
chunks = [options.raw];
chunklen = options.raw.length;
}
2017-11-10 21:04:58 +08:00
let getPreparedMessage = next => {
if (options.mimeTree) {
if (options.mimeTree && options.mimeTree.header) {
// remove old headers
if (/^Delivered-To/.test(options.mimeTree.header[0])) {
options.mimeTree.header.shift();
}
if (/^Return-Path/.test(options.mimeTree.header[0])) {
options.mimeTree.header.shift();
}
}
2017-11-10 21:04:58 +08:00
return this.messageHandler.prepareMessage(
{
mimeTree: options.mimeTree,
indexedHeaders: this.spamHeaderKeys
},
next
);
} else {
let raw = Buffer.concat(chunks, chunklen);
return this.messageHandler.prepareMessage(
{
raw,
indexedHeaders: this.spamHeaderKeys
},
next
);
}
2017-11-10 21:04:58 +08:00
};
2017-11-10 21:04:58 +08:00
getPreparedMessage((err, prepared) => {
if (err) {
return callback(err);
}
2017-11-10 21:04:58 +08:00
prepared.mimeTree.header.unshift('Return-Path: <' + sender + '>');
prepared.mimeTree.header.unshift('Delivered-To: ' + recipient);
2017-10-18 17:42:51 +08:00
2017-11-10 21:04:58 +08:00
prepared.mimeTree.parsedHeader['return-path'] = '<' + sender + '>';
prepared.mimeTree.parsedHeader['delivered-to'] = '<' + recipient + '>';
2017-10-18 17:42:51 +08:00
2017-11-10 21:04:58 +08:00
prepared.size = this.messageHandler.indexer.getSize(prepared.mimeTree);
2017-10-18 17:42:51 +08:00
2017-11-10 21:04:58 +08:00
let maildata = options.maildata || this.messageHandler.indexer.getMaildata(prepared.mimeTree);
2017-10-18 17:42:51 +08:00
2017-11-10 21:04:58 +08:00
// default flags are empty
let flags = [];
2017-10-18 17:42:51 +08:00
2017-11-10 21:04:58 +08:00
// default mailbox target is INBOX
let mailboxQueryKey = 'path';
let mailboxQueryValue = 'INBOX';
2017-10-27 16:50:37 +08:00
2017-11-10 21:04:58 +08:00
let meta = options.meta || {};
2017-10-27 16:50:37 +08:00
2017-11-10 21:04:58 +08:00
let received = [].concat((prepared.mimeTree.parsedHeader && prepared.mimeTree.parsedHeader.received) || []);
if (received.length) {
let receivedData = parseReceived(received[0]);
2017-10-27 16:50:37 +08:00
2017-11-10 21:04:58 +08:00
if (!receivedData.has('id') && received.length > 1) {
receivedData = parseReceived(received[1]);
}
2017-10-27 16:50:37 +08:00
2017-11-10 21:04:58 +08:00
if (receivedData.has('with')) {
meta.transtype = receivedData.get('with');
}
2017-10-27 16:50:37 +08:00
2017-11-10 21:04:58 +08:00
if (receivedData.has('id')) {
meta.queueId = receivedData.get('id');
}
2017-10-27 16:50:37 +08:00
2017-11-10 21:04:58 +08:00
if (receivedData.has('from')) {
meta.origin = receivedData.get('from');
2017-10-18 17:42:51 +08:00
}
2017-11-10 21:04:58 +08:00
}
2017-10-18 17:42:51 +08:00
2017-12-21 20:24:28 +08:00
this.db.database
2017-11-10 21:04:58 +08:00
.collection('filters')
.find({
user: userData._id
})
.sort({
_id: 1
})
.toArray((err, filters) => {
if (err) {
// ignore, as filtering is not so important
}
filters = (filters || []).filter(filter => !filter.disabled).concat(
2017-11-10 21:04:58 +08:00
this.spamChecks.map((check, i) => ({
id: 'SPAM#' + (i + 1),
query: {
headers: {
[check.key]: check.value
}
},
action: {
// only applies if any other filter does not already mark message as spam or ham
spam: true
2017-10-18 17:42:51 +08:00
}
2017-11-10 21:04:58 +08:00
}))
);
let isEncrypted = false;
let forwardTargets = new Map();
2017-12-22 21:10:32 +08:00
let matchingFilters = [];
let filterActions = new Map();
2018-01-30 22:14:15 +08:00
let spamScore = parseFloat([].concat(prepared.mimeTree.parsedHeader[spamScoreHeader.toLowerCase()] || []).shift(), 10) || 0;
2017-12-22 21:10:32 +08:00
filters
// apply all filters to the message
.map(filter => checkFilter(filter, prepared, maildata))
// remove all unmatched filters
.filter(filter => filter)
// apply filter actions
.forEach(filter => {
2018-10-19 15:45:07 +08:00
matchingFilters.push(filter.id || filter._id);
2017-12-22 21:10:32 +08:00
// apply matching filter
Object.keys(filter.action).forEach(key => {
2018-01-21 03:38:56 +08:00
if (key === 'targets') {
[].concat(filter.action[key] || []).forEach(target => {
forwardTargets.set(target.value, target);
2017-12-22 21:10:32 +08:00
});
return;
}
// if a previous filter already has set a value then do not touch it
if (!filterActions.has(key)) {
filterActions.set(key, filter.action[key]);
}
2017-11-10 21:04:58 +08:00
});
2017-12-22 21:10:32 +08:00
});
2017-10-18 17:42:51 +08:00
2018-09-20 18:10:10 +08:00
if (typeof userData.spamLevel === 'number' && userData.spamLevel >= 0) {
2018-01-30 22:14:15 +08:00
let isSpam;
2018-09-20 18:10:10 +08:00
2018-01-30 22:14:15 +08:00
if (userData.spamLevel === 0) {
2018-09-20 18:10:10 +08:00
// always mark as spam
2018-01-30 22:14:15 +08:00
isSpam = true;
} else if (userData.spamLevel === 100) {
2018-09-20 18:10:10 +08:00
// always mark as ham
2018-01-30 22:14:15 +08:00
isSpam = false;
2018-09-20 18:10:10 +08:00
filterActions.set('spam', false);
} else if (!filterActions.has('spam')) {
2018-09-13 21:00:40 +08:00
isSpam = (userData.spamLevel / 100) * this.spamScoreValue * 2 <= spamScore;
2018-01-30 22:14:15 +08:00
}
2018-09-20 18:10:10 +08:00
if (isSpam && !filterActions.has('spam')) {
// only update if spam decision is not yet made
2018-01-30 22:14:15 +08:00
filterActions.set('spam', true);
}
}
2017-12-22 21:10:32 +08:00
let encryptMessage = (condition, next) => {
if (!condition || isEncrypted) {
return next();
}
this.messageHandler.encryptMessage(
userData.pubKey,
{
chunks,
chunklen
},
(err, encrypted) => {
if (err) {
return next();
}
if (encrypted) {
chunks = [encrypted];
chunklen = encrypted.length;
isEncrypted = true;
return this.messageHandler.prepareMessage(
{
raw: Buffer.concat([extraHeader, encrypted]),
indexedHeaders: this.spamHeaderKeys
},
(err, preparedEncrypted) => {
if (err) {
return callback(err);
2017-11-10 21:04:58 +08:00
}
2017-12-22 21:10:32 +08:00
prepared = preparedEncrypted;
maildata = this.messageHandler.indexer.getMaildata(prepared.mimeTree);
next();
}
);
2017-10-30 19:41:53 +08:00
}
2017-11-10 21:04:58 +08:00
2017-12-22 21:10:32 +08:00
next();
2017-12-21 20:24:28 +08:00
}
2017-12-22 21:10:32 +08:00
);
};
let forwardMessage = done => {
2018-02-06 19:17:49 +08:00
if (!filterActions.get('delete')) {
2017-12-22 21:10:32 +08:00
// forward to default recipient only if the message is not deleted
2018-02-06 19:17:49 +08:00
if (userData.targets && userData.targets.length) {
userData.targets.forEach(target => {
forwardTargets.set(target.value, target);
});
} else if (options.targets && options.targets.length) {
// if user had no special targets, then use default ones provided by options
options.targets.forEach(target => {
forwardTargets.set(target.value, target);
});
}
2017-12-22 21:10:32 +08:00
}
// never forward messages marked as spam
if (!forwardTargets.size || filterActions.get('spam')) {
return setImmediate(done);
}
// check limiting counters
this.messageHandler.counters.ttlcounter(
'wdf:' + userData._id.toString(),
forwardTargets.size,
2017-12-27 21:22:48 +08:00
userData.forwards || consts.MAX_FORWARDS,
2017-12-22 21:10:32 +08:00
false,
(err, result) => {
if (err) {
// failed checks
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message);
} else if (!result.success) {
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed');
return done();
}
2017-12-21 20:24:28 +08:00
2017-12-22 21:10:32 +08:00
encryptMessage(userData.encryptForwarded && userData.pubKey, () => {
forward(
{
db: this.db,
maildrop: this.maildrop,
parentId: prepared.id,
userData,
sender,
recipient,
2018-01-04 18:15:08 +08:00
targets:
(forwardTargets.size &&
Array.from(forwardTargets).map(row => ({
type: row[1].type,
value: row[1].value
}))) ||
false,
2017-12-22 21:10:32 +08:00
chunks,
chunklen
},
done
);
2017-11-13 21:36:43 +08:00
});
2017-10-30 19:41:53 +08:00
}
2017-12-22 21:10:32 +08:00
);
};
let sendAutoreply = done => {
// never reply to messages marked as spam
if (!sender || !userData.autoreply || filterActions.get('spam')) {
return setImmediate(done);
}
2018-01-04 18:03:25 +08:00
let curtime = new Date();
2018-01-05 23:30:46 +08:00
this.db.database.collection('autoreplies').findOne(
2017-12-22 21:10:32 +08:00
{
2018-01-05 23:30:46 +08:00
user: userData._id
2017-12-22 21:10:32 +08:00
},
2018-01-04 18:03:25 +08:00
(err, autoreplyData) => {
if (err) {
2018-02-20 17:00:21 +08:00
return done(err);
2018-01-04 18:03:25 +08:00
}
if (!autoreplyData || !autoreplyData.status) {
2018-02-20 17:00:21 +08:00
return done(null, false);
2018-01-04 18:03:25 +08:00
}
2018-01-05 23:30:46 +08:00
if (autoreplyData.start && autoreplyData.start > curtime) {
2018-02-20 17:00:21 +08:00
return done(null, false);
2018-01-05 23:30:46 +08:00
}
if (autoreplyData.end && autoreplyData.end < curtime) {
2018-02-20 17:00:21 +08:00
return done(null, false);
2018-01-05 23:30:46 +08:00
}
2018-01-04 18:03:25 +08:00
autoreply(
{
db: this.db,
maildrop: this.maildrop,
parentId: prepared.id,
userData,
sender,
recipient,
chunks,
chunklen,
messageHandler: this.messageHandler
},
autoreplyData,
done
);
}
2017-12-22 21:10:32 +08:00
);
};
let outbound = [];
forwardMessage((err, id) => {
if (err) {
log.error(
'LMTP',
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
prepared.id.toString(),
sender,
recipient,
Array.from(forwardTargets)
.map(row => row[0])
.join(','),
err.message
2017-10-26 19:57:19 +08:00
);
2017-12-22 21:10:32 +08:00
} else if (id) {
2018-09-21 17:44:07 +08:00
filterResults.push({
forward: Array.from(forwardTargets)
.map(row => row[0])
.join(','),
'forward-queue-id': id
});
2017-12-22 21:10:32 +08:00
outbound.push(id);
log.silly(
'LMTP',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
prepared.id.toString(),
id,
sender,
recipient,
Array.from(forwardTargets)
.map(row => row[0])
.join(',')
2017-10-26 19:57:19 +08:00
);
2017-12-22 21:10:32 +08:00
}
2017-11-10 21:04:58 +08:00
2017-12-22 21:10:32 +08:00
sendAutoreply((err, id) => {
2017-10-26 19:57:19 +08:00
if (err) {
2017-12-22 21:10:32 +08:00
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
2017-10-26 19:57:19 +08:00
} else if (id) {
2018-09-21 17:44:07 +08:00
filterResults.push({ autoreply: sender, 'autoreply-queue-id': id });
2017-10-26 19:57:19 +08:00
outbound.push(id);
2017-12-22 21:10:32 +08:00
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
2017-10-26 19:57:19 +08:00
}
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
if (filterActions.get('delete')) {
// nothing to do with the message, just continue
let err = new Error('Message dropped by policy');
err.code = 'DroppedByPolicy';
2017-10-18 17:42:51 +08:00
2018-09-21 17:44:07 +08:00
filterResults.push({ delete: true });
2017-12-22 21:10:32 +08:00
return callback(null, {
userData,
response: 'Message dropped by policy as ' + prepared.id.toString(),
error: err
});
}
2017-12-21 21:13:05 +08:00
2017-12-22 21:10:32 +08:00
// apply filter results to the message
filterActions.forEach((value, key) => {
switch (key) {
case 'spam':
if (value > 0) {
// positive value is spam
mailboxQueryKey = 'specialUse';
mailboxQueryValue = '\\Junk';
2018-09-21 17:44:07 +08:00
filterResults.push({ spam: true });
2017-12-22 21:10:32 +08:00
}
break;
case 'seen':
if (value) {
flags.push('\\Seen');
2018-09-21 17:44:07 +08:00
filterResults.push({ seen: true });
2017-12-22 21:10:32 +08:00
}
break;
case 'flag':
if (value) {
flags.push('\\Flagged');
2018-09-21 17:44:07 +08:00
filterResults.push({ flagged: true });
2017-12-22 21:10:32 +08:00
}
break;
case 'mailbox':
if (value) {
// positive value is spam
mailboxQueryKey = 'mailbox';
mailboxQueryValue = value;
}
break;
2017-10-26 19:57:19 +08:00
}
2017-12-22 21:10:32 +08:00
});
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
let messageOpts = {
user: userData._id,
[mailboxQueryKey]: mailboxQueryValue,
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
prepared,
maildata,
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
meta,
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
filters: matchingFilters,
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
date: false,
flags,
2017-10-18 17:42:51 +08:00
2017-12-22 21:10:32 +08:00
// if similar message exists, then skip
skipExisting: true
};
2017-10-20 21:08:09 +08:00
2017-12-22 21:10:32 +08:00
if (outbound && outbound.length) {
messageOpts.outbound = [].concat(outbound || []);
}
2017-12-22 21:10:32 +08:00
if (forwardTargets.size) {
messageOpts.forwardTargets = Array.from(forwardTargets).map(row => ({
type: row[1].type,
value: row[1].value
}));
}
2017-10-27 20:45:51 +08:00
2017-12-22 21:10:32 +08:00
encryptMessage(userData.encryptMessages && userData.pubKey, () => {
if (isEncrypted) {
// make sure we have the updated message structure values
messageOpts.prepared = prepared;
messageOpts.maildata = maildata;
2018-09-21 17:44:07 +08:00
filterResults.push({ encrypted: true });
}
2017-10-30 19:41:53 +08:00
2017-12-22 21:10:32 +08:00
this.messageHandler.add(messageOpts, (err, inserted, info) => {
2018-09-21 17:44:07 +08:00
if (info) {
filterResults.push({ mailbox: info.mailbox && info.mailbox.toString(), id: info.id && info.id.toString() });
}
2018-10-19 15:15:16 +08:00
if (matchingFilters && matchingFilters.length) {
filterResults.push({
2018-10-19 15:45:07 +08:00
matchingFilters: matchingFilters.map(id => (id || '').toString())
2018-10-19 15:15:16 +08:00
});
}
2017-12-22 21:10:32 +08:00
// push to response list
callback(
null,
{
userData,
response: err ? err : 'Message stored as ' + info.id.toString(),
2018-09-21 17:44:07 +08:00
filterResults,
2017-12-22 21:10:32 +08:00
error: err
},
2018-01-04 18:15:08 +08:00
(!isEncrypted && {
// reuse parsed values
mimeTree: messageOpts.prepared.mimeTree,
maildata: messageOpts.maildata
}) ||
false
2017-12-22 21:10:32 +08:00
);
2017-10-30 19:41:53 +08:00
});
});
2017-10-26 19:57:19 +08:00
});
2017-10-18 17:42:51 +08:00
});
});
2017-11-10 21:04:58 +08:00
});
2017-10-18 17:42:51 +08:00
}
}
function checkFilter(filter, prepared, maildata) {
if (!filter || !filter.query) {
return false;
}
let query = filter.query;
// prepare filter data
let headerFilters = new Map();
if (query.headers) {
Object.keys(query.headers).forEach(key => {
let value = query.headers[key];
if (!value || !value.isRegex) {
value = (query.headers[key] || '').toString().toLowerCase();
}
2018-10-19 15:35:27 +08:00
if (value) {
headerFilters.set(key, value);
2018-10-19 00:07:13 +08:00
}
2017-10-18 17:42:51 +08:00
});
}
// check headers
if (headerFilters.size) {
let headerMatches = new Set();
for (let j = prepared.headers.length - 1; j >= 0; j--) {
let header = prepared.headers[j];
2018-10-19 15:35:27 +08:00
let key = header.key;
switch (key) {
case 'cc':
if (!headerFilters.get('cc')) {
// match cc against to query
key = 'to';
}
break;
}
if (headerFilters.has(key)) {
let check = headerFilters.get(key);
if (check.isRegex) {
if (check.test(header.value)) {
headerMatches.add(key);
}
} else if (header.value === check || header.value.indexOf(check) >= 0) {
headerMatches.add(key);
2017-10-18 17:42:51 +08:00
}
}
}
if (headerMatches.size < headerFilters.size) {
// not enough matches
return false;
}
}
if (typeof query.ha === 'boolean') {
let hasAttachments = maildata.attachments && maildata.attachments.length;
// false ha means no attachmens
if (hasAttachments && !query.ha) {
return false;
}
// true ha means attachmens must exist
if (!hasAttachments && query.ha) {
return false;
}
}
if (query.size) {
let messageSize = prepared.size;
let filterSize = Math.abs(query.size);
// negative value means "less than", positive means "more than"
if (query.size < 0 && messageSize > filterSize) {
return false;
}
if (query.size > 0 && messageSize < filterSize) {
return false;
}
}
if (
query.text &&
maildata.text
.toLowerCase()
.replace(/\s+/g, ' ')
.indexOf(query.text.toLowerCase()) < 0
) {
// message plaintext does not match the text field value
return false;
}
log.silly('Filter', 'Filter %s matched message %s', filter.id, prepared.id);
// we reached the end of the filter, so this means we have a match
return filter;
}
module.exports = FilterHandler;
function parseReceived(str) {
let result = new Map();
str.trim()
.replace(/[\r\n\s\t]+/g, ' ')
.trim()
.replace(/(^|\s+)(from|by|with|id|for)\s+([^\s]+)/gi, (m, p, k, v) => {
let key = k.toLowerCase();
let value = v;
if (!result.has(key)) {
result.set(key, value);
}
});
let date = str
.split(';')
.pop()
.trim();
if (date) {
date = new Date(date);
if (date.getTime()) {
result.set('date', date);
}
}
return result;
}