Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions plugins/core/email-bounce.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
const os = require('os');
const MimeNode = require('nodemailer/lib/mime-node');

function formatRecipients(value) {
return []
.concat(value || [])
.map(entry => ((entry && entry.address) || entry || '').toString().trim())
.filter(entry => entry);
}

function getSingleRecipient(value) {
return formatRecipients(value).find(entry => entry) || '';
}

function getFailedRecipient(bounce) {
return getSingleRecipient(bounce.recipient || bounce.envelope?.to || bounce.to);
}

function getBounceSubject(bounce, isDelayed) {
const failedRecipient = getFailedRecipient(bounce);
const status = isDelayed ? 'Delay' : 'Failure';
return failedRecipient ? `Delivery Status Notification (${status}: ${failedRecipient})` : `Delivery Status Notification (${status})`;
}

module.exports.title = 'Email Bounce Notification';
module.exports.init = function (app, done) {
// generate a multipart/report DSN failure response
Expand All @@ -21,6 +42,12 @@ module.exports.init = function (app, done) {
let from = cfg.mailerDaemon || app.config.mailerDaemon;
let to = bounce.from;
let sendingZone = cfg.sendingZone || app.config.sendingZone;
let failedRecipient = getFailedRecipient(bounce);
let originalRecipients = formatRecipients(bounce.to);
let originalRecipientsText =
originalRecipients.length && !(originalRecipients.length === 1 && originalRecipients[0] === failedRecipient)
? `\nOriginal message recipients:\n ${originalRecipients.join('\n ')}\n`
: '';

let rootNode = new MimeNode('multipart/report; report-type=delivery-status');

Expand All @@ -30,17 +57,19 @@ module.exports.init = function (app, done) {
rootNode.setHeader('From', fromAddress);
rootNode.setHeader('To', to);
rootNode.setHeader('X-Sending-Zone', sendingZone);
rootNode.setHeader('X-Failed-Recipients', bounce.to);
if (originalRecipients.length) {
rootNode.setHeader('X-Failed-Recipients', originalRecipients.join(', '));
}
rootNode.setHeader('Auto-Submitted', 'auto-replied');
rootNode.setHeader('Subject', `Delivery Status Notification (${isDelayed ? 'Delay' : 'Failure'})`);
rootNode.setHeader('Subject', getBounceSubject(bounce, isDelayed));

if (messageId) {
rootNode.setHeader('In-Reply-To', messageId);
rootNode.setHeader('References', messageId);
}

let bounceContent = `Delivery to the following recipient failed permanently:
${bounce.to}
${failedRecipient}${originalRecipientsText}

Technical details of permanent failure:

Expand All @@ -51,7 +80,8 @@ ${bounce.response}
if (isDelayed) {
bounceContent = `Delivery incomplete

There was a temporary problem delivering your message to ${bounce.to}.
There was a temporary problem delivering your message to:
${failedRecipient}${originalRecipientsText}

Delivery will be retried. You'll be notified if the delivery fails permanently.

Expand All @@ -73,7 +103,7 @@ X-ZoneMTA-Queue-ID: ${bounce.id}
X-ZoneMTA-Sender: rfc822; ${bounce.from}
Arrival-Date: ${new Date(bounce.arrivalDate).toUTCString().replace(/GMT/, '+0000')}

Final-Recipient: rfc822; ${bounce.to}
Final-Recipient: rfc822; ${failedRecipient}
Action: ${isDelayed ? 'delayed' : 'failed'}
Status: ${isDelayed ? '4.0.0' : '5.0.0'}
` +
Expand Down
139 changes: 139 additions & 0 deletions test/email-bounce-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use strict';

const emailBounce = require('../plugins/core/email-bounce');

function createHeaders() {
return {
getFirst(key) {
if (key === 'Message-ID') {
return '<test-message@example.com>';
}
return '';
},
get(key) {
if (key === 'Received') {
return [];
}
return [];
},
build() {
return 'Message-ID: <test-message@example.com>\r\n';
}
};
}

function createApp() {
const hooks = new Map();
return {
hooks,
config: {
zoneConfig: {},
mailerDaemon: 'Mailer Daemon <mailer-daemon@[HOSTNAME]>',
sendingZone: 'default'
},
addHook(name, handler) {
hooks.set(name, handler);
},
getQueue() {
return {
generateId(callback) {
callback(null, 'test-bounce-id');
}
};
},
logger: {
info() {},
error() {}
},
remotelog() {}
};
}

module.exports['Email bounce hides original recipients when it matches the failed recipient'] = test => {
const app = createApp();
emailBounce.init(app, () => {});

const queueBounce = app.hooks.get('queue:bounce');

const bounce = {
id: 'message-id',
sessionId: 'session-id',
zone: 'default',
from: 'sender@example.com',
to: [{ address: 'failed@example.com' }],
seq: '001',
headers: createHeaders(),
name: 'mx.example.com',
arrivalDate: new Date('2026-03-25T10:00:00Z').toISOString(),
response: '550 5.1.1 No such user'
};

const maildrop = {
add(envelope, stream, callback) {
const chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('error', callback);
stream.on('end', () => {
const message = Buffer.concat(chunks).toString();

test.equal(envelope.to, 'sender@example.com');
test.ok(/Subject: Delivery Status Notification \(Failure: failed@example\.com\)/.test(message));
test.ok(/Final-Recipient: rfc822; failed@example\.com/.test(message));
test.ok(/X-Failed-Recipients: failed@example\.com/.test(message));
test.ok(!/Original message recipients:\s+failed@example\.com/.test(message));

callback();
});
}
};

queueBounce(bounce, maildrop, err => {
test.ifError(err);
test.done();
});
};

module.exports['Email bounce includes original recipients when it differs from the failed recipient'] = test => {
const app = createApp();
emailBounce.init(app, () => {});

const queueBounce = app.hooks.get('queue:bounce');

const bounce = {
id: 'message-id',
sessionId: 'session-id',
zone: 'default',
from: 'sender@example.com',
recipient: 'failed@example.com',
to: [{ address: 'first@example.com' }, { address: 'failed@example.com' }],
seq: '001',
headers: createHeaders(),
name: 'mx.example.com',
arrivalDate: new Date('2026-03-25T10:00:00Z').toISOString(),
response: '550 5.1.1 No such user'
};

const maildrop = {
add(envelope, stream, callback) {
const chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('error', callback);
stream.on('end', () => {
const message = Buffer.concat(chunks).toString();

test.equal(envelope.to, 'sender@example.com');
test.ok(/Subject: Delivery Status Notification \(Failure: failed@example\.com\)/.test(message));
test.ok(/Final-Recipient: rfc822; failed@example\.com/.test(message));
test.ok(/X-Failed-Recipients: first@example\.com, failed@example\.com/.test(message));
test.ok(/Original message recipients:\s+first@example\.com\s+failed@example\.com/.test(message));

callback();
});
}
};

queueBounce(bounce, maildrop, err => {
test.ifError(err);
test.done();
});
};