How to test email sending in a test environment

Point the application's SMTP host at a local catch-all server and send. node -r ./egress.js send.js qa@build.test prints every outbound connection the run opened, and the server prints every message it stored. Two counts that match, with no connection off the loopback address, is the pass.

Why check this

Set this up before the first release that sends mail, and check it again whenever the mail configuration moves. What matters is the arrangement, not the message: where the messages go, how you prove they went nowhere else, and what happens when the configuration is wrong.

The failure it prevents: a load test seeds two thousand accounts with addresses copied from a production export, the staging deployment inherits the production SMTP credentials from a shared secret store, and two thousand real people get a password reset mail at 02:00. A catch-all server alone does not prevent this. A catch-all that refuses addresses outside the sandbox does, and it fails the test run while it is at it.

Prerequisites

// Catch-all with a guard: any recipient outside the sandbox domains is refused,
// so an address belonging to a real person fails the test instead of being
// swallowed. Stores what it accepts, delivers nothing.
const fs = require('fs');
const { SMTPServer } = require('smtp-server');
const SANDBOX = ['build.test', 'example.test', 'localhost'];

const server = new SMTPServer({
  name: 'mx.h2check.test',
  disableReverseLookup: true,
  key: fs.readFileSync('smtp.key'),
  cert: fs.readFileSync('smtp.crt'),
  authOptional: true,
  onRcptTo(address, session, callback) {
    const domain = address.address.split('@')[1].toLowerCase();
    if (!SANDBOX.includes(domain)) {
      const err = new Error('Recipient ' + address.address + ' is outside the test sandbox');
      err.responseCode = 550;
      return callback(err);
    }
    callback();
  },
  onData(stream, session, callback) {
    const chunks = [];
    stream.on('data', (c) => chunks.push(c));
    stream.on('end', () => {
      const raw = Buffer.concat(chunks);
      fs.writeFileSync('capture/' + Date.now() + '.eml', raw);
      console.log('captured ' + raw.length + ' bytes for ' +
        session.envelope.rcptTo.map((r) => r.address).join(','));
      callback();
    });
  },
});
server.listen(2531, '127.0.0.1', () =>
  console.log('guarded catch-all on 127.0.0.1:2531, pid ' + process.pid));
// Preload hook. Logs every outbound TCP connection the process opens and
// refuses anything that is not the loopback address.
// node -r ./egress.js send.js <recipients...>
const net = require('net');
const connect = net.Socket.prototype.connect;
net.Socket.prototype.connect = function (...args) {
  let o = args[0];
  if (Array.isArray(o)) o = o[0];                       // nodemailer passes [options, cb]
  if (typeof o !== 'object' || o === null) o = { port: args[0], host: args[1] };
  const host = o.host || o.path || '127.0.0.1';
  console.log('[egress] connect ' + host + ':' + o.port);
  if (!/^(127\.0\.0\.1|::1|localhost)$/i.test(host)) {
    throw new Error('[egress] blocked connection to ' + host + ':' + o.port);
  }
  return connect.apply(this, args);
};
// The application side: one transactional message through the configured SMTP host.
const nodemailer = require('nodemailer');
const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST || '127.0.0.1',
  port: Number(process.env.SMTP_PORT || 2531),
  secure: false,
  tls: { rejectUnauthorized: false },
});
(async () => {
  for (const to of process.argv.slice(2)) {
    try {
      const info = await transport.sendMail({
        from: 'no-reply@build.test',
        to,
        subject: 'Password reset',
        text: 'Reset link: https://app.build.test/reset?t=abc123',
      });
      console.log('to=' + to + '  accepted=' + JSON.stringify(info.accepted) +
        '  rejected=' + JSON.stringify(info.rejected) + '  response=' + info.response);
    } catch (e) {
      console.log('to=' + to + '  send failed: ' + (e.response || e.message));
    }
  }
  transport.close();
})();
// What an unset SMTP host does. No host, no port, no error at construction.
const nodemailer = require('nodemailer');
const t = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: process.env.SMTP_PORT });
console.log('transport host=' + JSON.stringify(t.options.host) + ' port=' + JSON.stringify(t.options.port));
t.sendMail({ from: 'no-reply@build.test', to: 'qa@build.test', subject: 'x', text: 'y' })
  .then((i) => console.log('sent: ' + i.response))
  .catch((e) => console.log('failed: ' + (e.code || e.name) + ' ' + e.message));

