UNPKG

node-red-node-email

Version:

Node-RED nodes to send and receive simple emails.

271 lines (230 loc) 8.67 kB
'use strict'; const stream = require('stream'); const Writable = stream.Writable; const PassThrough = stream.PassThrough; /** * Incoming SMTP stream parser. Detects and emits commands. If switched to * data mode, emits unescaped data events until final . * * @constructor * @param {Object} [options] Optional Stream options object */ class SMTPStream extends Writable { constructor(options) { // init Writable super(options); // Indicates if the stream is currently in data mode this._dataMode = false; // Output stream for the current data mode this._dataStream = null; // How many bytes are allowed for a data stream this._maxBytes = Infinity; // How many bytes have been emitted to data stream this.dataBytes = 0; // Callback to run once data mode is finished this._continueCallback = false; // unprocessed chars from the last parsing iteration (used in command mode) this._remainder = ''; // unprocessed bytes from the last parsing iteration (used in data mode) this._lastBytes = false; this.isClosed = false; // once the input stream ends, flush all output without expecting the newline this.on('finish', () => this._flushData()); } /** * Placeholder command handler. Override this with your own. */ oncommand(/* command, callback */) { throw new Error('Command handler is not set'); } /** * Switch to data mode and return output stream. The dots in the stream are unescaped. * * @returns {Stream} Data stream */ startDataMode(maxBytes) { this._dataMode = true; this._maxBytes = (maxBytes && Number(maxBytes)) || Infinity; this.dataBytes = 0; this._dataStream = new PassThrough(); return this._dataStream; } /** * Call this once data mode is over and you have finished processing the data stream */ continue() { if (typeof this._continueCallback === 'function') { this._continueCallback(); this._continueCallback = false; } else { // indicate that the 'continue' was already called once the stream actually ends this._continueCallback = true; } } // PRIVATE METHODS /** * Writable._write method. */ _write(chunk, encoding, next) { if (!chunk || !chunk.length) { return next(); } let data; let pos = 0; let newlineRegex; let called = false; let done = (...args) => { if (called) { return; } called = true; next(...args); }; if (this.isClosed) { return done(); } if (!this._dataMode) { newlineRegex = /\r?\n/g; data = this._remainder + chunk.toString('binary'); let readLine = () => { let match; let line; let buf; // check if the mode is not changed if (this._dataMode) { buf = Buffer.from(data.substr(pos), 'binary'); this._remainder = ''; return this._write(buf, 'buffer', done); } // search for the next newline // exec keeps count of the last match with lastIndex // so it knows from where to start with the next iteration 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); }; // start reading lines readLine(); } else { this._feedDataStream(chunk, done); } } /** * Processes a chunk in data mode. Escape dots are removed and final dot ends the data mode. */ _feedDataStream(chunk, done) { let i; let endseq = Buffer.from('\r\n.\r\n'); let len; let handled; let buf; if (this._lastBytes && this._lastBytes.length) { chunk = Buffer.concat([this._lastBytes, chunk], this._lastBytes.length + chunk.length); this._lastBytes = false; } len = chunk.length; // check if the data does not start with the 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; } // check if the first symbol is a escape dot if (!this.dataBytes && len >= 2 && chunk[0] === 0x2e && chunk[1] === 0x2e) { chunk = chunk.slice(1); len--; } // seek for the stream ending for (i = 2; i < len - 2; i++) { // if the dot is the first char in a line if (chunk[i] === 0x2e && chunk[i - 1] === 0x0a) { // if the dot matches end terminator if (Buffer.compare(chunk.slice(i - 2, i + 3), endseq) === 0) { if (i > 2) { buf = chunk.slice(0, i); this.dataBytes += buf.length; this._endDataMode(buf, chunk.slice(i + 3), done); } else { this._endDataMode(false, chunk.slice(i + 3), done); } return; } // check if the dot is an escape char and remove it if (chunk[i + 1] === 0x2e) { buf = chunk.slice(0, i); this._lastBytes = false; // clear remainder bytes this.dataBytes += buf.length; // increment byte counter // emit what we already have and continue without the dot if (this._dataStream.writable) { this._dataStream.write(buf); } return setImmediate(() => this._feedDataStream(chunk.slice(i + 1), done)); } } } // keep the last bytes if (chunk.length < 4) { this._lastBytes = chunk; } else { this._lastBytes = chunk.slice(chunk.length - 4); } // if current chunk is longer than the remainder bytes we keep for later emit the available bytes if (this._lastBytes.length < chunk.length) { buf = chunk.slice(0, chunk.length - this._lastBytes.length); this.dataBytes += buf.length; // write to stream but stop if need to wait for drain if (this._dataStream.writable) { handled = this._dataStream.write(buf); if (!handled) { this._dataStream.once('drain', done); } else { return done(); } } else { return done(); } } else { // nothing to emit, continue with the input stream return done(); } } /** * Flushes remaining bytes */ _flushData() { let line; if (this._remainder && !this.isClosed) { line = this._remainder; this._remainder = ''; this.oncommand(Buffer.from(line, 'binary')); } } /** * Ends data mode and returns to command mode. Stream is not resumed before #continue is called */ _endDataMode(chunk, remainder, callback) { if (this._continueCallback === true) { this._continueCallback = false; // wait until the stream is actually over and then continue this._dataStream.once('end', callback); } else { this._continueCallback = () => this._write(remainder, 'buffer', callback); } this._dataStream.byteLength = this.dataBytes; this._dataStream.sizeExceeded = this.dataBytes > this._maxBytes; if (chunk && chunk.length && this._dataStream.writable) { this._dataStream.end(chunk); } else { this._dataStream.end(); } this._dataMode = false; this._remainder = ''; this._dataStream = null; } } // Expose to the world module.exports.SMTPStream = SMTPStream;