import fs from 'fs'; import { hostname } from 'os'; import { Stream } from 'stream'; import { TextEncoder, TextDecoder } from 'util'; import { createHmac } from 'crypto'; import { EventEmitter } from 'events'; import { Socket } from 'net'; import { connect, TLSSocket, createSecureContext } from 'tls'; /* * Operator tokens and which tokens are expected to end the sequence */ const OPERATORS = new Map([ ['"', '"'], ['(', ')'], ['<', '>'], [',', ''], // Groups are ended by semicolons [':', ';'], // Semicolons are not a legal delimiter per the RFC2822 grammar other // than for terminating a group, but they are also not valid for any // other use in this context. Given that some mail clients have // historically allowed the semicolon as a delimiter equivalent to the // comma in their UI, it makes sense to treat them the same as a comma // when used outside of a group. [';', ''], ]); /** * Tokenizes the original input string * * @param {string | string[] | undefined} address string(s) to tokenize * @return {AddressToken[]} An array of operator|text tokens */ function tokenizeAddress(address = '') { var _a, _b; const tokens = []; let token = undefined; let operator = undefined; for (const character of address.toString()) { if (((_a = operator === null || operator === void 0 ? void 0 : operator.length) !== null && _a !== void 0 ? _a : 0) > 0 && character === operator) { tokens.push({ type: 'operator', value: character }); token = undefined; operator = undefined; } else if (((_b = operator === null || operator === void 0 ? void 0 : operator.length) !== null && _b !== void 0 ? _b : 0) === 0 && OPERATORS.has(character)) { tokens.push({ type: 'operator', value: character }); token = undefined; operator = OPERATORS.get(character); } else { if (token == null) { token = { type: 'text', value: character }; tokens.push(token); } else { token.value += character; } } } return tokens .map((x) => { x.value = x.value.trim(); return x; }) .filter((x) => x.value.length > 0); } /** * Converts tokens for a single address into an address object * * @param {AddressToken[]} tokens Tokens object * @return {AddressObject[]} addresses object array */ function convertAddressTokens(tokens) { const addressObjects = []; const groups = []; let addresses = []; let comments = []; let texts = []; let state = 'text'; let isGroup = false; function handleToken(token) { if (token.type === 'operator') { switch (token.value) { case '<': state = 'address'; break; case '(': state = 'comment'; break; case ':': state = 'group'; isGroup = true; break; default: state = 'text'; break; } } else if (token.value.length > 0) { switch (state) { case 'address': addresses.push(token.value); break; case 'comment': comments.push(token.value); break; case 'group': groups.push(token.value); break; default: texts.push(token.value); break; } } } // Filter out , (comments) and regular text for (const token of tokens) { handleToken(token); } // If there is no text but a comment, replace the two if (texts.length === 0 && comments.length > 0) { texts = [...comments]; comments = []; } // http://tools.ietf.org/html/rfc2822#appendix-A.1.3 if (isGroup) { addressObjects.push({ name: texts.length === 0 ? undefined : texts.join(' '), group: groups.length > 0 ? addressparser(groups.join(',')) : [], }); } else { // If no address was found, try to detect one from regular text if (addresses.length === 0 && texts.length > 0) { for (let i = texts.length - 1; i >= 0; i--) { if (texts[i].match(/^[^@\s]+@[^@\s]+$/)) { addresses = texts.splice(i, 1); break; } } // still no address if (addresses.length === 0) { for (let i = texts.length - 1; i >= 0; i--) { texts[i] = texts[i] .replace(/\s*\b[^@\s]+@[^@\s]+\b\s*/, (address) => { if (addresses.length === 0) { addresses = [address.trim()]; return ' '; } else { return address; } }) .trim(); if (addresses.length > 0) { break; } } } } // If there's still is no text but a comment exixts, replace the two if (texts.length === 0 && comments.length > 0) { texts = [...comments]; comments = []; } // Keep only the first address occurence, push others to regular text if (addresses.length > 1) { texts = [...texts, ...addresses.splice(1)]; } if (addresses.length === 0 && isGroup) { return []; } else { // Join values with spaces let address = addresses.join(' '); let name = texts.length === 0 ? address : texts.join(' '); if (address === name) { if (address.match(/@/)) { name = ''; } else { address = ''; } } addressObjects.push({ address, name }); } } return addressObjects; } /** * Parses structured e-mail addresses from an address field * * Example: * * "Name " * * will be converted to * * [{name: "Name", address: "address@domain"}] * * @param {string | string[] | undefined} address Address field * @return {AddressObject[]} An array of address objects */ function addressparser(address) { const addresses = []; let tokens = []; for (const token of tokenizeAddress(address)) { if (token.type === 'operator' && (token.value === ',' || token.value === ';')) { if (tokens.length > 0) { addresses.push(...convertAddressTokens(tokens)); } tokens = []; } else { tokens.push(token); } } if (tokens.length > 0) { addresses.push(...convertAddressTokens(tokens)); } return addresses; } /** * @param {Date} [date] an optional date to convert to RFC2822 format * @param {boolean} [useUtc] whether to parse the date as UTC (default: false) * @returns {string} the converted date */ function getRFC2822Date(date = new Date(), useUtc = false) { if (useUtc) { return getRFC2822DateUTC(date); } const dates = date .toString() .replace('GMT', '') .replace(/\s\(.*\)$/, '') .split(' '); dates[0] = dates[0] + ','; const day = dates[1]; dates[1] = dates[2]; dates[2] = day; return dates.join(' '); } /** * @param {Date} [date] an optional date to convert to RFC2822 format (UTC) * @returns {string} the converted date */ function getRFC2822DateUTC(date = new Date()) { const dates = date.toUTCString().split(' '); dates.pop(); // remove timezone dates.push('+0000'); return dates.join(' '); } /** * RFC 2822 regex * @see https://tools.ietf.org/html/rfc2822#section-3.3 * @see https://github.com/moment/moment/blob/a831fc7e2694281ce31e4f090bbcf90a690f0277/src/lib/create/from-string.js#L101 */ const rfc2822re = /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/.compile(); /** * @param {string} [date] a string to check for conformance to the [rfc2822](https://tools.ietf.org/html/rfc2822#section-3.3) standard * @returns {boolean} the result of the conformance check */ function isRFC2822Date(date) { return rfc2822re.test(date); } // adapted from https://github.com/emailjs/emailjs-mime-codec/blob/6909c706b9f09bc0e5c3faf48f723cca53e5b352/src/mimecodec.js const encoder = new TextEncoder(); /** * @see https://tools.ietf.org/html/rfc2045#section-6.7 */ const RANGES = [ [0x09], [0x0a], [0x0d], [0x20, 0x3c], [0x3e, 0x7e], ]; const LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(''); const MAX_CHUNK_LENGTH = 16383; // must be multiple of 3 const MAX_MIME_WORD_LENGTH = 52; const MAX_B64_MIME_WORD_BYTE_LENGTH = 39; function tripletToBase64(num) { return (LOOKUP[(num >> 18) & 0x3f] + LOOKUP[(num >> 12) & 0x3f] + LOOKUP[(num >> 6) & 0x3f] + LOOKUP[num & 0x3f]); } function encodeChunk(uint8, start, end) { let output = ''; for (let i = start; i < end; i += 3) { output += tripletToBase64((uint8[i] << 16) + (uint8[i + 1] << 8) + uint8[i + 2]); } return output; } function encodeBase64(data) { const len = data.length; const extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes let output = ''; // go through the array every three bytes, we'll deal with trailing stuff later for (let i = 0, len2 = len - extraBytes; i < len2; i += MAX_CHUNK_LENGTH) { output += encodeChunk(data, i, i + MAX_CHUNK_LENGTH > len2 ? len2 : i + MAX_CHUNK_LENGTH); } // pad the end with zeros, but make sure to not forget the extra bytes if (extraBytes === 1) { const tmp = data[len - 1]; output += LOOKUP[tmp >> 2]; output += LOOKUP[(tmp << 4) & 0x3f]; output += '=='; } else if (extraBytes === 2) { const tmp = (data[len - 2] << 8) + data[len - 1]; output += LOOKUP[tmp >> 10]; output += LOOKUP[(tmp >> 4) & 0x3f]; output += LOOKUP[(tmp << 2) & 0x3f]; output += '='; } return output; } /** * Splits a mime encoded string. Needed for dividing mime words into smaller chunks * * @param {string} str Mime encoded string to be split up * @param {number} maxlen Maximum length of characters for one part (minimum 12) * @return {string[]} lines */ function splitMimeEncodedString(str, maxlen = 12) { const minWordLength = 12; // require at least 12 symbols to fit possible 4 octet UTF-8 sequences const maxWordLength = Math.max(maxlen, minWordLength); const lines = []; while (str.length) { let curLine = str.substr(0, maxWordLength); const match = curLine.match(/=[0-9A-F]?$/i); // skip incomplete escaped char if (match) { curLine = curLine.substr(0, match.index); } let done = false; while (!done) { let chr; done = true; const match = str.substr(curLine.length).match(/^=([0-9A-F]{2})/i); // check if not middle of a unicode char sequence if (match) { chr = parseInt(match[1], 16); // invalid sequence, move one char back anc recheck if (chr < 0xc2 && chr > 0x7f) { curLine = curLine.substr(0, curLine.length - 3); done = false; } } } if (curLine.length) { lines.push(curLine); } str = str.substr(curLine.length); } return lines; } /** * * @param {number} nr number * @returns {boolean} if number is in range */ function checkRanges(nr) { return RANGES.reduce((val, range) => val || (range.length === 1 && nr === range[0]) || (range.length === 2 && nr >= range[0] && nr <= range[1]), false); } /** * Encodes all non printable and non ascii bytes to =XX form, where XX is the * byte value in hex. This function does not convert linebreaks etc. it * only escapes character sequences * * NOTE: Encoding support depends on util.TextDecoder, which is severely limited * prior to Node.js 13. * * @see https://nodejs.org/api/util.html#util_whatwg_supported_encodings * @see https://github.com/nodejs/node/issues/19214 * * @param {string|Uint8Array} data Either a string or an Uint8Array * @param {string} encoding WHATWG supported encoding * @return {string} Mime encoded string */ function mimeEncode(data = '', encoding = 'utf-8') { const decoder = new TextDecoder(encoding); const buffer = typeof data === 'string' ? encoder.encode(data) : encoder.encode(decoder.decode(data)); return buffer.reduce((aggregate, ord, index) => checkRanges(ord) && !((ord === 0x20 || ord === 0x09) && (index === buffer.length - 1 || buffer[index + 1] === 0x0a || buffer[index + 1] === 0x0d)) ? // if the char is in allowed range, then keep as is, unless it is a ws in the end of a line aggregate + String.fromCharCode(ord) : `${aggregate}=${ord < 0x10 ? '0' : ''}${ord .toString(16) .toUpperCase()}`, ''); } /** * Encodes a string or an Uint8Array to an UTF-8 MIME Word * * NOTE: Encoding support depends on util.TextDecoder, which is severely limited * prior to Node.js 13. * * @see https://tools.ietf.org/html/rfc2047 * @see https://nodejs.org/api/util.html#util_whatwg_supported_encodings * @see https://github.com/nodejs/node/issues/19214 * * @param {string|Uint8Array} data String to be encoded * @param {'Q' | 'B'} mimeWordEncoding='Q' Encoding for the mime word, either Q or B * @param {string} encoding WHATWG supported encoding * @return {string} Single or several mime words joined together */ function mimeWordEncode(data, mimeWordEncoding = 'Q', encoding = 'utf-8') { let parts = []; const decoder = new TextDecoder(encoding); const str = typeof data === 'string' ? data : decoder.decode(data); if (mimeWordEncoding === 'Q') { const encodedStr = mimeEncode(str, encoding).replace(/[^a-z0-9!*+\-/=]/gi, (chr) => chr === ' ' ? '_' : '=' + (chr.charCodeAt(0) < 0x10 ? '0' : '') + chr.charCodeAt(0).toString(16).toUpperCase()); parts = encodedStr.length < MAX_MIME_WORD_LENGTH ? [encodedStr] : splitMimeEncodedString(encodedStr, MAX_MIME_WORD_LENGTH); } else { // Fits as much as possible into every line without breaking utf-8 multibyte characters' octets up across lines let j = 0; let i = 0; while (i < str.length) { if (encoder.encode(str.substring(j, i)).length > MAX_B64_MIME_WORD_BYTE_LENGTH) { // we went one character too far, substring at the char before parts.push(str.substring(j, i - 1)); j = i - 1; } else { i++; } } // add the remainder of the string str.substring(j) && parts.push(str.substring(j)); parts = parts.map((x) => encoder.encode(x)).map((x) => encodeBase64(x)); } return parts .map((p) => `=?UTF-8?${mimeWordEncoding}?${p}?= `) .join('') .trim(); } const CRLF = '\r\n'; /** * MIME standard wants 76 char chunks when sending out. */ const MIMECHUNK = 76; /** * meets both base64 and mime divisibility */ const MIME64CHUNK = (MIMECHUNK * 6); /** * size of the message stream buffer */ const BUFFERSIZE = (MIMECHUNK * 24 * 7); let counter = 0; function generateBoundary() { let text = ''; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'()+_,-./:=?"; for (let i = 0; i < 69; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } function convertPersonToAddress(person) { return addressparser(person) .map(({ name, address }) => { return name ? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>` : address; }) .join(', '); } function convertDashDelimitedTextToSnakeCase(text) { return text .toLowerCase() .replace(/^(.)|-(.)/g, (match) => match.toUpperCase()); } class Message { /** * Construct an rfc2822-compliant message object. * * Special notes: * - The `from` field is required. * - At least one `to`, `cc`, or `bcc` header is also required. * - You can also add whatever other headers you want. * * @see https://tools.ietf.org/html/rfc2822 * @param {Partial} headers Message headers */ constructor(headers) { this.attachments = []; this.header = { 'message-id': `<${new Date().getTime()}.${counter++}.${process.pid}@${hostname()}>`, date: getRFC2822Date(), }; this.content = 'text/plain; charset=utf-8'; this.alternative = null; for (const header in headers) { // allow user to override default content-type to override charset or send a single non-text message if (/^content-type$/i.test(header)) { this.content = headers[header]; } else if (header === 'text') { this.text = headers[header]; } else if (header === 'attachment' && typeof headers[header] === 'object') { const attachment = headers[header]; if (Array.isArray(attachment)) { for (let i = 0; i < attachment.length; i++) { this.attach(attachment[i]); } } else if (attachment != null) { this.attach(attachment); } } else if (header === 'subject') { this.header.subject = mimeWordEncode(headers.subject); } else if (/^(cc|bcc|to|from)/i.test(header)) { this.header[header.toLowerCase()] = convertPersonToAddress(headers[header]); } else { // allow any headers the user wants to set?? this.header[header.toLowerCase()] = headers[header]; } } } /** * Attach a file to the message. * * Can be called multiple times, each adding a new attachment. * * @public * @param {MessageAttachment} options attachment options * @returns {Message} the current instance for chaining */ attach(options) { // sender can specify an attachment as an alternative if (options.alternative) { this.alternative = options; this.alternative.charset = options.charset || 'utf-8'; this.alternative.type = options.type || 'text/html'; this.alternative.inline = true; } else { this.attachments.push(options); } return this; } /** * @public * @param {function(isValid: boolean, invalidReason: string): void} callback . * @returns {void} */ valid(callback) { if (typeof this.header.from !== 'string' && Array.isArray(this.header.from) === false) { callback(false, 'Message must have a `from` header'); } else if (typeof this.header.to !== 'string' && Array.isArray(this.header.to) === false && typeof this.header.cc !== 'string' && Array.isArray(this.header.cc) === false && typeof this.header.bcc !== 'string' && Array.isArray(this.header.bcc) === false) { callback(false, 'Message must have at least one `to`, `cc`, or `bcc` header'); } else if (this.attachments.length === 0) { callback(true, undefined); } else { const failed = []; this.attachments.forEach((attachment) => { if (attachment.path) { if (fs.existsSync(attachment.path) == false) { failed.push(`${attachment.path} does not exist`); } } else if (attachment.stream) { if (!attachment.stream.readable) { failed.push('attachment stream is not readable'); } } else if (!attachment.data) { failed.push('attachment has no data associated with it'); } }); callback(failed.length === 0, failed.join(', ')); } } /** * @public * @returns {MessageStream} a stream of the current message */ stream() { return new MessageStream(this); } /** * @public * @param {function(Error, string): void} callback the function to call with the error and buffer * @returns {void} */ read(callback) { let buffer = ''; const str = this.stream(); str.on('data', (data) => (buffer += data)); str.on('end', (err) => callback(err, buffer)); str.on('error', (err) => callback(err, buffer)); } } class MessageStream extends Stream { /** * @param {Message} message the message to stream */ constructor(message) { super(); this.message = message; this.readable = true; this.paused = false; this.buffer = Buffer.alloc(MIMECHUNK * 24 * 7); this.bufferIndex = 0; /** * @param {string} [data] the data to output * @param {Function} [callback] the function * @param {any[]} [args] array of arguments to pass to the callback * @returns {void} */ const output = (data) => { // can we buffer the data? if (this.buffer != null) { const bytes = Buffer.byteLength(data); if (bytes + this.bufferIndex < this.buffer.length) { this.buffer.write(data, this.bufferIndex); this.bufferIndex += bytes; } // we can't buffer the data, so ship it out! else if (bytes > this.buffer.length) { if (this.bufferIndex) { this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex)); this.bufferIndex = 0; } const loops = Math.ceil(data.length / this.buffer.length); let loop = 0; while (loop < loops) { this.emit('data', data.substring(this.buffer.length * loop, this.buffer.length * (loop + 1))); loop++; } } // we need to clean out the buffer, it is getting full else { if (!this.paused) { this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex)); this.buffer.write(data, 0); this.bufferIndex = bytes; } else { // we can't empty out the buffer, so let's wait till we resume before adding to it this.once('resume', () => output(data)); } } } }; /** * @param {MessageAttachment} [attachment] the attachment whose headers you would like to output * @returns {void} */ const outputAttachmentHeaders = (attachment) => { let data = []; const headers = { 'content-type': attachment.type + (attachment.charset ? `; charset=${attachment.charset}` : '') + (attachment.method ? `; method=${attachment.method}` : ''), 'content-transfer-encoding': 'base64', 'content-disposition': attachment.inline ? 'inline' : `attachment; filename="${mimeWordEncode(attachment.name)}"`, }; // allow sender to override default headers if (attachment.headers != null) { for (const header in attachment.headers) { headers[header.toLowerCase()] = attachment.headers[header]; } } for (const header in headers) { data = data.concat([ convertDashDelimitedTextToSnakeCase(header), ': ', headers[header], CRLF, ]); } output(data.concat([CRLF]).join('')); }; /** * @param {string} data the data to output as base64 * @param {function(): void} [callback] the function to call after output is finished * @returns {void} */ const outputBase64 = (data, callback) => { const loops = Math.ceil(data.length / MIMECHUNK); let loop = 0; while (loop < loops) { output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF); loop++; } if (callback) { callback(); } }; const outputFile = (attachment, next) => { const chunk = MIME64CHUNK * 16; const buffer = Buffer.alloc(chunk); const closed = (fd) => fs.closeSync(fd); /** * @param {Error} err the error to emit * @param {number} fd the file descriptor * @returns {void} */ const opened = (err, fd) => { if (!err) { const read = (err, bytes) => { if (!err && this.readable) { let encoding = attachment && attachment.headers ? attachment.headers['content-transfer-encoding'] || 'base64' : 'base64'; if (encoding === 'ascii' || encoding === '7bit') { encoding = 'ascii'; } else if (encoding === 'binary' || encoding === '8bit') { encoding = 'binary'; } else { encoding = 'base64'; } // guaranteed to be encoded without padding unless it is our last read outputBase64(buffer.toString(encoding, 0, bytes), () => { if (bytes == chunk) { // we read a full chunk, there might be more fs.read(fd, buffer, 0, chunk, null, read); } // that was the last chunk, we are done reading the file else { this.removeListener('error', closed); fs.close(fd, next); } }); } else { this.emit('error', err || { message: 'message stream was interrupted somehow!' }); } }; fs.read(fd, buffer, 0, chunk, null, read); this.once('error', closed); } else { this.emit('error', err); } }; fs.open(attachment.path, 'r', opened); }; /** * @param {MessageAttachment} attachment the metadata to use as headers * @param {function(): void} callback the function to call after output is finished * @returns {void} */ const outputStream = (attachment, callback) => { const { stream } = attachment; if (stream === null || stream === void 0 ? void 0 : stream.readable) { let previous = Buffer.alloc(0); stream.resume(); stream.on('end', () => { outputBase64(previous.toString('base64'), callback); this.removeListener('pause', stream.pause); this.removeListener('resume', stream.resume); this.removeListener('error', stream.resume); }); stream.on('data', (buff) => { // do we have bytes from a previous stream data event? let buffer = Buffer.isBuffer(buff) ? buff : Buffer.from(buff); if (previous.byteLength > 0) { buffer = Buffer.concat([previous, buffer]); } const padded = buffer.length % MIME64CHUNK; previous = Buffer.alloc(padded); // encode as much of the buffer to base64 without empty bytes if (padded > 0) { // copy dangling bytes into previous buffer buffer.copy(previous, 0, buffer.length - padded); } outputBase64(buffer.toString('base64', 0, buffer.length - padded)); }); this.on('pause', stream.pause); this.on('resume', stream.resume); this.on('error', stream.resume); } else { this.emit('error', { message: 'stream not readable' }); } }; const outputAttachment = (attachment, callback) => { const build = attachment.path ? outputFile : attachment.stream ? outputStream : outputData; outputAttachmentHeaders(attachment); build(attachment, callback); }; /** * @param {string} boundary the boundary text between outputs * @param {MessageAttachment[]} list the list of potential messages to output * @param {number} index the index of the list item to output * @param {function(): void} callback the function to call if index is greater than upper bound * @returns {void} */ const outputMessage = (boundary, list, index, callback) => { if (index < list.length) { output(`--${boundary}${CRLF}`); if (list[index].related) { outputRelated(list[index], () => outputMessage(boundary, list, index + 1, callback)); } else { outputAttachment(list[index], () => outputMessage(boundary, list, index + 1, callback)); } } else { output(`${CRLF}--${boundary}--${CRLF}${CRLF}`); callback(); } }; const outputMixed = () => { const boundary = generateBoundary(); output(`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`); if (this.message.alternative == null) { outputText(this.message); outputMessage(boundary, this.message.attachments, 0, close); } else { outputAlternative( // typescript bug; should narrow to { alternative: MessageAttachment } this.message, () => outputMessage(boundary, this.message.attachments, 0, close)); } }; /** * @param {MessageAttachment} attachment the metadata to use as headers * @param {function(): void} callback the function to call after output is finished * @returns {void} */ const outputData = (attachment, callback) => { var _a, _b; outputBase64(attachment.encoded ? (_a = attachment.data) !== null && _a !== void 0 ? _a : '' : Buffer.from((_b = attachment.data) !== null && _b !== void 0 ? _b : '').toString('base64'), callback); }; /** * @param {Message} message the message to output * @returns {void} */ const outputText = (message) => { let data = []; data = data.concat([ 'Content-Type:', message.content, CRLF, 'Content-Transfer-Encoding: 7bit', CRLF, ]); data = data.concat(['Content-Disposition: inline', CRLF, CRLF]); data = data.concat([message.text || '', CRLF, CRLF]); output(data.join('')); }; /** * @param {MessageAttachment} message the message to output * @param {function(): void} callback the function to call after output is finished * @returns {void} */ const outputRelated = (message, callback) => { const boundary = generateBoundary(); output(`Content-Type: multipart/related; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`); outputAttachment(message, () => { var _a; outputMessage(boundary, (_a = message.related) !== null && _a !== void 0 ? _a : [], 0, () => { output(`${CRLF}--${boundary}--${CRLF}${CRLF}`); callback(); }); }); }; /** * @param {Message} message the message to output * @param {function(): void} callback the function to call after output is finished * @returns {void} */ const outputAlternative = (message, callback) => { const boundary = generateBoundary(); output(`Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`); outputText(message); output(`--${boundary}${CRLF}`); /** * @returns {void} */ const finish = () => { output([CRLF, '--', boundary, '--', CRLF, CRLF].join('')); callback(); }; if (message.alternative.related) { outputRelated(message.alternative, finish); } else { outputAttachment(message.alternative, finish); } }; const close = (err) => { var _a, _b; if (err) { this.emit('error', err); } else { this.emit('data', (_b = (_a = this.buffer) === null || _a === void 0 ? void 0 : _a.toString('utf-8', 0, this.bufferIndex)) !== null && _b !== void 0 ? _b : ''); this.emit('end'); } this.buffer = null; this.bufferIndex = 0; this.readable = false; this.removeAllListeners('resume'); this.removeAllListeners('pause'); this.removeAllListeners('error'); this.removeAllListeners('data'); this.removeAllListeners('end'); }; /** * @returns {void} */ const outputHeaderData = () => { if (this.message.attachments.length || this.message.alternative) { output(`MIME-Version: 1.0${CRLF}`); outputMixed(); } // you only have a text message! else { outputText(this.message); close(); } }; /** * @returns {void} */ const outputHeader = () => { let data = []; for (const header in this.message.header) { // do not output BCC in the headers (regex) nor custom Object.prototype functions... if (!/bcc/i.test(header) && Object.prototype.hasOwnProperty.call(this.message.header, header)) { data = data.concat([ convertDashDelimitedTextToSnakeCase(header), ': ', this.message.header[header], CRLF, ]); } } output(data.join('')); outputHeaderData(); }; this.once('destroy', close); process.nextTick(outputHeader); } /** * @public * pause the stream * @returns {void} */ pause() { this.paused = true; this.emit('pause'); } /** * @public * resume the stream * @returns {void} */ resume() { this.paused = false; this.emit('resume'); } /** * @public * destroy the stream * @returns {void} */ destroy() { this.emit('destroy', this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null); } /** * @public * destroy the stream at first opportunity * @returns {void} */ destroySoon() { this.emit('destroy'); } } /** * @readonly * @enum */ const SMTPErrorStates = { COULDNOTCONNECT: 1, BADRESPONSE: 2, AUTHFAILED: 3, TIMEDOUT: 4, ERROR: 5, NOCONNECTION: 6, AUTHNOTSUPPORTED: 7, CONNECTIONCLOSED: 8, CONNECTIONENDED: 9, CONNECTIONAUTH: 10, }; class SMTPError extends Error { /** * @protected * @param {string} message error message */ constructor(message) { super(message); this.code = null; this.smtp = null; this.previous = null; } /** * * @param {string} message error message * @param {number} code smtp error state * @param {Error | null} error previous error * @param {unknown} smtp arbitrary data * @returns {SMTPError} error */ static create(message, code, error, smtp) { const msg = (error === null || error === void 0 ? void 0 : error.message) ? `${message} (${error.message})` : message; const err = new SMTPError(msg); err.code = code; err.smtp = smtp; if (error) { err.previous = error; } return err; } } class SMTPResponseMonitor { constructor(stream, timeout, onerror) { let buffer = ''; const notify = () => { var _a, _b; if (buffer.length) { // parse buffer for response codes const line = buffer.replace('\r', ''); if (!((_b = (_a = line .trim() .split(/\n/) .pop()) === null || _a === void 0 ? void 0 : _a.match(/^(\d{3})\s/)) !== null && _b !== void 0 ? _b : false)) { return; } const match = line ? line.match(/(\d+)\s?(.*)/) : null; const data = match !== null ? { code: match[1], message: match[2], data: line } : { code: -1, data: line }; stream.emit('response', null, data); buffer = ''; } }; const error = (err) => { stream.emit('response', SMTPError.create('connection encountered an error', SMTPErrorStates.ERROR, err)); }; const timedout = (err) => { stream.end(); stream.emit('response', SMTPError.create('timedout while connecting to smtp server', SMTPErrorStates.TIMEDOUT, err)); }; const watch = (data) => { if (data !== null) { buffer += data.toString(); notify(); } }; const close = (err) => { stream.emit('response', SMTPError.create('connection has closed', SMTPErrorStates.CONNECTIONCLOSED, err)); }; const end = (err) => { stream.emit('response', SMTPError.create('connection has ended', SMTPErrorStates.CONNECTIONENDED, err)); }; this.stop = (err) => { stream.removeAllListeners('response'); stream.removeListener('data', watch); stream.removeListener('end', end); stream.removeListener('close', close); stream.removeListener('error', error); if (err != null && typeof onerror === 'function') { onerror(err); } }; stream.on('data', watch); stream.on('end', end); stream.on('close', close); stream.on('error', error); stream.setTimeout(timeout, timedout); } } /** * @readonly * @enum */ const AUTH_METHODS = { PLAIN: 'PLAIN', 'CRAM-MD5': 'CRAM-MD5', LOGIN: 'LOGIN', XOAUTH2: 'XOAUTH2', }; /** * @readonly * @enum */ const SMTPState = { NOTCONNECTED: 0, CONNECTING: 1, CONNECTED: 2, }; const DEFAULT_TIMEOUT = 5000; const SMTP_PORT = 25; const SMTP_SSL_PORT = 465; const SMTP_TLS_PORT = 587; const CRLF$1 = '\r\n'; const GREYLIST_DELAY = 300; let DEBUG = 0; /** * @param {...any[]} args the message(s) to log * @returns {void} */ const log = (...args) => { if (DEBUG === 1) { args.forEach((d) => console.log(typeof d === 'object' ? d instanceof Error ? d.message : JSON.stringify(d) : d)); } }; /** * @param {function(...any[]): void} callback the function to call * @param {...any[]} args the arguments to apply to the function * @returns {void} */ const caller = (callback, ...args) => { if (typeof callback === 'function') { callback(...args); } }; class SMTPConnection extends EventEmitter { /** * SMTP class written using python's (2.7) smtplib.py as a base. * * To target a Message Transfer Agent (MTA), omit all options. * * NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration. */ constructor({ timeout, host, user, password, domain, port, ssl, tls, logger, authentication, } = {}) { var _a; super(); this.timeout = DEFAULT_TIMEOUT; this.log = log; this.authentication = [ AUTH_METHODS['CRAM-MD5'], AUTH_METHODS.LOGIN, AUTH_METHODS.PLAIN, AUTH_METHODS.XOAUTH2, ]; this._state = SMTPState.NOTCONNECTED; this._secure = false; this.loggedin = false; this.sock = null; this.features = null; this.monitor = null; this.domain = hostname(); this.host = 'localhost'; this.ssl = false; this.tls = false; this.greylistResponseTracker = new WeakSet(); if (Array.isArray(authentication)) { this.authentication = authentication; } if (typeof timeout === 'number') { this.timeout = timeout; } if (typeof domain === 'string') { this.domain = domain; } if (typeof host === 'string') { this.host = host; } if (ssl != null && (typeof ssl === 'boolean' || (typeof ssl === 'object' && Array.isArray(ssl) === false))) { this.ssl = ssl; } if (tls != null && (typeof tls === 'boolean' || (typeof tls === 'object' && Array.isArray(tls) === false))) { this.tls = tls; } this.port = port || (ssl ? SMTP_SSL_PORT : tls ? SMTP_TLS_PORT : SMTP_PORT); this.loggedin = user && password ? false : true; if (!user && ((_a = password === null || password === void 0 ? void 0 : password.length) !== null && _a !== void 0 ? _a : 0) > 0) { throw new Error('`password` cannot be set without `user`'); } // keep these strings hidden when quicky debugging/logging this.user = () => user; this.password = () => password; if (typeof logger === 'function') { this.log = log; } } /** * @public * @param {0 | 1} level - * @returns {void} */ debug(level) { DEBUG = level; } /** * @public * @returns {SMTPState} the current state */ state() { return this._state; } /** * @public * @returns {boolean} whether or not the instance is authorized */ authorized() { return this.loggedin; } /** * Establish an SMTP connection. * * NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration. * * @public * @param {function(...any[]): void} callback function to call after response * @param {number} [port] the port to use for the connection * @param {string} [host] the hostname to use for the connection * @param {ConnectOptions} [options={}] the options * @returns {void} */ connect(callback, port = this.port, host = this.host, options = {}) { this.port = port; this.host = host; this.ssl = options.ssl || this.ssl; if (this._state !== SMTPState.NOTCONNECTED) { this.quit(() => this.connect(callback, port, host, options)); } /** * @returns {void} */ const connected = () => { this.log(`connected: ${this.host}:${this.port}`); if (this.ssl && !this.tls) { // if key/ca/cert was passed in, check if connection is authorized if (typeof this.ssl !== 'boolean' && this.sock instanceof TLSSocket && !this.sock.authorized) { this.close(true); caller(callback, SMTPError.create('could not establish an ssl connection', SMTPErrorStates.CONNECTIONAUTH)); } else { this._secure = true; } } }; /** * @param {Error} err err * @returns {void} */ const connectedErrBack = (err) => { if (!err) { connected(); } else { this.close(true); this.log(err); caller(callback, SMTPError.create('could not connect', SMTPErrorStates.COULDNOTCONNECT, err)); } }; const response = (err, msg) => { if (err) { if (this._state === SMTPState.NOTCONNECTED && !this.sock) { return; } this.close(true); caller(callback, err); } else if (msg.code == '220') { this.log(msg.data); // might happen first, so no need to wait on connected() this._state = SMTPState.CONNECTED; caller(callback, null, msg.data); } else { this.log(`response (data): ${msg.data}`); this.quit(() => { caller(callback, SMTPError.create('bad response on connection', SMTPErrorStates.BADRESPONSE, err, msg.data)); }); } }; this._state = SMTPState.CONNECTING; this.log(`connecting: ${this.host}:${this.port}`); if (this.ssl) { this.sock = connect(this.port, this.host.trim(), typeof this.ssl === 'object' ? this.ssl : {}, connected); } else { this.sock = new Socket(); this.sock.connect(this.port, this.host.trim(), connectedErrBack); } this.monitor = new SMTPResponseMonitor(this.sock, this.timeout, () => this.close(true)); this.sock.once('response', response); this.sock.once('error', response); // the socket could reset or throw, so let's handle it and let the user know } /** * @public * @param {string} str the string to send * @param {function(...any[]): void} callback function to call after response * @returns {void} */ send(str, callback) { if (this.sock != null && this._state === SMTPState.CONNECTED) { this.log(str); this.sock.once('response', (err, msg) => { if (err) { caller(callback, err); } else { this.log(msg.data); caller(callback, null, msg); } }); if (this.sock.writable) { this.sock.write(str); } } else { this.close(true); caller(callback, SMTPError.create('no connection has been established', SMTPErrorStates.NOCONNECTION)); } } /** * @public * @param {string} cmd command to issue * @param {function(...any[]): void} callback function to call after response * @param {(number[] | number)} [codes=[250]] array codes * @returns {void} */ command(cmd, callback, codes = [250]) { const codesArray = Array.isArray(codes) ? codes : typeof codes === 'number' ? [codes] : [250]; const response = (err, msg) => { if (err) { caller(callback, err); } else { const code = Number(msg.code); if (codesArray.indexOf(code) !== -1) { caller(callback, err, msg.data, msg.message); } else if ((code === 450 || code === 451) && msg.message.toLowerCase().includes('greylist') && this.greylistResponseTracker.has(response) === false) { this.greylistResponseTracker.add(response); setTimeout(() => { this.send(cmd + CRLF$1, response); }, GREYLIST_DELAY); } else { const suffix = msg.message ? `: ${msg.message}` : ''; const errorMessage = `bad response on command '${cmd.split(' ')[0]}'${suffix}`; caller(callback, SMTPError.create(errorMessage, SMTPErrorStates.BADRESPONSE, null, msg.data)); } } }; this.greylistResponseTracker.delete(response); this.send(cmd + CRLF$1, response); } /** * @public * @description SMTP 'helo' command. * * Hostname to send for self command defaults to the FQDN of the local * host. * * As this command was deprecated by rfc2821, it should only be used for compatibility with non-compliant servers. * @see https://tools.ietf.org/html/rfc2821#appendix-F.3 * * @param {function(...any[]): void} callback function to call after response * @param {string} domain the domain to associate with the 'helo' request * @returns {void} */ helo(callback, domain) { this.command(`helo ${domain || this.domain}`, (err, data) => { if (err) { caller(callback, err); } else { this.parse_smtp_features(data); caller(callback, err, data); } }); } /** * @public * @param {function(...any[]): void} callback function to call after response * @returns {void} */ starttls(callback) { const response = (err, msg) => { if (this.sock == null) { throw new Error('null socket'); } if (err) { err.message += ' while establishing a starttls session'; caller(callback, err); } else { const secureContext = createSecureContext(typeof this.tls === 'object' ? this.tls : {}); const secureSocket = new TLSSocket(this.sock, { secureContext }); secureSocket.on('error', (err) => { this.close(true); caller(callback, err); }); this._secure = true; this.sock = secureSocket; new SMTPResponseMonitor(this.sock, this.timeout, () => this.close(true)); caller(callback, msg.data); } }; this.command('starttls', response, [220]); } /** * @public * @param {string} data the string to parse for features * @returns {void} */ parse_smtp_features(data) { // According to RFC1869 some (badly written) // MTA's will disconnect on an ehlo. Toss an exception if // that happens -ddm data.split('\n').forEach((ext) => { const parse = ext.match(/^(?:\d+[-=]?)\s*?([^\s]+)(?:\s+(.*)\s*?)?$/); // To be able to communicate with as many SMTP servers as possible, // we have to take the old-style auth advertisement into account, // because: // 1) Else our SMTP feature parser gets confused. // 2) There are some servers that only advertise the auth methods we // support using the old style. if (parse != null && this.features != null) { // RFC 1869 requires a space between ehlo keyword and parameters. // It's actually stricter, in that only spaces are allowed between // parameters, but were not going to check for that here. Note // that the space isn't present if there are no parameters. this.features[parse[1].toLowerCase()] = parse[2] || true; } }); } /** * @public * @param {function(...any[]): void} callback function to call after response * @param {string} domain the domain to associate with the 'ehlo' request * @returns {void} */ ehlo(callback, domain) { this.features = {}; this.command(`ehlo ${domain || this.domain}`, (err, data) => { if (err) { caller(callback, err); } else { this.parse_smtp_features(data); if (this.tls && !this._secure) { this.starttls(() => this.ehlo(callback, domain)); } else { caller(callback, err, data); } } }); } /** * @public * @param {string} opt the features keyname to check * @returns {boolean} whether the extension exists */ has_extn(opt) { var _a; return ((_a = this.features) !== null && _a !== void 0 ? _a : {})[opt.toLowerCase()] === undefined; } /** * @public * @description SMTP 'help' command, returns text from the server * @param {function(...any[]): void} callback function to call after response * @param {string} domain the domain to associate with the 'help' request * @returns {void} */ help(callback, domain) { this.command(domain ? `help ${domain}` : 'help', callback, [211, 214]); } /** * @public * @param {function(...any[]): void} callback function to call after response * @returns {void} */ rset(callback) { this.command('rset', callback); } /** * @public * @param {function(...any[]): void} callback function to call after response * @returns {void} */ noop(callback) { this.send('noop', callback); } /** * @public * @param {function(...any[]): void} callback function to call after response * @param {string} from the sender * @returns {void} */ mail(callback, from) { this.command(`mail FROM:${from}`, callback); } /** * @public * @param {function(...any[]): void} callback function to call after response * @param {string} to the receiver * @returns {void} */ rcpt(callback, to) { this.command(`RCPT TO:${to}`, callback, [250, 251]); } /** * @public * @param {function(...any[]): void} callback function to call after response * @returns {void} */ data(callback) { this.command('data', callback, [354]); } /** * @public * @param {function(...any[]): void} callback function to call after response * @returns {void} */ data_end(callback) { this.command(`${CRLF$1}.`, callback); } /** * @public * @param {string} data the message to send * @returns {void} */ message(data) { var _a, _b; this.log(data); (_b = (_a = this.sock) === null || _a === void 0 ? void 0 : _a.write(data)) !== null && _b !== void 0 ? _b : this.log('no socket to write to'); } /** * @public * @description SMTP 'verify' command -- checks for address validity. * @param {string} address the address to validate * @param {function(...any[]): void} callback function to call after response * @returns {void} */ verify(address, callback) { this.command(`vrfy ${address}`, callback, [250, 251, 252]); } /** * @public * @description SMTP 'expn' command -- expands a mailing list. * @param {string} address the mailing list to expand * @param {function(...any[]): void} callback function to call after response * @returns {void} */ expn(address, callback) { this.command(`expn ${address}`, callback); } /** * @public * @description Calls this.ehlo() and, if an error occurs, this.helo(). * * If there has been no previous EHLO or HELO command self session, self * method tries ESMTP EHLO first. * * @param {function(...any[]): void} callback function to call after response * @param {string} [domain] the domain to associate with the command * @returns {void} */ ehlo_or_helo_if_needed(callback, domain) { // is this code callable...? if (!this.features) { const response = (err, data) => caller(callback, err, data); this.ehlo((err, data) => { if (err) { this.helo(response, domain); } else { caller(callback, err, data); } }, domain); } } /** * @public * * Log in on an SMTP server that requires authentication. * * If there has been no previous EHLO or HELO command self session, self * method tries ESMTP EHLO first. * * This method will return normally if the authentication was successful. * * @param {function(...any[]): void} callback function to call after response * @param {string} [user] the username to authenticate with * @param {string} [password] the password for the authentication * @param {{ method: string, domain: string }} [options] login options * @returns {void} */ login(callback, user, password, options = {}) { var _a, _b; const login = { user: user ? () => user : this.user, password: password ? () => password : this.password, method: (_b = (_a = options === null || options === void 0 ? void 0 : options.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : '', }; const domain = (options === null || options === void 0 ? void 0 : options.domain) || this.domain; const initiate = (err, data) => { var _a; if (err) { caller(callback, err); return; } let method = null; /** * @param {string} challenge challenge * @returns {string} base64 cram hash */ const encodeCramMd5 = (challenge) => { const hmac = createHmac('md5', login.password()); hmac.update(Buffer.from(challenge, 'base64').toString('ascii')); return Buffer.from(`${login.user()} ${hmac.digest('hex')}`).toString('base64'); }; /** * @returns {string} base64 login/password */ const encodePlain = () => Buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString('base64'); /** * @see https://developers.google.com/gmail/xoauth2_protocol * @returns {string} base64 xoauth2 auth token */ const encodeXoauth2 = () => Buffer.from(`user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001`).toString('base64'); // List of authentication methods we support: from preferred to // less preferred methods. if (!method) { const preferred = this.authentication; let auth = ''; if (typeof ((_a = this.features) === null || _a === void 0 ? void 0 : _a.auth) === 'string') { auth = this.features.auth; } for (let i = 0; i < preferred.length; i++) { if (auth.includes(preferred[i])) { method = preferred[i]; break; } } } /** * handle bad responses from command differently * @param {Error} err err * @param {unknown} data data * @returns {void} */ const failed = (err, data) => { this.loggedin = false; this.close(); // if auth is bad, close the connection, it won't get better by itself caller(callback, SMTPError.create('authorization.failed', SMTPErrorStates.AUTHFAILED, err, data)); }; /** * @param {Error} err err * @param {unknown} data data * @returns {void} */ const response = (err, data) => { if (err) { failed(err, data); } else { this.loggedin = true; caller(callback, err, data); } }; /** * @param {Error} err err * @param {unknown} data data * @param {string} msg msg * @returns {void} */ const attempt = (err, data, msg) => { if (err) { failed(err, data); } else { if (method === AUTH_METHODS['CRAM-MD5']) { this.command(encodeCramMd5(msg), response, [235, 503]); } else if (method === AUTH_METHODS.LOGIN) { this.command(Buffer.from(login.password()).toString('base64'), response, [235, 503]); } } }; /** * @param {Error} err err * @param {unknown} data data * @param {string} msg msg * @returns {void} */ const attemptUser = (err, data) => { if (err) { failed(err, data); } else { if (method === AUTH_METHODS.LOGIN) { this.command(Buffer.from(login.user()).toString('base64'), attempt, [334]); } } }; switch (method) { case AUTH_METHODS['CRAM-MD5']: this.command(`AUTH ${AUTH_METHODS['CRAM-MD5']}`, attempt, [334]); break; case AUTH_METHODS.LOGIN: this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attemptUser, [334]); break; case AUTH_METHODS.PLAIN: this.command(`AUTH ${AUTH_METHODS.PLAIN} ${encodePlain()}`, response, [235, 503]); break; case AUTH_METHODS.XOAUTH2: this.command(`AUTH ${AUTH_METHODS.XOAUTH2} ${encodeXoauth2()}`, response, [235, 503]); break; default: caller(callback, SMTPError.create('no form of authorization supported', SMTPErrorStates.AUTHNOTSUPPORTED, null, data)); break; } }; this.ehlo_or_helo_if_needed(initiate, domain); } /** * @public * @param {boolean} [force=false] whether or not to force destroy the connection * @returns {void} */ close(force = false) { if (this.sock) { if (force) { this.log('smtp connection destroyed!'); this.sock.destroy(); } else { this.log('smtp connection closed.'); this.sock.end(); } } if (this.monitor) { this.monitor.stop(); this.monitor = null; } this._state = SMTPState.NOTCONNECTED; this._secure = false; this.sock = null; this.features = null; this.loggedin = !(this.user() && this.password()); } /** * @public * @param {function(...any[]): void} [callback] function to call after response * @returns {void} */ quit(callback) { this.command('quit', (err, data) => { caller(callback, err, data); this.close(); }, [221, 250]); } } class SMTPClient { /** * Create a standard SMTP client backed by a self-managed SMTP connection. * * NOTE: `host` is trimmed before being used to establish a connection; however, the original untrimmed value will still be visible in configuration. * * @param {SMTPConnectionOptions} server smtp options */ constructor(server) { this.queue = []; this.sending = false; this.ready = false; this.timer = null; this.smtp = new SMTPConnection(server); } /** * @public * @param {Message} msg the message to send * @param {function(err: Error, msg: Message): void} callback . * @returns {void} */ send(msg, callback) { const message = msg instanceof Message ? msg : this._canMakeMessage(msg) ? new Message(msg) : null; if (message == null) { callback(new Error('message is not a valid Message instance'), msg); return; } message.valid((valid, why) => { if (valid) { const stack = this.createMessageStack(message, callback); if (stack.to.length === 0) { return callback(new Error('No recipients found in message'), msg); } this.queue.push(stack); this._poll(); } else { callback(new Error(why), msg); } }); } /** * @public * @param {Message} msg the message to send * @returns {Promise} a promise that resolves to the fully processed message */ sendAsync(msg) { return new Promise((resolve, reject) => { this.send(msg, (err, msg) => { if (err != null) { reject(err); } else { resolve(msg); } }); }); } /** * @public * @description Converts a message to the raw object used by the internal stack. * @param {Message} message message to convert * @param {function(err: Error, msg: Message): void} callback errback * @returns {MessageStack} raw message object */ createMessageStack(message, callback = function () { /* ΓΈ */ }) { const [{ address: from }] = addressparser(message.header.from); const stack = { message, to: [], from, callback: callback.bind(this), }; const { header: { to, cc, bcc, 'return-path': returnPath }, } = message; if ((typeof to === 'string' || Array.isArray(to)) && to.length > 0) { stack.to = addressparser(to); } if ((typeof cc === 'string' || Array.isArray(cc)) && cc.length > 0) { stack.to = stack.to.concat(addressparser(cc).filter((x) => stack.to.some((y) => y.address === x.address) === false)); } if ((typeof bcc === 'string' || Array.isArray(bcc)) && bcc.length > 0) { stack.to = stack.to.concat(addressparser(bcc).filter((x) => stack.to.some((y) => y.address === x.address) === false)); } if (typeof returnPath === 'string' && returnPath.length > 0) { const parsedReturnPath = addressparser(returnPath); if (parsedReturnPath.length > 0) { const [{ address: returnPathAddress }] = parsedReturnPath; stack.returnPath = returnPathAddress; } } return stack; } /** * @protected * @returns {void} */ _poll() { if (this.timer != null) { clearTimeout(this.timer); } if (this.queue.length) { if (this.smtp.state() == SMTPState.NOTCONNECTED) { this._connect(this.queue[0]); } else if (this.smtp.state() == SMTPState.CONNECTED && !this.sending && this.ready) { this._sendmail(this.queue.shift()); } } // wait around 1 seconds in case something does come in, // otherwise close out SMTP connection if still open else if (this.smtp.state() == SMTPState.CONNECTED) { this.timer = setTimeout(() => this.smtp.quit(), 1000); } } /** * @protected * @param {MessageStack} stack stack * @returns {void} */ _connect(stack) { /** * @param {Error} err callback error * @returns {void} */ const connect = (err) => { if (!err) { const begin = (err) => { if (!err) { this.ready = true; this._poll(); } else { stack.callback(err, stack.message); // clear out the queue so all callbacks can be called with the same error message this.queue.shift(); this._poll(); } }; if (!this.smtp.authorized()) { this.smtp.login(begin); } else { this.smtp.ehlo_or_helo_if_needed(begin); } } else { stack.callback(err, stack.message); // clear out the queue so all callbacks can be called with the same error message this.queue.shift(); this._poll(); } }; this.ready = false; this.smtp.connect(connect); } /** * @protected * @param {MessageStack} msg message stack * @returns {boolean} can make message */ _canMakeMessage(msg) { return (msg.from && (msg.to || msg.cc || msg.bcc) && (msg.text !== undefined || this._containsInlinedHtml(msg.attachment))); } /** * @protected * @param {MessageAttachment | MessageAttachment[]} attachment attachment * @returns {boolean} whether the attachment contains inlined html */ _containsInlinedHtml(attachment) { if (Array.isArray(attachment)) { return attachment.some((att) => { return this._isAttachmentInlinedHtml(att); }); } else { return this._isAttachmentInlinedHtml(attachment); } } /** * @protected * @param {MessageAttachment} attachment attachment * @returns {boolean} whether the attachment is inlined html */ _isAttachmentInlinedHtml(attachment) { return (attachment && (attachment.data || attachment.path) && attachment.alternative === true); } /** * @protected * @param {MessageStack} stack stack * @param {function(MessageStack): void} next next * @returns {function(Error): void} callback */ _sendsmtp(stack, next) { /** * @param {Error} [err] error * @returns {void} */ return (err) => { if (!err && next) { next.apply(this, [stack]); } else { // if we snag on SMTP commands, call done, passing the error // but first reset SMTP state so queue can continue polling this.smtp.rset(() => this._senddone(err, stack)); } }; } /** * @protected * @param {MessageStack} stack stack * @returns {void} */ _sendmail(stack) { const from = stack.returnPath || stack.from; this.sending = true; this.smtp.mail(this._sendsmtp(stack, this._sendrcpt), '<' + from + '>'); } /** * @protected * @param {MessageStack} stack stack * @returns {void} */ _sendrcpt(stack) { var _a; if (stack.to == null || typeof stack.to === 'string') { throw new TypeError('stack.to must be array'); } const to = (_a = stack.to.shift()) === null || _a === void 0 ? void 0 : _a.address; this.smtp.rcpt(this._sendsmtp(stack, stack.to.length ? this._sendrcpt : this._senddata), `<${to}>`); } /** * @protected * @param {MessageStack} stack stack * @returns {void} */ _senddata(stack) { this.smtp.data(this._sendsmtp(stack, this._sendmessage)); } /** * @protected * @param {MessageStack} stack stack * @returns {void} */ _sendmessage(stack) { const stream = stack.message.stream(); stream.on('data', (data) => this.smtp.message(data)); stream.on('end', () => { this.smtp.data_end(this._sendsmtp(stack, () => this._senddone(null, stack))); }); // there is no way to cancel a message while in the DATA portion, // so we have to close the socket to prevent a bad email from going out stream.on('error', (err) => { this.smtp.close(); this._senddone(err, stack); }); } /** * @protected * @param {Error} err err * @param {MessageStack} stack stack * @returns {void} */ _senddone(err, stack) { this.sending = false; stack.callback(err, stack.message); this._poll(); } } export { AUTH_METHODS, BUFFERSIZE, DEFAULT_TIMEOUT, MIME64CHUNK, MIMECHUNK, Message, SMTPClient, SMTPConnection, SMTPError, SMTPErrorStates, SMTPResponseMonitor, SMTPState, addressparser, getRFC2822Date, getRFC2822DateUTC, isRFC2822Date, mimeEncode, mimeWordEncode }; //# sourceMappingURL=email.mjs.map