UNPKG

node-red-node-email

Version:

Node-RED nodes to send and receive simple emails.

312 lines (264 loc) 8.79 kB
/* eslint no-useless-escape: 0 */ 'use strict'; const { Buffer } = require('node:buffer'); const stream = require('node:stream'); const Transform = stream.Transform; /** * Encodes a Buffer into a Quoted-Printable encoded string * * @param {Buffer} buffer Buffer to convert * @returns {String} Quoted-Printable encoded string */ function encode(buffer) { if (typeof buffer === 'string') { buffer = Buffer.from(buffer, 'utf-8'); } // usable characters that do not need encoding let ranges = [ // https://tools.ietf.org/html/rfc2045#section-6.7 [0x09], // <TAB> [0x0a], // <LF> [0x0d], // <CR> [0x20, 0x3c], // <SP>!"#$%&'()*+,-./0123456789:; [0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|} ]; let result = ''; let ord; for (let i = 0, len = buffer.length; i < len; i++) { ord = buffer[i]; // if the char is in allowed range, then keep as is, unless it is a ws in the end of a line if (checkRanges(ord, ranges) && !((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) { result += String.fromCharCode(ord); continue; } result += '=' + (ord < 0x10 ? '0' : '') + ord.toString(16).toUpperCase(); } return result; } /** * Decodes a Quoted-Printable encoded string to a Buffer object * * @param {String} str Quoted-Printable encoded string * @returns {Buffer} Decoded value */ function decode(str) { str = (str || '') .toString() // remove invalid whitespace from the end of lines .replace(/[\t ]+$/gm, '') // remove soft line breaks .replace(/\=(?:\r?\n|$)/g, ''); let encodedBytesCount = (str.match(/\=[\da-fA-F]{2}/g) || []).length, bufferLength = str.length - encodedBytesCount * 2, chr, hex, buffer = Buffer.alloc(bufferLength), bufferPos = 0; for (let i = 0, len = str.length; i < len; i++) { chr = str.charAt(i); if (chr === '=' && (hex = str.substr(i + 1, 2)) && /[\da-fA-F]{2}/.test(hex)) { buffer[bufferPos++] = parseInt(hex, 16); i += 2; continue; } buffer[bufferPos++] = chr.charCodeAt(0); } return buffer; } /** * Adds soft line breaks to a Quoted-Printable string * * @param {String} str Quoted-Printable encoded string that might need line wrapping * @param {Number} [lineLength=76] Maximum allowed length for a line * @returns {String} Soft-wrapped Quoted-Printable encoded string */ function wrap(str, lineLength) { str = (str || '').toString(); lineLength = lineLength || 76; if (str.length <= lineLength) { return str; } let pos = 0, len = str.length, match, code, line, lineMargin = Math.floor(lineLength / 3), result = ''; // insert soft linebreaks where needed while (pos < len) { line = str.substr(pos, lineLength); if ((match = line.match(/\r\n/))) { line = line.substr(0, match.index + match[0].length); result += line; pos += line.length; continue; } if (line.substr(-1) === '\n') { // nothing to change here result += line; pos += line.length; continue; } else if ((match = line.substr(-lineMargin).match(/\n.*?$/))) { // truncate to nearest line break line = line.substr(0, line.length - (match[0].length - 1)); result += line; pos += line.length; continue; } else if (line.length > lineLength - lineMargin && (match = line.substr(-lineMargin).match(/[ \t\.,!\?][^ \t\.,!\?]*$/))) { // truncate to nearest space line = line.substr(0, line.length - (match[0].length - 1)); } else if (line.match(/\=[\da-f]{0,2}$/i)) { // push incomplete encoding sequences to the next line if ((match = line.match(/\=[\da-f]{0,1}$/i))) { line = line.substr(0, line.length - match[0].length); } // ensure that utf-8 sequences are not split while (line.length > 3 && line.length < len - pos && !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && (match = line.match(/\=[\da-f]{2}$/gi))) { code = parseInt(match[0].substr(1, 2), 16); if (code < 128) { break; } line = line.substr(0, line.length - 3); if (code >= 0xc0) { break; } } } if (pos + line.length < len && line.substr(-1) !== '\n') { if (line.length === lineLength && line.match(/\=[\da-f]{2}$/i)) { line = line.substr(0, line.length - 3); } else if (line.length === lineLength) { line = line.substr(0, line.length - 1); } pos += line.length; line += '=\r\n'; } else { pos += line.length; } result += line; } return result; } /** * Helper function to check if a number is inside provided ranges * * @param {Number} nr Number to check for * @param {Array} ranges An Array of allowed values * @returns {Boolean} True if the value was found inside allowed ranges, false otherwise */ function checkRanges(nr, ranges) { for (let i = ranges.length - 1; i >= 0; i--) { if (!ranges[i].length) { continue; } if (ranges[i].length === 1 && nr === ranges[i][0]) { return true; } if (ranges[i].length === 2 && nr >= ranges[i][0] && nr <= ranges[i][1]) { return true; } } return false; } /** * Creates a transform stream for encoding data to Quoted-Printable encoding * * @constructor * @param {Object} options Stream options * @param {Number} [options.lineLength=76] Maximum lenght for lines, set to false to disable wrapping */ class Encoder extends Transform { constructor(options) { super(); // init Transform this.options = options || {}; if (this.options.lineLength !== false) { this.options.lineLength = this.options.lineLength || 76; } this._curLine = ''; this.inputBytes = 0; this.outputBytes = 0; Transform.call(this, this.options); } _transform(chunk, encoding, done) { let qp; if (encoding !== 'buffer') { chunk = Buffer.from(chunk, encoding); } if (!chunk || !chunk.length) { return done(); } this.inputBytes += chunk.length; if (this.options.lineLength) { qp = this._curLine + encode(chunk); qp = wrap(qp, this.options.lineLength); qp = qp.replace(/(^|\n)([^\n]*)$/, (match, lineBreak, lastLine) => { this._curLine = lastLine; return lineBreak; }); if (qp) { this.outputBytes += qp.length; this.push(qp); } } else { qp = encode(chunk); this.outputBytes += qp.length; this.push(qp, 'ascii'); } done(); } _flush(done) { if (this._curLine) { this.outputBytes += this._curLine.length; this.push(this._curLine, 'ascii'); } done(); } } /** * Creates a transform stream for decoding Quoted-Printable encoded strings * The input is not actually processed as a stream but concatted and processed as a single input * * @constructor * @param {Object} options Stream options */ class Decoder extends Transform { constructor(options) { options = options || {}; super(options); // init Transform this.options = options; this._curLine = ''; this.inputBytes = 0; this.outputBytes = 0; this.qpChunks = []; } _transform(chunk, encoding, done) { if (!chunk || !chunk.length) { return done(); } if (typeof chunk === 'string') { chunk = Buffer.from(chunk, encoding); } this.qpChunks.push(chunk); this.inputBytes += chunk.length; done(); } _flush(done) { if (this.inputBytes) { let buf = decode(Buffer.concat(this.qpChunks, this.inputBytes).toString()); this.outputBytes += buf.length; this.push(buf); } done(); } } // expose to the world module.exports = { encode, decode, wrap, Encoder, Decoder };