How to check if email bounced
A bounce is either a refusal inside the SMTP transaction or a report delivered afterwards. node send-bounce.js shows the first as a 550 or 452 on RCPT TO. The second arrives at the envelope return path, not at the From: address, as a multipart/report.
Why check this
Run this before the first release that sends mail to addresses users typed, and again whenever the sending path changes. The check is not whether mail bounces, which it will. It is whether the application notices, and whether it can tell a dead address from a full mailbox.
The failure it prevents: a typo in a signup address produces a hard bounce on every send, the application retries it hourly for a month, and the provider starts rating the sender on that failure. The bounce was recorded the whole time. It arrived at the return path, and the mailbox at the return path was never read.
Prerequisites
- RFC 3463 for enhanced status codes and RFC 3464 for the delivery status report format.
bouncer.js, a local MTA that refuses two recipients in the transaction and bounces a third afterwards:
// A local MTA that refuses two recipients during the transaction and, for a
// third, accepts the message and then returns a delivery status notification
// to the envelope return path. Nothing is delivered anywhere.
const fs = require('fs');
const { SMTPServer } = require('smtp-server');
const REFUSE = {
'unknown@build.test': [550, '5.1.1 The mailbox you tried to reach does not exist'],
'full@build.test': [452, '4.2.2 The recipient mailbox is over quota, try again later'],
};
function dsn(returnPath, recipient, status, diagnostic) {
const now = new Date().toUTCString();
return [
'From: Mail Delivery System <MAILER-DAEMON@mx.h2check.test>',
'To: <' + returnPath + '>',
'Subject: Undelivered Mail Returned to Sender',
'Date: ' + now,
'Auto-Submitted: auto-replied',
'Content-Type: multipart/report; report-type=delivery-status; boundary="dsn-1"',
'', '--dsn-1', 'Content-Type: text/plain; charset=us-ascii', '',
'This is the mail system at mx.h2check.test.',
'Delivery to the following recipient failed permanently: ' + recipient,
'', '--dsn-1', 'Content-Type: message/delivery-status', '',
'Reporting-MTA: dns; mx.h2check.test',
'',
'Final-Recipient: rfc822; ' + recipient,
'Action: failed',
'Status: ' + status,
'Diagnostic-Code: smtp; ' + diagnostic,
'', '--dsn-1--', '',
].join('\r\n');
}
const server = new SMTPServer({
name: 'mx.h2check.test',
disableReverseLookup: true,
authOptional: true,
hideSTARTTLS: true,
onRcptTo(address, session, callback) {
const rule = REFUSE[address.address.toLowerCase()];
if (rule) {
const err = new Error(rule[1]);
err.responseCode = rule[0];
return callback(err);
}
callback();
},
onData(stream, session, callback) {
const chunks = [];
stream.on('data', (c) => chunks.push(c));
stream.on('end', () => {
const returnPath = session.envelope.mailFrom.address;
for (const r of session.envelope.rcptTo) {
if (r.address.toLowerCase().startsWith('blackhole@')) {
const file = 'capture/bounce-' + Date.now() + '.eml';
fs.writeFileSync(file, dsn(returnPath, r.address, '5.2.1',
'550 5.2.1 The mailbox is disabled and not accepting messages'));
console.log('accepted at DATA, then bounced ' + r.address + ' to ' + returnPath + ' -> ' + file);
} else {
console.log('delivered ' + r.address + ' (' + Buffer.concat(chunks).length + ' bytes)');
}
}
callback();
});
},
});
server.listen(2532, '127.0.0.1', () =>
console.log('bouncing MTA on 127.0.0.1:2532, pid ' + process.pid));
send-bounce.js, the application side. TheFrom:header and the envelope return path are different on purpose:
// The application side. The From header and the envelope return path are
// deliberately different addresses, which is where the bounce goes.
const nodemailer = require('nodemailer');
const t = nodemailer.createTransport({ host: '127.0.0.1', port: 2532 });
const RECIPIENTS = ['qa@build.test', 'unknown@build.test', 'full@build.test', 'blackhole@build.test'];
(async () => {
for (const to of RECIPIENTS) {
const id = Buffer.from('4821-' + (RECIPIENTS.indexOf(to) + 1)).toString('hex');
try {
const i = await t.sendMail({
from: 'Build Test <no-reply@build.test>',
to,
envelope: { from: 'bounces+' + id + '@build.test', to },
subject: 'Order 4821 is confirmed',
text: 'Order 4821 is confirmed.',
});
console.log(to.padEnd(22) + 'accepted ' + i.response);
} catch (e) {
console.log(to.padEnd(22) + 'refused ' + (e.response || e.message));
}
}
t.close();
})();
bounce-read.js, a bounce processor that reads the report rather than the prose:
// Reads a delivery status notification the way a bounce processor should:
// from the machine-readable part, not from the human text.
const fs = require('fs');
for (const file of fs.readdirSync('capture').filter((f) => f.startsWith('bounce-'))) {
const text = fs.readFileSync('capture/' + file, 'utf8');
const field = (name) => ((new RegExp('^' + name + ': *(.+)$', 'im').exec(text) || [])[1] || '-').trim();
const status = field('Status');
const verp = /bounces\+([0-9a-f]+)@/.exec(field('To'));
console.log(file);
console.log(' report type : ' + (/report-type=([\w-]+)/.exec(text) || [])[1]);
console.log(' arrived at : ' + field('To'));
console.log(' send id from VERP: ' + (verp ? Buffer.from(verp[1], 'hex').toString() : 'none'));
console.log(' final recipient : ' + field('Final-Recipient'));
console.log(' action : ' + field('Action'));
console.log(' status : ' + status + ' (' + (status.startsWith('5') ? 'hard, do not retry' : 'soft, retry') + ')');
console.log(' diagnostic code : ' + field('Diagnostic-Code'));
}
send-multi.js, one transaction with three recipients:
// One transaction, three recipients, two of which the MTA refuses.
const nodemailer = require('nodemailer');
const t = nodemailer.createTransport({ host: '127.0.0.1', port: 2532 });
t.sendMail({
from: 'no-reply@build.test',
envelope: { from: 'bounces+4821-0@build.test', to: ['qa@build.test', 'unknown@build.test', 'full@build.test'] },
subject: 'Order 4821 is confirmed', text: 'Order 4821 is confirmed.',
}).then((i) => {
console.log('accepted : ' + JSON.stringify(i.accepted));
console.log('rejected : ' + JSON.stringify(i.rejected));
console.log('errors : ' + JSON.stringify((i.rejectedErrors || []).map((e) => e.response)));
console.log('response : ' + i.response);
t.close();
}).catch((e) => { console.log('whole send failed: ' + (e.response || e.message)); t.close(); });
Steps
- Step 1.
Start the bouncing MTA and keep its process id.
node bouncer.js &bouncing MTA on 127.0.0.1:2532, pid 44692It listens on the loopback address, refuses two named recipients and bounces a third after accepting it.
- Step 2.
Send to four recipients, one per transaction.
node send-bounce.jsqa@build.test accepted 250 OK: message queued unknown@build.test refused 550 5.1.1 The mailbox you tried to reach does not exist full@build.test refused 452 4.2.2 The recipient mailbox is over quota, try again later blackhole@build.test accepted 250 OK: message queuedTwo failures arrived while the connection was open, and the reply code carries the decision.
5.1.1is permanent,4.2.2is temporary. The difference is the first digit, and it decides whether a retry is useful or harmful. - Step 3.
Read what the server did with the two it accepted.
cat bouncer.logbouncing MTA on 127.0.0.1:2532, pid 44692 delivered qa@build.test (316 bytes) accepted at DATA, then bounced blackhole@build.test to bounces+343832312d34@build.test -> capture/bounce-1789161897779.emlThe fourth recipient got a
250in step 2 and failed anyway. An SMTP acceptance is a promise to try, and any server may accept a message and reject it later, after content filtering or an internal routing lookup. - Step 4.
Read the bounce the server produced.
cat capture/bounce-1789161897779.emlFrom: Mail Delivery System <MAILER-DAEMON@mx.h2check.test> To: <bounces+343832312d34@build.test> Subject: Undelivered Mail Returned to Sender Date: Fri, 11 Sep 2026 21:24:57 GMT Auto-Submitted: auto-replied Content-Type: multipart/report; report-type=delivery-status; boundary="dsn-1" --dsn-1 Content-Type: text/plain; charset=us-ascii This is the mail system at mx.h2check.test. Delivery to the following recipient failed permanently: blackhole@build.test --dsn-1 Content-Type: message/delivery-status Reporting-MTA: dns; mx.h2check.test Final-Recipient: rfc822; blackhole@build.test Action: failed Status: 5.2.1 Diagnostic-Code: smtp; 550 5.2.1 The mailbox is disabled and not accepting messages --dsn-1--Read the
To:line. The message was sent withFrom: Build Test <no-reply@build.test>and the bounce went tobounces+343832312d34@build.test, the envelope return path. Nobody watching theFrom:mailbox sees this report. The second part is the one a program reads. - Step 5.
Run the bounce through a processor.
node bounce-read.jsbounce-1789161897779.eml report type : delivery-status arrived at : <bounces+343832312d34@build.test> send id from VERP: 4821-4 final recipient : rfc822; blackhole@build.test action : failed status : 5.2.1 (hard, do not retry) diagnostic code : smtp; 550 5.2.1 The mailbox is disabled and not accepting messagesStatus: 5.2.1classifies the failure without parsing anybody's English. The return path carried an identifier, so the report ties back to send4821-4without a search.Diagnostic-Codekeeps the original reply for the case where the enhanced code is too coarse. - Step 6.
Send one transaction with three recipients, two of which fail.
node send-multi.jsaccepted : ["qa@build.test"] rejected : ["unknown@build.test","full@build.test"] errors : ["550 5.1.1 The mailbox you tried to reach does not exist","452 4.2.2 The recipient mailbox is over quota, try again later"] response : 250 OK: message queuedThe send resolved without throwing.
responseis250, and two of three recipients were refused. Code that only catches an exception records this as a success. The permanent and the temporary failure sit in one array, so the classification has to come fromrejectedErrors. - Step 7.
Stop the server by its process id.
powershell -Command "Stop-Process -Id 44692 -Force"$ netstat -ano | grep LISTENING | grep ":2532 " $Nothing is listening on 2532.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 550 5.1.1 on RCPT TO | The mailbox does not exist | Hard bounce. Suppress the address and stop sending to it. |
| 452 4.2.2 on RCPT TO | The mailbox is full right now | Soft bounce. Retry with backoff, and suppress after a set number of days. |
| 550 5.7.1 on RCPT TO or after DATA | Policy refused the message | Check authentication first, see How to check if an email has passed SPF DKIM and DMARC. |
| 250 and then a report later | The server accepted and failed afterwards | Asynchronous bounce. Only the return path mailbox will see it. |
| Action: failed, Status: 5.x.x | Permanent, reported after acceptance | Suppress the address. Retrying costs reputation. |
| Action: delayed, Status: 4.x.x | The server is still trying | Do not suppress and do not resend. A final report follows. |
| rejected non-empty while the send resolved | Some recipients failed in a multi-recipient transaction | Assert on rejected, not on the absence of an exception. |
| No bounce anywhere and mail missing | The return path mailbox is unread or does not exist | Point the envelope sender at an address something processes. |
Common mistakes
What to check next
- How to test email sending in a test environment: the catch-all arrangement this server is a variant of.
- How to test SMTP connection: the transaction these refusals happen inside, command by command.
- How to check if an email has passed SPF DKIM and DMARC: what to check first when the refusal is
5.7.x. - How to test transactional email rendering: the message that bounced, read as the recipient's server saw it.
- Email testing checklist: where this sits in a release that touches mail.
FAQ
How do I know whether an email bounced?
Two places. The SMTP reply during the transaction, which step 2 prints, and the delivery status report that arrives at the return path afterwards, which step 4 shows. An application that watches only the first misses every asynchronous bounce.
What is the difference between a hard and a soft bounce?
The first digit of the status. 5.x.x is permanent: the address does not exist, or policy refuses it. 4.x.x is temporary: a full mailbox, a rate limit, a greylisting delay. Retry the second, suppress the first.
Where can I get a test address that always bounces?
From your own server, as here. Public bounce addresses exist and point at infrastructure you do not own. A local MTA that returns a chosen status code gives you 5.1.1, 4.2.2 and 5.7.1 on demand, with no traffic leaving the machine.
Why does the bounce not arrive at the From address?
Because routing uses the envelope, and the envelope sender comes from MAIL FROM. Setting a per-send return path, as send-bounce.js does, is what lets a processor match a report to a send without guessing.
Verified
Verified by Maks Vernynode 22.23.2smtp-server 3.19.11nodemailer 10.0.8
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate14 minpublished updated Maks Verny