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

// 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));
// 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();
})();
// 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'));
}
// 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

  1. Step 1.

    Start the bouncing MTA and keep its process id.

    node bouncer.js &
    
    bouncing MTA on 127.0.0.1:2532, pid 44692

    It listens on the loopback address, refuses two named recipients and bounces a third after accepting it.

  2. Step 2.

    Send to four recipients, one per transaction.

    node send-bounce.js
    
    qa@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 queued

    Two failures arrived while the connection was open, and the reply code carries the decision. 5.1.1 is permanent, 4.2.2 is temporary. The difference is the first digit, and it decides whether a retry is useful or harmful.

  3. Step 3.

    Read what the server did with the two it accepted.

    cat bouncer.log
    
    bouncing 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.eml

    The fourth recipient got a 250 in 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.

  4. Step 4.

    Read the bounce the server produced.

    cat capture/bounce-1789161897779.eml
    
    From: 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 with From: Build Test <no-reply@build.test> and the bounce went to bounces+343832312d34@build.test, the envelope return path. Nobody watching the From: mailbox sees this report. The second part is the one a program reads.

  5. Step 5.

    Run the bounce through a processor.

    node bounce-read.js
    
    bounce-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 messages

    Status: 5.2.1 classifies the failure without parsing anybody's English. The return path carried an identifier, so the report ties back to send 4821-4 without a search. Diagnostic-Code keeps the original reply for the case where the enhanced code is too coarse.

  6. Step 6.

    Send one transaction with three recipients, two of which fail.

    node send-multi.js
    
    accepted     : ["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 queued

    The send resolved without throwing. response is 250, 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 from rejectedErrors.

  7. 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

Sign: The bounce mailbox is empty and the sender insists nothing bounced.Cause: Reports go to the envelope return path from MAIL FROM, not to the From header. Step 4 was sent as no-reply@build.test and the report arrived at bounces+343832312d34@build.test. If the return path is a no-reply address that discards mail, every asynchronous bounce is discarded with it.
Sign: A send is recorded as successful and the recipient never got the mail.Cause: In step 6 sendMail resolved, response was 250, and two of three recipients had been refused. A try/catch around the send sees nothing. Read accepted and rejected on every send, and treat a non-empty rejected as a failure.
Sign: Addresses are suppressed after a single failure and real users stop getting mail.Cause: A 4xx is a full mailbox, a greylisting delay or a rate limit, and it clears. Step 2 produces 452 4.2.2 next to 550 5.1.1 so the two can be told apart. Suppress on the first 5xx, retry a 4xx with backoff, and suppress only after it persists.
Sign: The bounce processor breaks when a provider changes its wording.Cause: Parsing the human-readable part is parsing prose that each MTA writes differently. The message/delivery-status part carries Action, Status and Diagnostic-Code in a fixed format, which is what step 5 reads. Use the text part for a ticket, never for a decision.

What to check next

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.

intermediate14 minpublished updated Maks Verny