diff --git a/config/default.js b/config/default.js index 2178cbe..6f07c13 100644 --- a/config/default.js +++ b/config/default.js @@ -133,10 +133,10 @@ module.exports = { disableInterfaces: ['forwarder'], // do not bounce messages from this interface sendingZone: 'bounces', - // send a warning email about delayed delivery + // Send a warning email about delayed delivery delayEmail: { enabled: true, - after: 3 * 3600 * 1000 + after: 3 * 3600 * 1000 // 3h }, zoneConfig: { diff --git a/lib/bounces.js b/lib/bounces.js index 7350a56..e1dc4ec 100644 --- a/lib/bounces.js +++ b/lib/bounces.js @@ -106,6 +106,98 @@ module.exports.check = (input, category) => { }; }; +module.exports.canSendBounce = delivery => { + if (delivery.skipBounce) { + log.info( + this.logName, + 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s as defined by routing', + delivery.sessionId, + delivery.id, + delivery.seq, + delivery.from || '<>' + ); + return false; + } + + if (/^mailer-daemon@/i.test(delivery.from) || !delivery.from) { + log.info( + this.logName, + 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to envelope (MAIL FROM=%s)', + delivery.sessionId, + delivery.id, + delivery.seq, + delivery.from || '<>', + JSON.stringify(delivery.from || '') + .replace(/"/g, '') + .trim() || '<>' + ); + return false; + } + + let xAutoResponseSuppress = delivery.headers.getFirst('X-Auto-Response-Suppress'); + if (/\ball\b/i.test(xAutoResponseSuppress)) { + log.info( + this.logName, + 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', + delivery.sessionId, + delivery.id, + delivery.seq, + delivery.from || '<>', + 'X-Auto-Response-Suppress', + JSON.stringify(xAutoResponseSuppress).replace(/"/g, '').trim() + ); + return false; + } + + let autoSubmitted = delivery.headers.getFirst('Auto-Submitted'); + if (/\bauto-(generated|replied)\b/i.test(autoSubmitted)) { + log.info( + this.logName, + 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', + delivery.sessionId, + delivery.id, + delivery.seq, + delivery.from || '<>', + 'Auto-Submitted', + JSON.stringify(autoSubmitted).replace(/"/g, '').trim() + ); + return false; + } + + let contentType = delivery.headers.getFirst('Content-Type'); + if (/^multipart\/report\b/i.test(contentType)) { + log.info( + this.logName, + 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', + delivery.sessionId, + delivery.id, + delivery.seq, + delivery.from || '<>', + 'Content-Type', + 'multipart/report' + ); + return false; + } + + if (delivery.parsedEnvelope && /^mailer-daemon@/i.test(delivery.parsedEnvelope.from)) { + log.info( + this.logName, + 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', + delivery.sessionId, + delivery.id, + delivery.seq, + delivery.from || '<>', + 'From', + JSON.stringify(delivery.parsedEnvelope.from || '<>') + .replace(/"/g, '') + .trim() || '<>' + ); + return false; + } + + return true; +}; + function formatSMTPResponse(str) { str = (str || '').toString().trim(); let code = str.match(/^\d{3}[\s-]+([\d.]+\s*)?/); diff --git a/lib/mail-queue.js b/lib/mail-queue.js index f6d304e..d924663 100644 --- a/lib/mail-queue.js +++ b/lib/mail-queue.js @@ -8,10 +8,13 @@ const QueueLocker = require('./queue-locker'); const TtlCache = require('./ttl-cache'); const crypto = require('crypto'); const plugins = require('./plugins'); +const Headers = require('mailsplit').Headers; const db = require('./db'); const GridFSBucket = require('mongodb').GridFSBucket; const ObjectId = require('mongodb').ObjectId; const internalCounters = require('./counters'); +const bounces = require('./bounces'); +const MailDrop = require('./mail-drop'); const yaml = require('js-yaml'); const fs = require('fs'); const pathlib = require('path'); @@ -47,6 +50,7 @@ class MailQueue { this.closing = false; this.garbageTimer = null; this.seqIndex = new SeqIndex(); + this.maildrop = new MailDrop(this); this.cache = new TtlCache(); // shared cache for workers this.locks = new QueueLocker(); @@ -818,25 +822,45 @@ class MailQueue { let lastCheck = now; if (firstCheck && prevLastCheck) { - return plugins.handler.runHooks( - 'queue:delayed', - [ - delivery, - responseData, - { - first: firstCheck, - prev: prevLastCheck, - last: lastCheck - } - ], - err => { - if (err) { - log.error('Queue', '%s.%s queue:delayed %s', delivery.id, delivery.seq, err.message); - } - + return this.getMeta(delivery.id, (err, meta) => { + if (err) { + // ignore + log.error('Queue', '%s.%s GET META %s', delivery.id, delivery.seq, err.message); return callback(null, true); } - ); + + let deliveryEntry = Object.assign(item.value, meta || {}); + deliveryEntry.headers = new Headers(deliveryEntry.headers); + + deliveryEntry.envelope = { + from: deliveryEntry.from, + to: deliveryEntry.recipient + }; + + if (!bounces.canSendBounce(deliveryEntry)) { + return false; + } + + return plugins.handler.runHooks( + 'queue:delayed', + [ + Object.assign({}, deliveryEntry, responseData), + this.maildrop, + { + first: firstCheck, + prev: prevLastCheck, + last: lastCheck + } + ], + err => { + if (err) { + log.error('Queue', '%s.%s queue:delayed %s', deliveryEntry.id, deliveryEntry.seq, err.message); + } + + return callback(null, true); + } + ); + }); } } diff --git a/lib/sender.js b/lib/sender.js index 1066b46..67ff099 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -1416,92 +1416,8 @@ class Sender extends EventEmitter { } sendBounceMessage(delivery, bounce, smtpResponse) { - if (/^mailer-daemon@/i.test(delivery.from) || !delivery.from) { - log.info( - this.logName, - 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to envelope (MAIL FROM=%s)', - delivery.sessionId, - delivery.id, - delivery.seq, - delivery.from || '<>', - JSON.stringify(delivery.from || '') - .replace(/"/g, '') - .trim() || '<>' - ); - return; - } - - if (delivery.skipBounce) { - log.info( - this.logName, - 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s as defined by routing', - delivery.sessionId, - delivery.id, - delivery.seq, - delivery.from || '<>' - ); - return; - } - - let xAutoResponseSuppress = delivery.headers.getFirst('X-Auto-Response-Suppress'); - if (/\ball\b/i.test(xAutoResponseSuppress)) { - log.info( - this.logName, - 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', - delivery.sessionId, - delivery.id, - delivery.seq, - delivery.from || '<>', - 'X-Auto-Response-Suppress', - JSON.stringify(xAutoResponseSuppress).replace(/"/g, '').trim() - ); - return; - } - - let autoSubmitted = delivery.headers.getFirst('Auto-Submitted'); - if (/\bauto-(generated|replied)\b/i.test(autoSubmitted)) { - log.info( - this.logName, - 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', - delivery.sessionId, - delivery.id, - delivery.seq, - delivery.from || '<>', - 'Auto-Submitted', - JSON.stringify(autoSubmitted).replace(/"/g, '').trim() - ); - return; - } - - let contentType = delivery.headers.getFirst('Content-Type'); - if (/^multipart\/report\b/i.test(contentType)) { - log.info( - this.logName, - 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', - delivery.sessionId, - delivery.id, - delivery.seq, - delivery.from || '<>', - 'Content-Type', - 'multipart/report' - ); - return; - } - - if (delivery.parsedEnvelope && /^mailer-daemon@/i.test(delivery.parsedEnvelope.from)) { - log.info( - this.logName, - 'id=%s %s.%s SKIPBOUNCE Skip bounce to %s due to header (%s=%s)', - delivery.sessionId, - delivery.id, - delivery.seq, - delivery.from || '<>', - 'From', - JSON.stringify(delivery.parsedEnvelope.from || '<>') - .replace(/"/g, '') - .trim() || '<>' - ); - return; + if (!bounces.canSendBounce(delivery)) { + return false; } this.sendCommand( diff --git a/plugins/core/email-bounce.js b/plugins/core/email-bounce.js index 3fc82aa..94e87b2 100644 --- a/plugins/core/email-bounce.js +++ b/plugins/core/email-bounce.js @@ -6,7 +6,10 @@ const MimeNode = require('nodemailer/lib/mime-node'); module.exports.title = 'Email Bounce Notification'; module.exports.init = function (app, done) { // generate a multipart/report DSN failure response - function generateBounceMessage(bounce) { + function generateBounceMessage(bounce, opts) { + opts = opts || {}; + const { isDelayed } = opts; + let headers = bounce.headers; let messageId = headers.getFirst('Message-ID'); @@ -29,26 +32,37 @@ module.exports.init = function (app, done) { rootNode.setHeader('X-Sending-Zone', sendingZone); rootNode.setHeader('X-Failed-Recipients', bounce.to); rootNode.setHeader('Auto-Submitted', 'auto-replied'); - rootNode.setHeader('Subject', 'Delivery Status Notification (Failure)'); + rootNode.setHeader('Subject', `Delivery Status Notification (${isDelayed ? 'Delay' : 'Failure'})`); if (messageId) { rootNode.setHeader('In-Reply-To', messageId); rootNode.setHeader('References', messageId); } - rootNode - .createChild('text/plain') - .setHeader('Content-Description', 'Notification') - .setContent( - `Delivery to the following recipient failed permanently: + let bounceContent = `Delivery to the following recipient failed permanently: ${bounce.to} Technical details of permanent failure: ${bounce.response} -` - ); +`; + + if (isDelayed) { + bounceContent = `Delivery incomplete + +There was a temporary problem delivering your message to ${bounce.to}. + +Delivery will be retried. You'll be notified if the delivery fails permanently. + +Technical details of the failure: + +${bounce.response} + +`; + } + + rootNode.createChild('text/plain').setHeader('Content-Description', 'Notification').setContent(bounceContent); rootNode .createChild('message/delivery-status') @@ -60,8 +74,8 @@ X-ZoneMTA-Sender: rfc822; ${bounce.from} Arrival-Date: ${new Date(bounce.arrivalDate).toUTCString().replace(/GMT/, '+0000')} Final-Recipient: rfc822; ${bounce.to} -Action: failed -Status: 5.0.0 +Action: ${isDelayed ? 'delayed' : 'failed'} +Status: ${isDelayed ? '4.0.0' : '5.0.0'} ` + (bounce.mxHostname ? `Remote-MTA: dns; ${bounce.mxHostname} @@ -129,28 +143,73 @@ Status: 5.0.0 }); }); - app.addHook('queue:delayed', async (delivery, bounce, options) => { + app.addHook('queue:delayed', async (bounce, maildrop, options) => { if (!app.config.delayEmail || !app.config.delayEmail.enabled) { return; } + if ((app.config.disableInterfaces || []).includes(bounce.interface)) { + // bounces are disabled for messages from this interface (eg. forwarded messages) + return; + } + + if (!bounce.from) { + // nowhere to send the bounce to + return; + } + // check if past required time - let prevDiff = options.prev - options.first; - let curDiff = options.last - options.first; + const prevDiff = options.prev - options.first; + const curDiff = options.last - options.first; if (prevDiff > app.config.delayEmail.after || curDiff < app.config.delayEmail.after) { return; } - app.logger.info( - 'Bounce', - 'Should send a delay email %s', - JSON.stringify({ - id: delivery.id, - seq: delivery.seq, - recipient: delivery.recipient, - response: bounce.response - }) - ); + const headers = bounce.headers; + + if (headers.get('Received').length > 25) { + // too many hops + app.logger.info( + 'Bounce', + 'Too many hops (%s)! Delivery loop detected for %s.%s, dropping message', + headers.get('Received').length, + bounce.seq, + bounce.id + ); + return; + } + + const envelope = { + interface: 'bounce', + sessionId: bounce.sessionId, + from: '', + to: bounce.from, + transtype: 'HTTP', + time: Date.now() + }; + + const mail = generateBounceMessage(bounce, { isDelayed: true }); + + let id = await new Promise((resolve, reject) => { + app.getQueue().generateId((err, id) => { + if (err) { + reject(err); + } else { + resolve(id); + } + }); + }); + + envelope.id = id; + + await new Promise(resolve => { + maildrop.add(envelope, mail.createReadStream(), err => { + if (err && err.name !== 'SMTPResponse') { + app.logger.error('Bounce', err.message); + } + resolve(); + }); + }); }); done();