smtp-server: standing up an SMTP or LMTP listener inside your Node app
Create custom SMTP servers on the fly
At a glance
- What is it?
- smtp-server is the Node.js module behind Nodemailer that creates SMTP and LMTP server instances programmatically, used for testing outbound mail, capturing messages, or bridging an SMTP client to an LMTP delivery path. It ships as an ES module and a CommonJS build, and it is explicit about which of its limits it does not enforce for you.
- Who is it for?
- smtp-server suits a Node developer who needs a real SMTP endpoint inside a test harness, a mail capture tool, or an LMTP bridge, and who wants the same protocol implementation the Nodemailer client speaks to rather than a mock that behaves differently from the wire. It does not suit anyone expecting a mail server that enforces policy on its own, because the size limit is only advertised and the enforcement is left to the application handler.
- Can I use it commercially?
- Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
An SMTP listener you construct in code, not a daemon you configure
smtp-server is a Node.js module for creating SMTP and LMTP server instances on the fly. It is the server side counterpart to Nodemailer, and the documentation and terms live on the smtp-server homepage at nodemailer.com/extras/smtp-server/.
The framing matters. This is a library, not a mail server product. There is no configuration file, no daemon to install, no queue, no retry policy, no relay. You construct an SMTPServer with an options object, listen on a port, and handle messages through callbacks. Everything a mail server does above the protocol, from storage to scheduling to reputation management, is absent by design.
That makes the intended users narrow and clear. A developer testing code that sends email wants a real endpoint to point at, and mocking the SMTP conversation by hand is tedious and fragile. A developer building an internal tool wants to capture messages for inspection. A developer with an LMTP delivery path wants to receive the final message over LMTP while accepting SMTP from a client, and LMTP is the protocol for exactly that handoff, so this module speaks both.
The example files reflect those cases: examples/server.js, examples/lmtp.js and a readme in the examples directory.
The alternative approaches do exist. A full mail server daemon handles storage, filtering and queueing but is a heavyweight dependency to bring up in a test suite, and configuring one to match a client's expectations takes longer than writing a handler. A hand-rolled fake SMTP responder is quick but will diverge from the protocol in the details that break in production, such as pipelining, chunking and the exact reply codes. smtp-server sits between the two: it is a real protocol implementation you mount inside your process, with no daemon and no state.
Two module formats and the type declarations that came with them
Installation is a single npm command.
npm install smtp-serverThe package ships both an ES module build and a CommonJS build, with TypeScript type declarations for both, which means there is no need for a separate @types/smtp-server package anymore. That is a real improvement for a library that has been around long enough to have had its own DefinitelyTyped entry, and it removes a version-skew failure mode where the types and the runtime drift apart.
The import style follows your application format.
// ES modules
import { SMTPServer } from 'smtp-server';
// CommonJS
const { SMTPServer } = require('smtp-server');The package manifest shows how this is wired. The package is marked as type module, with main pointing at the CommonJS build and an exports map that resolves the root entry differently for import and require, mapping to the ESM and CJS distributions respectively. Separate entries exist for the lib paths.
Deep imports into smtp-server/lib/* keep working, for example require('smtp-server/lib/smtp-connection'), and the exports map covers both the .js suffixed and bare lib paths so the deep import resolves under either module system. That backwards compatibility is deliberate, since applications in the wild reach into those internals.
In TypeScript the public types are exported from the package root: SMTPServerOptions, SMTPServerSession, SMTPServerAddress and SMTPServerDataStream. A typesVersions map redirects the legacy lib/*.js paths to the generated declaration files so that older type imports keep resolving.
The repository builds two distributions, with tsconfig.esm.json and tsconfig.cjs.json alongside a base config, and the build script is a node scripts/build.js. The test setup is unusually thorough for a library of this size, and the scripts are worth reading because they describe the compatibility matrix the project actually maintains.
The size option advertises a limit it does not enforce
The SIZE extension support is the clearest example of how the library draws a line between protocol work and application policy, and it is worth understanding before you rely on it.
Setting the size option advertises a maximum message size to clients. Declared sizes in MAIL FROM:<addr> SIZE=nnn parameters are checked against the limit. The actual transfer size is not enforced by the server itself, because message data is streamed to the application, and it is up to the application to decide what to do with an oversized message.
const server = new SMTPServer({
size: 1024 * 1024, // 1 MiB limit
onData(stream, session, callback) {
stream.on('end', () => {
if (stream.sizeExceeded) {
const err = new Error('Message too large');
err.responseCode = 552;
return callback(err);
}
callback(null, 'OK');
});
stream.resume(); // Consume the stream
}
});The example shows the intended pattern. You check stream.sizeExceeded in your onData handler and reject the message with a 552 response code once the data has been received.
Two details change how useful that is. The sizeExceeded flag and the byteLength counter on the data stream are updated in real time while the message is being received, so a handler that wants to stop an oversized transfer early can check the flag from a data event instead of waiting for end. That is the difference between rejecting a 500 MB message after storing it and aborting it partway through.
And the failure mode is quiet in the other direction. A client that does not declare SIZE=nnn in MAIL FROM is not stopped by the server. If your application never checks the flag, the limit is decorative.
For a test harness where you want to prove a client handles a rejection, this is exactly right. For a service exposed to untrusted senders, you need the check in your handler, and the example is the starting point.
XCLIENT and XFORWARD let a proxy pass through the real client address
The useXClient and useXForward options enable the Postfix XCLIENT and XFORWARD commands. These are the mechanisms an SMTP proxy uses to hand on the connection details of the client it is relaying, and with XCLIENT LOGIN also the identity.
The security note is stated bluntly: anyone allowed to use them can impersonate any client address, so the commands should only be available to the proxies themselves.
The option takes either true, which allows every connecting client, or an array of the IP addresses the proxies connect from.
const server = new SMTPServer({
useXClient: ['10.0.0.5', '10.0.0.6'], // only these hosts may send XCLIENT
useXForward: ['10.0.0.5', '10.0.0.6']
});The address comparison has a subtlety that catches people out. The address is compared with the one the connection was accepted from, and that is the address from the PROXY protocol header when useProxy is used. IPv4-mapped IPv6 addresses match their IPv4 form, so an allow list of ['10.0.0.5'] also covers a client that a dual-stack listener reports as ::ffff:10.0.0.5.
That is a sensible detail, because dual-stack listeners are the norm on modern Linux hosts and an allow list that silently stopped matching would be a slow, hard to diagnose failure.
The failure behaviour is also safe by default. Other clients do not see the commands in the EHLO response, and if they try them anyway they get a 550 reply. So the extension is invisible unless the connecting host is on the list, which is the behaviour you want when the proxy list is short and the internet is not.
The useProxy option takes the same true or array value, and covers the PROXY protocol header. These three options together are how you put smtp-server behind a proxy that preserves the original client address without letting anyone else forge it.
maxRecipients answers with the status code RFC 5321 asks for
Recipient count is capped with the maxRecipients option, and the behaviour follows the RFC closely enough that it is worth quoting.
By default a mail transaction can have any number of recipients. Once maxRecipients is set and the limit is reached, further RCPT TO commands are answered with 452 Error: too many recipients, with enhanced status code 4.5.3, as RFC 5321 section 4.5.3.1.10 prescribes. The client can continue to DATA with the recipients that were accepted, and a recipient that was already accepted does not count again.
const server = new SMTPServer({
maxRecipients: 100
});The last point is a small thing that shows the implementation is careful. If a client sends the same recipient twice, the second RCPT TO is not a new recipient and does not consume another slot. Naive counting would let a client exhaust the limit with duplicates, and would also mis-report how many distinct people a message is going to.
The 452 code with a 4.5.3 enhanced status code is a temporary failure, which is the correct class here: the client is told to retry later rather than that the command was permanently invalid. Well behaved clients handle the situation by delivering to the recipients already accepted.
What the option does not do is decide whether the recipients are the right ones, or whether the transaction should be allowed at all. It caps a count. Anything richer, such as per-domain limits or allow lists, belongs in the application.
A test matrix that runs the same suite across four runtimes
The scripts in the package manifest describe a compatibility matrix that is broader than most libraries of this size maintain, and it is worth understanding what the project is actually testing.
There is a separate CommonJS smoke test, run with NODE_OPTIONS set to --no-experimental-require-module, which suggests the project is deliberately testing the require path under conditions where Node's own require of ES modules is not available. That is the kind of edge that breaks silently when a library publishes both builds.
npm run test:cjsThere is a Bun target, running bun test with a sixty second timeout over the same test files, so the suite is expected to pass on Node and on Bun alike. There is a coverage target using c8 with the same runner flags.
The base test command itself is explicit about its isolation settings, with force exit, concurrency of one and a sixty second timeout, applied to every file matching the *-test.ts pattern under test/.
npm run testThe most interesting target is compare. There is a compare/ directory with a compare.ts script, and a postfix:start target that runs a shell script to start a real Postfix for comparison. That is a strong signal about the project's standards: it does not only test that smtp-server agrees with itself, it checks its behaviour against Postfix, the reference SMTP implementation.
The rest of the tooling is conventional but modern: eslint with a flat config, prettier with its own ignore file, TypeScript configs for the base, ESM and CJS builds, and a .c8rc.json for coverage. Release automation uses release-please, with a manifest and a config file, and there is a .ncurc.cjs for dependency update checks.
Release cadence matches the tooling. Versions 3.20.0, 3.20.1 and 3.20.2 were all published on 2026-10-18, and the last push was on 2026-10-19, so the tree is being worked on daily.
Licence, security reporting and what the server does not do
The licence field on the repository says Other, a custom licence GitHub cannot classify, with a pointer to the LICENSE file. This is a Nodemailer project, and the documentation and terms are published on the smtp-server homepage rather than only in the repository, so the terms worth reading are the ones published there. This is not legal advice, and for a library published on npm the point is simply that the terms are not what the GitHub metadata suggests.
Security has its own surface here. The SECURITY.md and SECURITY.txt files are both included in the published files list in the package manifest, which means the reporting instructions ship inside the installed package rather than only living on the repository page. For a server component that parses network input, that is the right call.
There is also a CLAUDE.md in the repository root, which usually holds instructions for coding agents working on the codebase, and the .github/ directory with its issue and pull request templates.
What the module deliberately does not do is the list worth keeping in mind. It does not store messages, so a message is delivered to your callback and then gone unless you keep it. It does not deliver anything, since it accepts mail rather than sending it, which is the mirror image of the Nodemailer client. It does not authenticate clients unless you implement that in your handlers, and it does not rate limit, throttle or apply policy beyond the recipient cap and the advertised size. It does not filter spam or scan content.
That list is not a set of complaints. It is the definition of the module's scope, and the reason it is a building block for mail testing and capture tools rather than a mail server you would point at production traffic.
Editorial conclusion
smtp-server suits a Node developer who needs a real SMTP endpoint inside a test harness, a mail capture tool, or an LMTP bridge, and who wants the same protocol implementation the Nodemailer client speaks to rather than a mock that behaves differently from the wire. It does not suit anyone expecting a mail server that enforces policy on its own, because the size limit is only advertised and the enforcement is left to the application handler. Before adopting it, read the size option carefully, because a limit that only produces a flag at the end of the stream is not a limit, and if you enable useXClient or useXForward, restrict them to the proxy addresses rather than setting them to true.
Frequently asked questions
What is the smtp-server npm package used for?
It is a Node.js module for creating SMTP and LMTP server instances on the fly, so you can run a real SMTP endpoint inside a Node process for testing, capturing messages or bridging to an LMTP delivery path. It is the server side counterpart to Nodemailer.
Does smtp-server enforce a maximum message size?
Not by itself. The size option advertises a maximum size to clients and checks declared sizes in MAIL FROM SIZE=nnn parameters, but the actual transfer is not enforced because data is streamed to the application. You check stream.sizeExceeded in your onData handler and reject the message yourself.
How do I limit the number of recipients in smtp-server?
Set the maxRecipients option. Once the limit is reached, further RCPT TO commands get a 452 Error: too many recipients with enhanced status code 4.5.3, and the client can continue to DATA with the recipients already accepted. A duplicate recipient does not count again.
What do the useXClient and useXForward options do?
They enable the Postfix XCLIENT and XFORWARD commands so an SMTP proxy can pass on the real client address and, with XCLIENT LOGIN, the identity. They take true or an array of allowed proxy addresses, and anyone allowed to use them can impersonate any client address.
Does smtp-server ship TypeScript types?
Yes. The package ships both an ES module build and a CommonJS build with TypeScript declarations for both, so a separate @types/smtp-server package is no longer needed. Types such as SMTPServerOptions, SMTPServerSession, SMTPServerAddress and SMTPServerDataStream are exported from the package root.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/nodemailer-smtp-server)Was this useful?