Steps

  1. Step 1.

    Start the guarded server and the unguarded one, and keep both process ids.

    node guard.js & node smtpd.js &
    
    guarded catch-all on 127.0.0.1:2531, pid 3364
    smtpd listening on 127.0.0.1:2526, pid 5948

    Both bind to 127.0.0.1 and not to 0.0.0.0. A catch-all reachable from the network is a mail relay that anybody on that network can use.

  2. Step 2.

    Send to two sandbox addresses and one address belonging to a person.

    node -r ./egress.js send.js qa@build.test alerts@example.test someone@gmail.com
    
    [egress] connect 127.0.0.1:2531
    to=qa@build.test  accepted=["qa@build.test"]  rejected=[]  response=250 OK: message queued
    [egress] connect 127.0.0.1:2531
    to=alerts@example.test  accepted=["alerts@example.test"]  rejected=[]  response=250 OK: message queued
    [egress] connect 127.0.0.1:2531
    to=someone@gmail.com  send failed: 550 Recipient someone@gmail.com is outside the test sandbox

    Three [egress] lines, all to 127.0.0.1:2531. That is the answer to "did anything leave": the run opened no other socket, and gmail.com was never resolved, because a client with a fixed SMTP host does not look up the recipient domain.

  3. Step 3.

    Reconcile what the application claims it sent with what the server holds.

    echo "captured messages: $(ls capture/*.eml | wc -l)"; grep captured guard.log
    
    captured messages: 2
    captured 319 bytes for qa@build.test
    captured 325 bytes for alerts@example.test

    Two accepted sends, two files, two log lines. A count that comes out low means a message was accepted and dropped, which is the bug a catch-all is most likely to hide.

  4. Step 4.

    Read one captured message.

    cat capture/1789161415606.eml
    
    From: no-reply@build.test
    To: qa@build.test
    Subject: Password reset
    Message-ID: <3281efd0-43cb-a2c1-40aa-0b55a91ed97b@build.test>
    Content-Transfer-Encoding: 7bit
    Date: Fri, 11 Sep 2026 21:16:55 +0000
    MIME-Version: 1.0
    Content-Type: text/plain; charset=utf-8
    
    Reset link: https://app.build.test/reset?t=abc123

    The file is the message as it went on the wire, so a test can assert on the reset token, the sender and the headers without a mailbox anywhere.

  5. Step 5.

    Send the same outside address to a catch-all with no guard.

    SMTP_PORT=2526 node -r ./egress.js send.js someone@gmail.com
    
    [egress] connect 127.0.0.1:2526
    to=someone@gmail.com  accepted=["someone@gmail.com"]  rejected=[]  response=250 OK: message queued
    stored capture/1789161422825.eml  323 bytes  return-path=no-reply@build.test  rcpt=someone@gmail.com

    accepted and a 250. The test suite passes, nobody is mailed, and the address that should have failed the run is sitting in capture/ where no one reads it. On the day the host is wrong, that address is the one that gets the mail.

  6. Step 6.

    Remove the SMTP host from the environment and send again.

    node -r ./egress.js default-host.js
    
    transport host=undefined port=undefined
    [egress] connect 127.0.0.1:587
    [egress] connect ::1:587
    failed: ESOCKET connect ECONNREFUSED ::1:587

    The transport reports no host and no port, accepts the configuration without complaint, and then connects to localhost:587. On this machine nothing listens there. On a build agent with a local relay, that is a live submission service and the mail is gone.

  7. Step 7.

    Stop both servers by their own process ids.

    powershell -Command "Stop-Process -Id 3364 -Force; Stop-Process -Id 5948 -Force"
    
    $ netstat -ano | grep LISTENING | grep -E ":(2526|2531) "
    $

    No listener on either port. Stopping by image name would take down every other Node process on the machine, including somebody else's test server.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Every [egress] line points at 127.0.0.1 | Nothing left the machine | The arrangement holds for this run. | | An [egress] line with any other host | The run opened a socket off the machine | Stop and find out what opened it before you send again. | | 550 ... outside the test sandbox | The guard caught an address belonging to a person | Fix the fixture. This is the check working. | | accepted for an address outside the sandbox | The catch-all has no guard | Add the recipient allowlist, as in step 5. | | Captured files fewer than accepted sends | A message was accepted and lost | Look at the server's onData handler and at the disk it writes to. | | transport host=undefined | The configuration never reached the client | Fail startup on a missing SMTP host instead of defaulting. | | ECONNREFUSED ::1:587 | The client fell back to localhost | On a host with a local MTA there would be no error and no warning. |

Common mistakes

Sign: The catch-all accepts everything, so no test ever fails on a bad recipient.Cause: A server that answers 250 to any address turns a wrong fixture into a silent pass. The guard in step 2 refuses anything outside the sandbox domains with 550, which surfaces in the send result and fails the run. Step 5 is the same send without it.
Sign: An unset SMTP host is treated as a safe default.Cause: Step 6 shows the transport reporting host=undefined and then connecting to localhost:587. Nothing errors at construction. On a build agent running a local MTA the mail is submitted for real. Read the effective host at startup and refuse to boot without it.
Sign: The test environment is proved safe by reading the config file.Cause: Configuration is what you intended, not what the process did. A preload hook that prints every outbound connection and refuses non-loopback addresses reports the second. It costs 12 lines and it is the only artefact in this procedure that answers the question the release manager asks.
Sign: The catch-all is bound to all interfaces so colleagues can see the captures.Cause: A server listening on 0.0.0.0 that accepts any sender and any recipient is an open relay for everyone who can route to it. Bind to 127.0.0.1 and read the captures over a share, or put a real viewer in front of the store.

What to check next

FAQ

How do I test email sending without sending email?

Run an SMTP server on the loopback address that stores messages and delivers nothing, and point the application at it. The application performs a complete SMTP transaction, so the code path under test is the real one.

Which local SMTP server should I use for development?

Anything that speaks SMTP and keeps the message. A viewer such as MailDev or Mailpit adds a web inbox. The 30 lines in this page are enough when the assertions are made by a test rather than by a person looking at a screen.

How do I prove no mail reached a real address?

Record where the process connected, as in step 2, and check that every address in the run was refused or captured. A configuration file is evidence of intent. The connection log is evidence of behaviour.

Should the test environment use production SMTP credentials?

No. Credentials that work in production make a misconfiguration deliverable. Give the test environment credentials that only the local server accepts, so the worst outcome of a wrong host is a failed connection.

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.

intermediate12 minpublished updated Maks Verny