The SMTPStream class is a Writable stream that parses incoming SMTP protocol data, detecting commands and switching between command mode and data mode for message content.
Overview
SMTPStream handles the SMTP protocol at the byte level, providing:
- Command parsing with newline detection
- Automatic mode switching between commands and data
- Dot-stuffing and dot-unstuffing for data transmission
- Backpressure handling for large messages
- Stream size limiting
Constructor
const { SMTPStream } = require('smtp-server/lib/smtp-stream');
let stream = new SMTPStream(options);
Options
Standard Node.js Writable stream options
Properties
Number of bytes emitted to the current data stream
Indicates if the stream has been closed
Internal flag indicating if stream is in data mode
Output stream for data mode content
Maximum allowed bytes for data stream (default: Infinity)
Unprocessed characters from last parsing iteration (command mode)
Unprocessed bytes from last parsing iteration (data mode)
Methods
oncommand(command, callback)
Command handler that must be implemented. This method is called for each SMTP command received.
The command line as a Buffer (excluding CRLF)
Callback to invoke when command processing is complete
stream.oncommand = (command, callback) => {
let line = command.toString();
console.log('Received command:', line);
callback();
};
You must override the oncommand method before using the stream, or it will throw an error: “Command handler is not set”
startDataMode(maxBytes)
Switches the stream to data mode and returns a PassThrough stream for reading message data.
Maximum number of bytes allowed in the data stream. If exceeded, sizeExceeded property is set to true.
Returns: PassThrough stream for reading unescaped message data
let dataStream = stream.startDataMode(10 * 1024 * 1024); // 10MB limit
dataStream.on('data', (chunk) => {
console.log('Received data chunk:', chunk.length, 'bytes');
});
dataStream.on('end', () => {
if (dataStream.sizeExceeded) {
console.log('Message exceeded size limit');
}
console.log('Total bytes:', dataStream.byteLength);
});
Data mode automatically handles dot-unstuffing according to SMTP protocol (RFC 5321). Leading dots (.) are removed from lines that begin with ..
continue()
Signals that data mode processing is complete and the stream should return to command mode.
dataStream.on('end', () => {
// Process the message
saveMessage(dataStream);
// Return to command mode
stream.continue();
});
You must call continue() after processing the data stream, or the SMTP connection will hang.
Command Mode
In command mode, the stream parses incoming data line-by-line, looking for \r\n or \n delimiters.
Parsing Logic
if (!this._dataMode) {
newlineRegex = /\r?\n/g;
data = this._remainder + chunk.toString('binary');
let readLine = () => {
let match;
let line;
// Search for the next newline
if ((match = newlineRegex.exec(data))) {
line = data.substr(pos, match.index - pos);
pos += line.length + match[0].length;
} else {
this._remainder = pos < data.length ? data.substr(pos) : '';
return done();
}
this.oncommand(Buffer.from(line, 'binary'), readLine);
};
readLine();
}
Mode Switching
If startDataMode() is called during command processing, the stream automatically switches modes and re-processes remaining bytes as data:
if (this._dataMode) {
buf = Buffer.from(data.substr(pos), 'binary');
this._remainder = '';
return this._write(buf, 'buffer', done);
}
Data Mode
In data mode, the stream processes binary data, handling SMTP dot-stuffing protocol and detecting the end-of-data sequence (\r\n.\r\n).
End Detection
The stream looks for the canonical SMTP data terminator:
let endseq = Buffer.from('\r\n.\r\n');
// Check if data starts with end terminator
if (!this.dataBytes && len >= 3 && Buffer.compare(chunk.slice(0, 3), Buffer.from('.\r\n')) === 0) {
this._endDataMode(false, chunk.slice(3), done);
return;
}
Dot Unstuffing
According to RFC 5321, lines beginning with a dot have the leading dot removed:
// Check if first symbol is an escape dot
if (!this.dataBytes && len >= 2 && chunk[0] === 0x2e && chunk[1] === 0x2e) {
chunk = chunk.slice(1);
len--;
}
// Check if dot is an escape char and remove it
if (chunk[i + 1] === 0x2e) {
buf = chunk.slice(0, i);
this._lastBytes = false;
this.dataBytes += buf.length;
if (this._dataStream.writable) {
this._dataStream.write(buf);
}
return setImmediate(() => this._feedDataStream(chunk.slice(i + 1), done));
}
Buffer Management
The stream keeps the last 4 bytes buffered to ensure proper end-sequence detection:
// Keep the last bytes
if (chunk.length < 4) {
this._lastBytes = chunk;
} else {
this._lastBytes = chunk.slice(chunk.length - 4);
}
// Emit available bytes
if (this._lastBytes.length < chunk.length) {
buf = chunk.slice(0, chunk.length - this._lastBytes.length);
this.dataBytes += buf.length;
if (this._dataStream.writable) {
handled = this._dataStream.write(buf);
if (!handled) {
this._dataStream.once('drain', done);
}
}
}
The stream implements proper backpressure by listening to the drain event when the data stream buffer is full.
Data Stream Properties
When data mode ends, the returned PassThrough stream is enhanced with additional properties:
Total number of bytes received in data mode
True if byteLength exceeds the maxBytes limit
this._dataStream.byteLength = this.dataBytes;
this._dataStream.sizeExceeded = this.dataBytes > this._maxBytes;
Complete Example
const { SMTPStream } = require('smtp-server/lib/smtp-stream');
const net = require('net');
let server = net.createServer((socket) => {
let stream = new SMTPStream();
let dataStream = null;
// Handle commands
stream.oncommand = (command, callback) => {
let line = command.toString().trim();
console.log('C:', line);
if (line.toUpperCase() === 'DATA') {
socket.write('354 Start mail input\r\n');
dataStream = stream.startDataMode(10 * 1024 * 1024);
dataStream.on('data', (chunk) => {
console.log('Received', chunk.length, 'bytes');
});
dataStream.on('end', () => {
console.log('Data complete:', dataStream.byteLength, 'bytes');
if (dataStream.sizeExceeded) {
socket.write('552 Message exceeds size limit\r\n');
} else {
socket.write('250 Message accepted\r\n');
}
stream.continue();
});
} else if (line.toUpperCase() === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
callback();
};
socket.pipe(stream);
socket.write('220 SMTP Server ready\r\n');
});
server.listen(2525);
Edge Cases
Flush on Stream End
When the input stream ends, any remaining buffered command is flushed:
this.on('finish', () => this._flushData());
_flushData() {
let line;
if (this._remainder && !this.isClosed) {
line = this._remainder;
this._remainder = '';
this.oncommand(Buffer.from(line, 'binary'));
}
}
Preventing Duplicate Callbacks
The stream protects against duplicate callback invocation:
let called = false;
let done = (...args) => {
if (called) {
return;
}
called = true;
next(...args);
};
Always call the callback in your oncommand handler to avoid blocking the stream.