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
guard.js, the server the application sends to. It stores everything it accepts and refuses any recipient outside the sandbox domains:
// 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));
egress.js, a preload hook that records where the run connected and stops it leaving the machine:
// 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);
};
send.js, standing in for the application. Replace it with your own send path once the arrangement works:
// 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();
})();
smtpd.jsand the certificate from How to test SMTP connection.smtpd.jsis the same catch-all without the guard, and step 5 uses it as the counter-example.default-host.js, four lines that show what an unset host does:
// 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
- 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 5948Both bind to
127.0.0.1and not to0.0.0.0. A catch-all reachable from the network is a mail relay that anybody on that network can use. - 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 sandboxThree
[egress]lines, all to127.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. - 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.logcaptured messages: 2 captured 319 bytes for qa@build.test captured 325 bytes for alerts@example.testTwo 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.
- Step 4.
Read one captured message.
cat capture/1789161415606.emlFrom: 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=abc123The 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.
- 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.comacceptedand a250. The test suite passes, nobody is mailed, and the address that should have failed the run is sitting incapture/where no one reads it. On the day the host is wrong, that address is the one that gets the mail. - Step 6.
Remove the SMTP host from the environment and send again.
node -r ./egress.js default-host.jstransport host=undefined port=undefined [egress] connect 127.0.0.1:587 [egress] connect ::1:587 failed: ESOCKET connect ECONNREFUSED ::1:587The 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. - 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
What to check next
- How to test SMTP connection: what the session looks like when this arrangement refuses a message.
- How to test transactional email rendering: what to check inside the messages this server captured.
- How to check if email bounced: the same local server made to refuse recipients, so the application's error path runs.
- How to check List-Unsubscribe header: a header worth asserting on in every captured bulk message.
- Email testing checklist: where this sits in a release that touches mail.
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.
Related on this site
intermediate12 minpublished updated Maks Verny