wildduck/lib/filter-handler.js
Lauri Anteploon 203bbf6cfd Moved "greylist" and "soft reject" to same case block.
In Rspamd "greylist" and "soft reject" actions are synonymous.
Which one is emitted depends on the greylisting module configuration.
2020-11-11 10:43:21 +00:00

846 lines
28 KiB
JavaScript

'use strict';
const log = require('npmlog');
const ObjectID = require('mongodb').ObjectID;
const forward = require('./forward');
const autoreply = require('./autoreply');
const Maildropper = require('./maildropper');
const tools = require('./tools');
const consts = require('./consts');
const util = require('util');
class FilterHandler {
constructor(options) {
this.db = options.db;
this.messageHandler = options.messageHandler;
this.prepareMessage = util.promisify(this.messageHandler.prepareMessage.bind(this.messageHandler));
this.encryptMessage = util.promisify(this.messageHandler.encryptMessage.bind(this.messageHandler));
this.addMessage = util.promisify((...args) => {
let callback = args.pop();
this.messageHandler.add(...args, (err, status, data) => {
if (err) {
return callback(err);
}
return callback(null, { status, data });
});
});
this.ttlcounter = util.promisify(this.messageHandler.counters.ttlcounter.bind(this.messageHandler.counters));
this.forward = util.promisify(forward);
this.autoreply = util.promisify(autoreply);
this.maildrop = new Maildropper({
db: this.db,
zone: options.sender.zone,
collection: options.sender.collection,
gfs: options.sender.gfs,
loopSecret: options.sender.loopSecret
});
this.loggelf = options.loggelf || (() => false);
}
getUserData(address, callback) {
let query = {};
if (!address) {
return callback(null, false);
}
if (typeof address === 'object' && address._id) {
return callback(null, address);
}
let collection;
if (tools.isId(address)) {
query._id = new ObjectID(address);
collection = 'users';
} else if (typeof address !== 'string') {
return callback(null, false);
} else if (address.indexOf('@') >= 0) {
query.addrview = tools.uview(address);
collection = 'addresses';
} else {
query.unameview = address.replace(/\./g, '');
collection = 'users';
}
let fields = {
name: true,
forwards: true,
targets: true,
autoreply: true,
encryptMessages: true,
encryptForwarded: true,
pubKey: true,
spamLevel: true,
tagsview: true
};
if (collection === 'users') {
return this.db.users.collection('users').findOne(
query,
{
projection: fields
},
callback
);
}
return this.db.users.collection('addresses').findOne(query, (err, addressData) => {
if (err) {
return callback(err);
}
if (!addressData || !!addressData.user) {
return callback(null, false);
}
return this.db.users.collection('users').findOne(
{
_id: addressData.user
},
{
projection: fields
},
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)
.then(status => callback(null, status.response, status.prepared))
.catch(callback);
});
}
async storeMessage(userData, options) {
let sender = options.sender || '';
let recipient = options.recipient || userData.address;
let filterResults = [];
// 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;
}
let rawchunks = chunks;
let prepared;
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();
}
}
prepared = await this.prepareMessage({
mimeTree: options.mimeTree
});
} else {
let raw = Buffer.concat(chunks, chunklen);
prepared = await this.prepareMessage({
raw
});
}
prepared.mimeTree.header.unshift('Return-Path: <' + sender + '>');
prepared.mimeTree.header.unshift('Delivered-To: ' + recipient);
prepared.mimeTree.parsedHeader['return-path'] = '<' + sender + '>';
prepared.mimeTree.parsedHeader['delivered-to'] = '<' + recipient + '>';
prepared.size = this.messageHandler.indexer.getSize(prepared.mimeTree);
let maildata = options.maildata || this.messageHandler.indexer.getMaildata(prepared.mimeTree);
// default flags are empty
let flags = [];
// default mailbox target is INBOX
let mailboxQueryKey = 'path';
let mailboxQueryValue = 'INBOX';
let meta = options.meta || {};
let parsedHeader = (prepared.mimeTree.parsedHeader && prepared.mimeTree.parsedHeader) || {};
let received = [].concat(parsedHeader.received || []);
if (received.length) {
let receivedData = parseReceived(received[0]);
if (!receivedData.has('id') && received.length > 1) {
receivedData = parseReceived(received[1]);
}
if (receivedData.has('with')) {
meta.transtype = receivedData.get('with');
}
if (receivedData.has('id')) {
meta.queueId = receivedData.get('id');
}
if (receivedData.has('from')) {
meta.origin = receivedData.get('from');
}
}
let filters = [];
try {
filters = await this.db.database
.collection('filters')
.find({
user: userData._id,
disabled: { $ne: true }
})
.sort({
_id: 1
})
.toArray();
} catch (err) {
// ignore as filters are not so importand
}
let isEncrypted = false;
let forwardTargets = new Map();
let matchingFilters = [];
let filterActions = new Map();
// check global whitelist/blacklist before filters
if (userData.tagsview && userData.tagsview.length) {
let from = parsedHeader.from || parsedHeader.sender;
from = [].concat(from || []);
tools.decodeAddresses(from);
from = tools.flatAddresses(from);
if (from && from.length) {
from = from[0];
let domain = tools.normalizeDomain(from.address.split('@').pop());
try {
let domainaccessData = await this.db.database.collection('domainaccess').findOne({
tag: { $in: userData.tagsview },
domain
});
if (domainaccessData) {
switch (domainaccessData.action) {
case 'block':
filterActions.set('spam', true);
matchingFilters.push(`block:${domainaccessData.tag}:${domainaccessData._id}`);
break;
case 'allow':
filterActions.set('spam', false);
matchingFilters.push(`allow:${domainaccessData.tag}:${domainaccessData._id}`);
break;
}
}
} catch (err) {
// ignore, not important
}
}
}
for (let filterData of filters) {
if (!(await checkFilter(filterData, prepared, maildata))) {
continue;
}
matchingFilters.push(filterData.id || filterData._id);
// apply matching filter
Object.keys(filterData.action).forEach(key => {
if (key === 'targets') {
[].concat(filterData.action[key] || []).forEach(target => {
forwardTargets.set(target.value, target);
});
return;
}
// if a previous filter already has set a value then do not touch it
if (!filterActions.has(key)) {
filterActions.set(key, filterData.action[key]);
}
});
}
if (typeof userData.spamLevel === 'number' && userData.spamLevel >= 0) {
let isSpam;
if (userData.spamLevel === 0) {
// always mark as spam
isSpam = true;
} else if (userData.spamLevel === 100) {
// always mark as ham
isSpam = false;
filterActions.set('spam', false);
} else if (!filterActions.has('spam')) {
let spamScore;
switch (meta.spamAction) {
case 'reject':
spamScore = 75;
break;
case 'rewrite subject':
case 'soft reject':
case 'greylist':
spamScore = 50;
break;
case 'add header':
spamScore = 25;
break;
case 'no action':
default:
spamScore = 0;
break;
}
isSpam = spamScore >= userData.spamLevel;
}
if (isSpam && !filterActions.has('spam')) {
// only update if spam decision is not yet made
filterActions.set('spam', true);
}
}
let encryptMessage = async () => {
if (isEncrypted) {
return;
}
let encrypted = await this.encryptMessage(userData.pubKey, {
chunks,
chunklen
});
if (encrypted) {
chunks = [encrypted];
chunklen = encrypted.length;
isEncrypted = true;
prepared = await this.prepareMessage({
raw: Buffer.concat([extraHeader, encrypted])
});
maildata = this.messageHandler.indexer.getMaildata(prepared.mimeTree);
}
};
let forwardMessage = async () => {
if (!filterActions.get('delete')) {
// forward to default recipient only if the message is not deleted
if (userData.targets && userData.targets.length) {
userData.targets.forEach(targetData => {
let key = targetData.value;
if (targetData.type === 'relay') {
targetData.recipient = userData.address;
key = `${targetData.recipient}:${targetData.value}`;
}
forwardTargets.set(key, targetData);
});
} else if (options.targets && options.targets.length) {
// if user had no special targets, then use default ones provided by options
options.targets.forEach(targetData => {
let key = targetData.value;
if (targetData.type === 'relay') {
targetData.recipient = userData.address;
key = `${targetData.recipient}:${targetData.value}`;
}
forwardTargets.set(key, targetData);
});
}
}
// never forward messages marked as spam
if (!forwardTargets.size) {
return false;
}
const targets = Array.from(forwardTargets).map(row => ({
type: row[1].type,
value: row[1].value,
recipient
}));
const logdata = {
_user: userData._id.toString(),
_mail_action: 'forward',
_sender: sender,
_recipient: recipient,
_target_address: (targets || []).map(target => ((target && target.value) || target).toString().replace(/\?.*$/, '')).join('\n'),
_message_id: prepared.mimeTree.parsedHeader['message-id']
};
if (filterActions.get('spam')) {
logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to spam';
logdata._error = 'Skipped forwarding due to spam';
logdata._code = 'ESPAM';
this.loggelf(logdata);
return;
}
// check limiting counters
try {
let counterResult = await this.ttlcounter(
'wdf:' + userData._id.toString(),
forwardTargets.size,
userData.forwards || consts.MAX_FORWARDS,
false
);
if (!counterResult.success) {
log.silly('Filter', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed');
logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to rate limiting';
logdata._error = 'Skipped forwarding due to rate limiting';
logdata._code = 'ERATELIMIT';
this.loggelf(logdata);
return false;
}
} catch (err) {
// failed checks, ignore
log.info('Filter', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message);
logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to database error';
logdata._error = err.message;
logdata._code = err.code;
this.loggelf(logdata);
}
if (userData.encryptForwarded && userData.pubKey) {
await encryptMessage();
}
try {
let forwardResponse = await this.forward({
db: this.db,
maildrop: this.maildrop,
parentId: prepared.id,
userData,
sender,
recipient,
targets,
chunks,
chunklen
});
if (forwardResponse) {
logdata.short_message = '[FRWRDOK] Scheduled forwarding';
logdata._target_queue_id = forwardResponse;
this.loggelf(logdata);
}
return forwardResponse;
} catch (err) {
logdata.short_message = '[FRWRDFAIL] Skipped forwarding due to queueing error';
logdata._error = err.message;
logdata._code = err.code;
this.loggelf(logdata);
}
};
let sendAutoreply = async () => {
// never reply to messages marked as spam
if (!sender || !userData.autoreply || filterActions.get('spam') || options.disableAutoreply) {
return;
}
let curtime = new Date();
let autoreplyData = await this.db.database.collection('autoreplies').findOne({
user: userData._id
});
if (!autoreplyData || !autoreplyData.status) {
return false;
}
if (autoreplyData.start && autoreplyData.start > curtime) {
return false;
}
if (autoreplyData.end && autoreplyData.end < curtime) {
return false;
}
let autoreplyResponse = await this.autoreply(
{
db: this.db,
maildrop: this.maildrop,
parentId: prepared.id,
userData,
sender,
recipient,
chunks,
chunklen,
messageHandler: this.messageHandler
},
autoreplyData
);
return autoreplyResponse;
};
let outbound = [];
try {
let forwardId = await forwardMessage();
if (forwardId) {
filterResults.push({
forward: Array.from(forwardTargets)
.map(row => row[0])
.join(','),
'forward-queue-id': forwardId
});
outbound.push(forwardId);
log.silly(
'Filter',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
prepared.id.toString(),
forwardId,
sender,
recipient,
Array.from(forwardTargets)
.map(row => row[0])
.join(',')
);
}
} catch (err) {
log.error(
'Filter',
'%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
);
}
try {
let autoreplyId = await sendAutoreply();
if (autoreplyId) {
filterResults.push({ autoreply: sender, 'autoreply-queue-id': autoreplyId });
outbound.push(autoreplyId);
log.silly('Filter', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), autoreplyId, '<>', sender);
}
} catch (err) {
log.error('Filter', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
}
if (filterActions.get('delete')) {
// nothing to do with the message, just continue
let err = new Error(`Message dropped by policy [${matchingFilters.map(id => (id || '').toString()).join(':')}]`);
err.code = 'DroppedByPolicy';
filterResults.push({ delete: true });
try {
let audits = await this.db.database
.collection('audits')
.find({ user: userData._id, expires: { $gt: new Date() } })
.toArray();
let now = new Date();
for (let auditData of audits) {
if ((auditData.start && auditData.start > now) || (auditData.end && auditData.end < now)) {
// audit not active
continue;
}
await this.auditHandler.store(auditData._id, rawchunks, {
date: prepared.idate || new Date(),
msgid: prepared.msgid,
header: prepared.mimeTree && prepared.mimeTree.parsedHeader,
ha: prepared.ha,
info: Object.assign({ notStored: true }, meta || {})
});
}
} catch (err) {
log.error('Filter', '%s AUDITFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
}
return {
response: {
userData,
response: 'Message dropped by policy as ' + prepared.id.toString(),
error: err
}
};
}
// 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';
filterResults.push({ spam: true });
}
break;
case 'seen':
if (value) {
flags.push('\\Seen');
filterResults.push({ seen: true });
}
break;
case 'flag':
if (value) {
flags.push('\\Flagged');
filterResults.push({ flagged: true });
}
break;
case 'mailbox':
if (value) {
// positive value is spam
mailboxQueryKey = 'mailbox';
mailboxQueryValue = value;
}
break;
}
});
let messageOpts = {
user: userData._id,
[mailboxQueryKey]: mailboxQueryValue,
inboxDefault: true, // if mailbox is not found, then store to INBOX
prepared,
maildata,
meta,
filters: matchingFilters,
date: false,
flags,
rawchunks
};
if (options.verificationResults) {
messageOpts.verificationResults = options.verificationResults;
}
if (outbound && outbound.length) {
messageOpts.outbound = [].concat(outbound || []);
}
if (forwardTargets.size) {
messageOpts.forwardTargets = Array.from(forwardTargets).map(row => ({
type: row[1].type,
value: row[1].value
}));
}
if (userData.encryptMessages && userData.pubKey) {
await encryptMessage();
if (isEncrypted) {
// make sure we have the updated message structure values
messageOpts.prepared = prepared;
messageOpts.maildata = maildata;
filterResults.push({ encrypted: true });
}
}
if (matchingFilters && matchingFilters.length) {
filterResults.push({
matchingFilters: matchingFilters.map(id => (id || '').toString())
});
}
try {
let { data } = await this.addMessage(messageOpts);
if (data) {
filterResults.push({
mailbox: data.mailbox && data.mailbox.toString(),
path: data.mailboxPath,
uid: data.uid,
id: data.id && data.id.toString()
});
return {
response: {
userData,
response: 'Message stored as ' + data.id.toString(),
filterResults,
attachments: (maildata && maildata.attachments) || []
},
prepared:
(!isEncrypted && {
// reuse parsed values
mimeTree: messageOpts.prepared.mimeTree,
maildata: messageOpts.maildata
}) ||
false
};
}
} catch (err) {
return {
response: {
userData,
response: err,
filterResults,
attachments: (maildata && maildata.attachments) || [],
error: err
},
prepared:
(!isEncrypted && {
// reuse parsed values
mimeTree: messageOpts.prepared.mimeTree,
maildata: messageOpts.maildata
}) ||
false
};
}
}
}
async function checkFilter(filterData, prepared, maildata) {
if (!filterData || !filterData.query) {
return false;
}
let query = filterData.query;
// prepare filter data
let headerFilters = new Map();
if (query.headers) {
Object.keys(query.headers).forEach(key => {
let header = key.replace(/[A-Z]+/g, c => '-' + c.toLowerCase());
let value = query.headers[key];
if (!value || !value.isRegex) {
value = (query.headers[key] || '').toString().toLowerCase();
}
if (value) {
if (header === 'list-id' && typeof value === 'string' && value.indexOf('<') >= 0) {
// only check actual ID part of the List-ID header
let m = value.match(/<([^>]+)/);
if (m && m[1] && m[1].trim()) {
value = m[1].trim();
}
}
headerFilters.set(header, value);
}
});
}
// check headers
if (headerFilters.size) {
let headerMatches = new Set();
for (let j = prepared.headers.length - 1; j >= 0; j--) {
let header = prepared.headers[j];
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);
let value = (header.value || '').toString();
if (check.isRegex) {
if (check.test(value)) {
headerMatches.add(key);
}
} else if (value === check || value.indexOf(check) >= 0) {
headerMatches.add(key);
}
}
}
if (headerMatches.size < headerFilters.size) {
// not enough matches
return false;
}
}
if (typeof query.ha === 'boolean') {
let hasAttachments = maildata.attachments && maildata.attachments.length;
// 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', filterData.id, prepared.id);
// we reached the end of the filter, so this means we have a match
return filterData;
}
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;
}