wildduck/lmtp.js
2017-04-17 15:58:46 +03:00

380 lines
12 KiB
JavaScript

'use strict';
// Simple LMTP server that accepts all messages for valid recipients
const config = require('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');
const fs = require('fs');
let messageHandler;
const serverOptions = {
lmtp: true,
// log to console
logger: {
info(...args) {
args.shift();
log.info('LMTP', ...args);
},
debug(...args) {
args.shift();
log.silly('LMTP', ...args);
},
error(...args) {
args.shift();
log.error('LMTP', ...args);
}
},
name: false,
// not required but nice-to-have
banner: 'Welcome to Wild Duck Mail Server',
disabledCommands: ['AUTH'],
onMailFrom(address, session, callback) {
// reset session entries
session.users = [];
// accept alls sender addresses
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.database.collection('addresses').findOne({
address: recipient
}, (err, address) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (!address) {
return callback(new Error('Unknown recipient'));
}
db.database.collection('users').findOne({
_id: address.user
}, {
fields: {
filters: true
}
}, (err, user) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (!user) {
return callback(new Error('Unknown recipient'));
}
if (!session.users) {
session.users = [];
}
session.users.push({
recipient: originalRecipient,
user
});
callback();
});
});
},
// Handle message stream
onData(stream, session, callback) {
let chunks = [];
let chunklen = 0;
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
stream.once('error', err => {
log.error('LMTP', err);
callback(new Error('Error reading from stream'));
});
stream.once('end', () => {
let spamHeader = config.spamHeader && config.spamHeader.toLowerCase();
let responses = [];
let users = session.users;
let stored = 0;
let storeNext = () => {
if (stored >= users.length) {
return callback(null, responses.map(r => r.response));
}
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(
'Delivered-To: ' + recipient + '\r\n'
//+ 'Received: ' + generateReceivedHeader(session, queueId, os.hostname(), recipient) + '\r\n'
);
chunks.unshift(header);
chunklen += header.length;
let prepared = messageHandler.prepareMessage({
raw: Buffer.concat(chunks, chunklen)
});
let maildata = messageHandler.indexer.processContent(prepared.id, prepared.mimeTree);
// default flags are empty
let flags = [];
// default mailbox target is INBOX
let mailboxQueryKey = 'path';
let mailboxQueryValue = 'INBOX';
let filters = (user.filters || []).concat(spamHeader ? {
id: 'wdspam',
query: {
headers: {
[spamHeader]: 'Yes'
}
},
action: {
// only applies if any other filter does not already mark message as spam or ham
spam: true
}
} : []);
let filterActions = new Map();
filters.
// apply all filters to the message
map(filter => checkFilter(filter, prepared, maildata)).
// remove all unmatched filers
filter(filter => filter).
// apply filter actions
forEach(filter => {
// apply matching filter
if (!filterActions) {
filterActions = filter.action;
} else {
Object.keys(filter.action).forEach(key => {
// if a previous filter already has set a value then do not touch it
if (!filterActions.has(key)) {
filterActions.set(key, filter.action[key]);
}
});
}
});
if (filterActions.has('delete') && 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();
}
// 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;
}
});
let messageOptions = {
user: user && user._id || user,
[mailboxQueryKey]: mailboxQueryValue,
prepared,
maildata,
meta: {
source: 'LMTP',
from: tools.normalizeAddress(session.envelope.mailFrom && session.envelope.mailFrom.address || ''),
to: recipient,
origin: session.remoteAddress,
originhost: session.clientHostname,
transhost: session.hostNameAppearsAs,
transtype: session.transmissionType,
time: Date.now()
},
date: false,
flags,
// if similar message exists, then skip
skipExisting: true
};
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()
});
storeNext();
});
};
storeNext();
});
}
};
if (config.lmtp.key) {
serverOptions.key = fs.readFileSync(config.lmtp.key);
}
if (config.lmtp.cert) {
serverOptions.cert = fs.readFileSync(config.lmtp.cert);
}
const server = new SMTPServer(serverOptions);
module.exports = done => {
if (!config.lmtp.enabled) {
return setImmediate(() => done(null, false));
}
messageHandler = new MessageHandler(db.database);
let started = false;
server.on('error', err => {
if (!started) {
started = true;
return done(err);
}
log.error('LMTP', err);
});
server.listen(config.lmtp.port, config.lmtp.host, () => {
if (started) {
return server.close();
}
started = true;
done(null, server);
});
};
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 => {
headerFilters.set(key, (query.headers[key] || '').toString().toLowerCase());
});
}
// 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);
}
}
if (headerMatches.size < headerFilters.size) {
// not enough matches
return false;
}
}
if (query.ha) {
let hasAttachments = maildata.attachments && maildata.attachments.length;
// negative ha means no attachmens
if (hasAttachments && query.ha < 0) {
return false;
}
// positive ha means attachmens must exist
if (!hasAttachments && query.ha > 0) {
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().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;
}