wildduck/lmtp.js

520 lines
20 KiB
JavaScript
Raw Normal View History

'use strict';
2017-04-13 02:59:30 +08:00
// Simple LMTP server that accepts all messages for valid recipients
2017-07-16 19:37:33 +08:00
const config = require('wild-config');
const log = require('npmlog');
const SMTPServer = require('smtp-server').SMTPServer;
const tools = require('./lib/tools');
const MessageHandler = require('./lib/message-handler');
const db = require('./lib/db');
2017-04-24 21:51:50 +08:00
const forward = require('./lib/forward');
2017-05-08 00:00:04 +08:00
const autoreply = require('./lib/autoreply');
2017-07-16 19:37:33 +08:00
const certs = require('./lib/certs').get('lmtp');
let messageHandler;
2017-04-12 19:52:29 +08:00
const serverOptions = {
lmtp: true,
// log to console
logger: {
info(...args) {
args.shift();
2017-04-12 19:52:29 +08:00
log.info('LMTP', ...args);
},
debug(...args) {
args.shift();
2017-04-12 19:52:29 +08:00
log.silly('LMTP', ...args);
},
error(...args) {
args.shift();
2017-04-12 19:52:29 +08:00
log.error('LMTP', ...args);
}
},
name: false,
// not required but nice-to-have
2017-07-27 16:47:32 +08:00
banner: config.lmtp.banner || 'Welcome to Wild Duck Mail Server',
2017-04-12 19:52:29 +08:00
disabledCommands: ['AUTH'],
2017-03-30 01:06:09 +08:00
onMailFrom(address, session, callback) {
// reset session entries
2017-04-12 19:52:29 +08:00
session.users = [];
2017-03-30 01:06:09 +08:00
2017-04-12 19:52:29 +08:00
// accept alls sender addresses
2017-03-30 01:06:09 +08:00
return callback();
},
// Validate RCPT TO envelope address. Example allows all addresses that do not start with 'deny'
// If this method is not set, all addresses are allowed
onRcptTo(rcpt, session, callback) {
let originalRecipient = tools.normalizeAddress(rcpt.address);
let recipient = originalRecipient.replace(/\+[^@]*@/, '@');
db.users.collection('addresses').findOne({
2017-07-31 15:59:18 +08:00
addrview: recipient.substr(0, recipient.indexOf('@')).replace(/\./g, '') + recipient.substr(recipient.indexOf('@'))
}, (err, address) => {
if (err) {
2017-04-12 19:52:29 +08:00
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (!address) {
return callback(new Error('Unknown recipient'));
}
db.users.collection('users').findOne({
_id: address.user
2017-04-14 04:30:39 +08:00
}, {
fields: {
2017-05-08 00:00:04 +08:00
name: true,
forwards: true,
2017-05-07 20:09:14 +08:00
forward: true,
2017-05-08 00:00:04 +08:00
targetUrl: true,
autoreply: true,
encryptMessages: true,
pubKey: true
2017-04-14 04:30:39 +08:00
}
}, (err, user) => {
if (err) {
2017-04-12 19:52:29 +08:00
log.error('LMTP', err);
return callback(new Error('Database error'));
}
2017-03-28 01:53:13 +08:00
if (!user) {
return callback(new Error('Unknown recipient'));
}
if (!session.users) {
2017-04-12 19:52:29 +08:00
session.users = [];
}
2017-04-12 19:52:29 +08:00
session.users.push({
recipient: originalRecipient,
2017-04-14 04:30:39 +08:00
user
});
callback();
});
});
},
// Handle message stream
onData(stream, session, callback) {
let chunks = [];
let chunklen = 0;
2017-04-13 16:35:39 +08:00
stream.on('readable', () => {
let chunk;
2017-04-13 16:35:39 +08:00
while ((chunk = stream.read()) !== null) {
2017-04-12 19:52:29 +08:00
chunks.push(chunk);
chunklen += chunk.length;
}
});
stream.once('error', err => {
2017-04-12 19:52:29 +08:00
log.error('LMTP', err);
callback(new Error('Error reading from stream'));
});
2017-04-13 16:35:39 +08:00
stream.once('end', () => {
2017-04-12 20:18:22 +08:00
let spamHeader = config.spamHeader && config.spamHeader.toLowerCase();
2017-06-03 14:51:58 +08:00
let sender = tools.normalizeAddress((session.envelope.mailFrom && session.envelope.mailFrom.address) || '');
2017-04-12 19:52:29 +08:00
let responses = [];
let users = session.users;
let stored = 0;
2017-04-13 16:35:39 +08:00
let storeNext = () => {
if (stored >= users.length) {
2017-04-12 20:21:38 +08:00
return callback(null, responses.map(r => r.response));
}
2017-04-12 19:52:29 +08:00
let rcptData = users[stored++];
let recipient = rcptData.recipient;
let user = rcptData.user;
let response = responses.filter(r => r.user === user);
if (response.length) {
responses.push(response[0]);
return storeNext();
}
// create Delivered-To and Received headers
let header = Buffer.from(
2017-04-12 19:52:29 +08:00
'Delivered-To: ' + recipient + '\r\n'
2017-07-12 02:38:23 +08:00
//+ 'Received: ' + generateReceivedHeader(session, queueId, os.hostname().toLowerCase(), recipient) + '\r\n'
);
chunks.unshift(header);
chunklen += header.length;
2017-04-24 21:51:50 +08:00
let raw = Buffer.concat(chunks, chunklen);
2017-04-12 03:50:20 +08:00
2017-08-03 20:26:44 +08:00
let prepared = messageHandler.prepareMessage({
raw
});
let maildata = messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree);
2017-06-03 14:51:58 +08:00
2017-08-03 20:26:44 +08:00
// default flags are empty
let flags = [];
2017-07-24 21:44:08 +08:00
2017-08-03 20:26:44 +08:00
// default mailbox target is INBOX
let mailboxQueryKey = 'path';
let mailboxQueryValue = 'INBOX';
2017-04-13 16:35:39 +08:00
2017-08-03 20:26:44 +08:00
db.database.collection('filters').find({ user: user._id }).sort({ _id: 1 }).toArray((err, filters) => {
if (err) {
// ignore, as filtering is not so important
}
// append generic spam header check to the filters
filters = (filters || []).concat(
spamHeader
? {
id: 'SPAM',
query: {
headers: {
[spamHeader]: 'Yes'
}
2017-08-03 20:26:44 +08:00
},
action: {
// only applies if any other filter does not already mark message as spam or ham
spam: true
}
}
2017-08-03 20:26:44 +08:00
: []
);
let forwardTargets = new Set();
let forwardTargetUrls = new Set();
let matchingFilters = [];
let filterActions = new Map();
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 => {
matchingFilters.push(filter.id);
// apply matching filter
if (!filterActions) {
filterActions = filter.action;
} else {
Object.keys(filter.action).forEach(key => {
if (key === 'forward') {
forwardTargets.add(filter.action[key]);
return;
}
2017-08-03 20:26:44 +08:00
if (key === 'targetUrl') {
forwardTargetUrls.add(filter.action[key]);
return;
}
2017-05-07 20:09:14 +08:00
2017-08-03 20:26:44 +08:00
// 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-07-24 21:44:08 +08:00
}
2017-08-03 20:26:44 +08:00
});
2017-05-07 20:09:14 +08:00
2017-08-03 20:26:44 +08:00
let forwardMessage = done => {
if (user.forward && !filterActions.get('delete')) {
// forward to default recipient only if the message is not deleted
forwardTargets.add(user.forward);
}
2017-05-08 00:00:04 +08:00
2017-08-03 20:26:44 +08:00
if (user.targetUrl && !filterActions.get('delete')) {
// forward to default URL only if the message is not deleted
forwardTargetUrls.add(user.targetUrl);
}
2017-04-12 03:50:20 +08:00
2017-08-03 20:26:44 +08:00
// never forward messages marked as spam
if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) {
return setImmediate(done);
}
2017-08-03 20:26:44 +08:00
// check limiting counters
messageHandler.counters.ttlcounter(
'wdf:' + user._id.toString(),
forwardTargets.size + forwardTargetUrls.size,
user.forwards,
(err, result) => {
if (err) {
// failed checks
log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), err.message);
} else if (!result.success) {
log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + user._id.toString(), 'Precondition failed');
return done();
}
2017-08-03 20:26:44 +08:00
forward(
{
user,
sender,
recipient,
forward: forwardTargets.size ? Array.from(forwardTargets) : false,
targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false,
chunks
},
done
);
}
2017-08-03 20:26:44 +08:00
);
};
2017-08-03 20:26:44 +08:00
let sendAutoreply = done => {
// never reply to messages marked as spam
if (!sender || !user.autoreply || filterActions.get('spam')) {
return setImmediate(done);
}
autoreply(
{
user,
sender,
recipient,
chunks,
messageHandler
},
done
);
};
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).concat(forwardTargetUrls).join(','),
err.message
);
} else if (id) {
log.silly(
'LMTP',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
prepared.id.toString(),
id,
sender,
recipient,
Array.from(forwardTargets).concat(forwardTargetUrls).join(',')
2017-07-24 21:44:08 +08:00
);
2017-08-03 20:26:44 +08:00
}
2017-04-17 20:58:46 +08:00
2017-08-03 20:26:44 +08:00
sendAutoreply((err, id) => {
2017-07-24 21:44:08 +08:00
if (err) {
2017-08-03 20:26:44 +08:00
log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message);
2017-07-24 21:44:08 +08:00
} else if (id) {
2017-08-03 20:26:44 +08:00
log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender);
2017-07-24 21:44:08 +08:00
}
2017-04-12 20:18:22 +08:00
2017-08-03 20:26:44 +08:00
if (filterActions.get('delete')) {
// nothing to do with the message, just continue
responses.push({
user,
response: 'Message dropped by policy as ' + prepared.id.toString()
});
prepared = false;
maildata = false;
return storeNext();
}
2017-05-08 00:00:04 +08:00
2017-08-03 20:26:44 +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';
}
break;
case 'seen':
if (value) {
flags.push('\\Seen');
}
break;
case 'flag':
if (value) {
flags.push('\\Flagged');
}
break;
case 'mailbox':
if (value) {
// positive value is spam
mailboxQueryKey = 'mailbox';
mailboxQueryValue = value;
}
break;
2017-07-24 21:44:08 +08:00
}
2017-08-03 20:26:44 +08:00
});
2017-04-12 20:18:22 +08:00
2017-08-03 20:26:44 +08:00
let messageOptions = {
user: (user && user._id) || user,
[mailboxQueryKey]: mailboxQueryValue,
prepared,
maildata,
meta: {
source: 'LMTP',
from: sender,
to: recipient,
origin: session.remoteAddress,
originhost: session.clientHostname,
transhost: session.hostNameAppearsAs,
transtype: session.transmissionType,
time: Date.now()
},
2017-04-12 20:18:22 +08:00
2017-08-03 20:26:44 +08:00
filters: matchingFilters,
2017-08-03 20:26:44 +08:00
date: false,
flags,
2017-04-25 02:20:06 +08:00
2017-08-03 20:26:44 +08:00
// if similar message exists, then skip
skipExisting: true
};
2017-07-31 05:20:35 +08:00
2017-08-03 20:26:44 +08:00
messageHandler.encryptMessage(user.encryptMessages ? user.pubKey : false, raw, (err, encrypted) => {
if (!err && encrypted) {
messageOptions.prepared = messageHandler.prepareMessage({
raw: encrypted
});
2017-08-03 20:31:55 +08:00
messageOptions.maildata = messageHandler.indexer.getMaildata(messageOptions.prepared.id, messageOptions.prepared.mimeTree);
2017-08-03 20:26:44 +08:00
}
2017-07-24 21:44:08 +08:00
messageHandler.add(messageOptions, (err, inserted, info) => {
// remove Delivered-To
chunks.shift();
chunklen -= header.length;
// push to response list
responses.push({
user,
response: err ? err : 'Message stored as ' + info.id.toString()
});
2017-04-25 02:20:06 +08:00
storeNext();
});
2017-07-24 21:44:08 +08:00
});
2017-05-08 00:00:04 +08:00
});
2017-04-25 02:20:06 +08:00
});
});
};
storeNext();
});
}
2017-04-12 19:52:29 +08:00
};
2017-07-16 19:37:33 +08:00
if (certs) {
serverOptions.key = certs.key;
if (certs.ca) {
serverOptions.ca = certs.ca;
2017-07-12 02:38:23 +08:00
}
2017-07-16 19:37:33 +08:00
serverOptions.cert = certs.cert;
2017-04-12 19:52:29 +08:00
}
const server = new SMTPServer(serverOptions);
module.exports = done => {
2017-04-12 19:52:29 +08:00
if (!config.lmtp.enabled) {
return setImmediate(() => done(null, false));
}
2017-07-31 06:20:04 +08:00
messageHandler = new MessageHandler({ database: db.database, gridfs: db.gridfs, users: db.users, redis: db.redis });
let started = false;
server.on('error', err => {
if (!started) {
started = true;
return done(err);
}
2017-04-12 19:52:29 +08:00
log.error('LMTP', err);
});
2017-04-12 19:52:29 +08:00
server.listen(config.lmtp.port, config.lmtp.host, () => {
if (started) {
return server.close();
}
started = true;
done(null, server);
});
};
2017-04-16 02:59:27 +08:00
2017-04-17 20:58:46 +08:00
function checkFilter(filter, prepared, maildata) {
if (!filter || !filter.query) {
2017-04-16 02:59:27 +08:00
return false;
}
2017-04-17 20:58:46 +08:00
let query = filter.query;
2017-04-16 02:59:27 +08:00
2017-04-17 20:58:46 +08:00
// prepare filter data
let headerFilters = new Map();
if (query.headers) {
Object.keys(query.headers).forEach(key => {
headerFilters.set(key, (query.headers[key] || '').toString().toLowerCase());
});
}
2017-04-16 02:59:27 +08:00
2017-04-17 20:58:46 +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];
if (headerFilters.has(header.key) && header.value.indexOf(headerFilters.get(header.key)) >= 0) {
headerMatches.add(header.key);
2017-04-16 02:59:27 +08:00
}
}
2017-04-17 20:58:46 +08:00
if (headerMatches.size < headerFilters.size) {
// not enough matches
return false;
2017-04-16 02:59:27 +08:00
}
2017-04-17 20:58:46 +08:00
}
2017-04-16 02:59:27 +08:00
2017-07-24 21:44:08 +08:00
if (typeof query.ha === 'boolean') {
2017-04-17 20:58:46 +08:00
let hasAttachments = maildata.attachments && maildata.attachments.length;
2017-07-24 21:44:08 +08:00
// false ha means no attachmens
if (hasAttachments && !query.ha) {
2017-04-17 20:58:46 +08:00
return false;
2017-04-16 02:59:27 +08:00
}
2017-07-24 21:44:08 +08:00
// true ha means attachmens must exist
if (!hasAttachments && query.ha) {
2017-04-17 20:58:46 +08:00
return false;
}
}
2017-04-16 02:59:27 +08:00
2017-04-17 20:58:46 +08:00
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;
2017-04-16 02:59:27 +08:00
}
2017-04-17 20:58:46 +08:00
if (query.size > 0 && messageSize < filterSize) {
return false;
}
}
2017-04-16 02:59:27 +08:00
2017-07-24 21:44:08 +08:00
if (query.text && maildata.text.toLowerCase().replace(/\s+/g, ' ').indexOf(query.text.toLowerCase()) < 0) {
2017-04-17 20:58:46 +08:00
// message plaintext does not match the text field value
return false;
}
2017-04-17 20:58:46 +08:00
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;
}