text: /* eslint no-undefined: 0, prefer-spread: 0, no-control-regex: 0 */
  
  'use strict';
  
  const crypto = require('crypto');
  const os = require('os');
  const fs = require('fs');
  const punycode = require('punycode');
  const PassThrough = require('stream').PassThrough;
  const shared = require('../shared');
  
  const mimeFuncs = require('../mime-funcs');
  const qp = require('../qp');
  const base64 = require('../base64');
  const addressparser = require('../addressparser');
  const fetch = require('../fetch');
  const LastNewline = require('./last-newline');
  
  /**
   * Creates a new mime tree node. Assumes 'multipart/*' as the content type
   * if it is a branch, anything else counts as leaf. If rootNode is missing from
   * the options, assumes this is the root.
   *
   * @param {String} contentType Define the content type for the node. Can be left blank for attachments (derived from filename)
   * @param {Object} [options] optional options
   * @param {Object} [options.rootNode] root node for this tree
   * @param {Object} [options.parentNode] immediate parent for this node
   * @param {Object} [options.filename] filename for an attachment node
   * @param {String} [options.baseBoundary] shared part of the unique multipart boundary
   * @param {Boolean} [options.keepBcc] If true, do not exclude Bcc from the generated headers
   * @param {Function} [options.normalizeHeaderKey] method to normalize header keys for custom caseing
   * @param {String} [options.textEncoding] either 'Q' (the default) or 'B'
   */
  class MimeNode {
      constructor(contentType, options) {
          this.nodeCounter = 0;
  
          options = options || {};
  
          /**
           * shared part of the unique multipart boundary
           */
          this.baseBoundary = options.baseBoundary || crypto.randomBytes(8).toString('hex');
          this.boundaryPrefix = options.boundaryPrefix || '--_NmP';
  
          this.disableFileAccess = !!options.disableFileAccess;
          this.disableUrlAccess = !!options.disableUrlAccess;
  
          this.normalizeHeaderKey = options.normalizeHeaderKey;
  
          /**
           * If date headers is missing and current node is the root, this value is used instead
           */
          this.date = new Date();
  
          /**
           * Root node for current mime tree
           */
          this.rootNode = options.rootNode || this;
  
          /**
           * If true include Bcc in generated headers (if available)
           */
          this.keepBcc = !!options.keepBcc;
  
          /**
           * If filename is specified but contentType is not (probably an attachment)
           * detect the content type from filename extension
           */
          if (options.filename) {
              /**
               * Filename for this node. Useful with attachments
               */
              this.filename = options.filename;
              if (!contentType) {
                  contentType = mimeFuncs.detectMimeType(this.filename.split('.').pop());
              }
          }
  
          /**
           * Indicates which encoding should be used for header strings: "Q" or "B"
           */
          this.textEncoding = (options.textEncoding || '')
              .toString()
              .trim()
              .charAt(0)
              .toUpperCase();
  
          /**
           * Immediate parent for this node (or undefined if not set)
           */
          this.parentNode = options.parentNode;
  
          /**
           * Hostname for default message-id values
           */
          this.hostname = options.hostname;
  
          /**
           * An array for possible child nodes
           */
          this.childNodes = [];
  
          /**
           * Used for generating unique boundaries (prepended to the shared base)
           */
          this._nodeId = ++this.rootNode.nodeCounter;
  
          /**
           * A list of header values for this node in the form of [{key:'', value:''}]
           */
          this._headers = [];
  
          /**
           * True if the content only uses ASCII printable characters
           * @type {Boolean}
           */
          this._isPlainText = false;
  
          /**
           * True if the content is plain text but has longer lines than allowed
           * @type {Boolean}
           */
          this._hasLongLines = false;
  
          /**
           * If set, use instead this value for envelopes instead of generating one
           * @type {Boolean}
           */
          this._envelope = false;
  
          /**
           * If set then use this value as the stream content instead of building it
           * @type {String|Buffer|Stream}
           */
          this._raw = false;
  
          /**
           * Additional transform streams that the message will be piped before
           * exposing by createReadStream
           * @type {Array}
           */
          this._transforms = [];
  
          /**
           * Additional process functions that the message will be piped through before
           * exposing by createReadStream. These functions are run after transforms
           * @type {Array}
           */
          this._processFuncs = [];
  
          /**
           * If content type is set (or derived from the filename) add it to headers
           */
          if (contentType) {
              this.setHeader('Content-Type', contentType);
          }
      }
  
      /////// PUBLIC METHODS
  
      /**
       * Creates and appends a child node.Arguments provided are passed to MimeNode constructor
       *
       * @param {String} [contentType] Optional content type
       * @param {Object} [options] Optional options object
       * @return {Object} Created node object
       */
      createChild(contentType, options) {
          if (!options && typeof contentType === 'object') {
              options = contentType;
              contentType = undefined;
          }
          let node = new MimeNode(contentType, options);
          this.appendChild(node);
          return node;
      }
  
      /**
       * Appends an existing node to the mime tree. Removes the node from an existing
       * tree if needed
       *
       * @param {Object} childNode node to be appended
       * @return {Object} Appended node object
       */
      appendChild(childNode) {
          if (childNode.rootNode !== this.rootNode) {
              childNode.rootNode = this.rootNode;
              childNode._nodeId = ++this.rootNode.nodeCounter;
          }
  
          childNode.parentNode = this;
  
          this.childNodes.push(childNode);
          return childNode;
      }
  
      /**
       * Replaces current node with another node
       *
       * @param {Object} node Replacement node
       * @return {Object} Replacement node
       */
      replace(node) {
          if (node === this) {
              return this;
          }
  
          this.parentNode.childNodes.forEach((childNode, i) => {
              if (childNode === this) {
                  node.rootNode = this.rootNode;
                  node.parentNode = this.parentNode;
                  node._nodeId = this._nodeId;
  
                  this.rootNode = this;
                  this.parentNode = undefined;
  
                  node.parentNode.childNodes[i] = node;
              }
          });
  
          return node;
      }
  
      /**
       * Removes current node from the mime tree
       *
       * @return {Object} removed node
       */
      remove() {
          if (!this.parentNode) {
              return this;
          }
  
          for (let i = this.parentNode.childNodes.length - 1; i >= 0; i--) {
              if (this.parentNode.childNodes[i] === this) {
                  this.parentNode.childNodes.splice(i, 1);
                  this.parentNode = undefined;
                  this.rootNode = this;
                  return this;
              }
          }
      }
  
      /**
       * Sets a header value. If the value for selected key exists, it is overwritten.
       * You can set multiple values as well by using [{key:'', value:''}] or
       * {key: 'value'} as the first argument.
       *
       * @param {String|Array|Object} key Header key or a list of key value pairs
       * @param {String} value Header value
       * @return {Object} current node
       */
      setHeader(key, value) {
          let added = false,
              headerValue;
  
          // Allow setting multiple headers at once
          if (!value && key && typeof key === 'object') {
              // allow {key:'content-type', value: 'text/plain'}
              if (key.key && 'value' in key) {
                  this.setHeader(key.key, key.value);
              } else if (Array.isArray(key)) {
                  // allow [{key:'content-type', value: 'text/plain'}]
                  key.forEach(i => {
                      this.setHeader(i.key, i.value);
                  });
              } else {
                  // allow {'content-type': 'text/plain'}
                  Object.keys(key).forEach(i => {
                      this.setHeader(i, key[i]);
                  });
              }
              return this;
          }
  
          key = this._normalizeHeaderKey(key);
  
          headerValue = {
              key,
              value
          };
  
          // Check if the value exists and overwrite
          for (let i = 0, len = this._headers.length; i < len; i++) {
              if (this._headers[i].key === key) {
                  if (!added) {
                      // replace the first match
                      this._headers[i] = headerValue;
                      added = true;
                  } else {
                      // remove following matches
                      this._headers.splice(i, 1);
                      i--;
                      len--;
                  }
              }
          }
  
          // match not found, append the value
          if (!added) {
              this._headers.push(headerValue);
          }
  
          return this;
      }
  
      /**
       * Adds a header value. If the value for selected key exists, the value is appended
       * as a new field and old one is not touched.
       * You can set multiple values as well by using [{key:'', value:''}] or
       * {key: 'value'} as the first argument.
       *
       * @param {String|Array|Object} key Header key or a list of key value pairs
       * @param {String} value Header value
       * @return {Object} current node
       */
      addHeader(key, value) {
          // Allow setting multiple headers at once
          if (!value && key && typeof key === 'object') {
              // allow {key:'content-type', value: 'text/plain'}
              if (key.key && key.value) {
                  this.addHeader(key.key, key.value);
              } else if (Array.isArray(key)) {
                  // allow [{key:'content-type', value: 'text/plain'}]
                  key.forEach(i => {
                      this.addHeader(i.key, i.value);
                  });
              } else {
                  // allow {'content-type': 'text/plain'}
                  Object.keys(key).forEach(i => {
                      this.addHeader(i, key[i]);
                  });
              }
              return this;
          } else if (Array.isArray(value)) {
              value.forEach(val => {
                  this.addHeader(key, val);
              });
              return this;
          }
  
          this._headers.push({
              key: this._normalizeHeaderKey(key),
              value
          });
  
          return this;
      }
  
      /**
       * Retrieves the first mathcing value of a selected key
       *
       * @param {String} key Key to search for
       * @retun {String} Value for the key
       */
      getHeader(key) {
          key = this._normalizeHeaderKey(key);
          for (let i = 0, len = this._headers.length; i < len; i++) {
              if (this._headers[i].key === key) {
                  return this._headers[i].value;
              }
          }
      }
  
      /**
       * Sets body content for current node. If the value is a string, charset is added automatically
       * to Content-Type (if it is text/*). If the value is a Buffer, you need to specify
       * the charset yourself
       *
       * @param (String|Buffer) content Body content
       * @return {Object} current node
       */
      setContent(content) {
          this.content = content;
          if (typeof this.content.pipe === 'function') {
              // pre-stream handler. might be triggered if a stream is set as content
              // and 'error' fires before anything is done with this stream
              this._contentErrorHandler = err => {
                  this.content.removeListener('error', this._contentErrorHandler);
                  this.content = err;
              };
              this.content.once('error', this._contentErrorHandler);
          } else if (typeof this.content === 'string') {
              this._isPlainText = mimeFuncs.isPlainText(this.content);
              if (this._isPlainText && mimeFuncs.hasLongerLines(this.content, 998)) {
                  // If there are lines longer than 998 symbols/bytes do not use 7bit
                  this._hasLongLines = true;
              }
          }
          return this;
      }
  
      build(callback) {
          let promise;
  
          if (!callback) {
              promise = new Promise((resolve, reject) => {
                  callback = shared.callbackPromise(resolve, reject);
              });
          }
  
          let stream = this.createReadStream();
          let buf = [];
          let buflen = 0;
          let returned = false;
  
          stream.on('readable', () => {
              let chunk;
  
              while ((chunk = stream.read()) !== null) {
                  buf.push(chunk);
                  buflen += chunk.length;
              }
          });
  
          stream.once('error', err => {
              if (returned) {
                  return;
              }
              returned = true;
  
              return callback(err);
          });
  
          stream.once('end', chunk => {
              if (returned) {
                  return;
              }
              returned = true;
  
              if (chunk && chunk.length) {
                  buf.push(chunk);
                  buflen += chunk.length;
              }
              return callback(null, Buffer.concat(buf, buflen));
          });
  
          return promise;
      }
  
      getTransferEncoding() {
          let transferEncoding = false;
          let contentType = (this.getHeader('Content-Type') || '')
              .toString()
              .toLowerCase()
              .trim();
  
          if (this.content) {
              transferEncoding = (this.getHeader('Content-Transfer-Encoding') || '')
                  .toString()
                  .toLowerCase()
                  .trim();
              if (!transferEncoding || !['base64', 'quoted-printable'].includes(transferEncoding)) {
                  if (/^text\//i.test(contentType)) {
                      // If there are no special symbols, no need to modify the text
                      if (this._isPlainText && !this._hasLongLines) {
                          transferEncoding = '7bit';
                      } else if (typeof this.content === 'string' || this.content instanceof Buffer) {
                          // detect preferred encoding for string value
                          transferEncoding = this._getTextEncoding(this.content) === 'Q' ? 'quoted-printable' : 'base64';
                      } else {
                          // we can not check content for a stream, so either use preferred encoding or fallback to QP
                          transferEncoding = this.transferEncoding === 'B' ? 'base64' : 'quoted-printable';
                      }
                  } else if (!/^(multipart|message)\//i.test(contentType)) {
                      transferEncoding = transferEncoding || 'base64';
                  }
              }
          }
          return transferEncoding;
      }
  
      /**
       * Builds the header block for the mime node. Append \r\n\r\n before writing the content
       *
       * @returns {String} Headers
       */
      buildHeaders() {
          let transferEncoding = this.getTransferEncoding();
          let headers = [];
  
          if (transferEncoding) {
              this.setHeader('Content-Transfer-Encoding', transferEncoding);
          }
  
          if (this.filename && !this.getHeader('Content-Disposition')) {
              this.setHeader('Content-Disposition', 'attachment');
          }
  
          // Ensure mandatory header fields
          if (this.rootNode === this) {
              if (!this.getHeader('Date')) {
                  this.setHeader('Date', this.date.toUTCString().replace(/GMT/, '+0000'));
              }
  
              // ensure that Message-Id is present
              this.messageId();
  
              if (!this.getHeader('MIME-Version')) {
                  this.setHeader('MIME-Version', '1.0');
              }
          }
  
          this._headers.forEach(header => {
              let key = header.key;
              let value = header.value;
              let structured;
              let param;
              let options = {};
              let formattedHeaders = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
  
              if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
                  Object.keys(value).forEach(key => {
                      if (key !== 'value') {
                          options[key] = value[key];
                      }
                  });
                  value = (value.value || '').toString();
                  if (!value.trim()) {
                      return;
                  }
              }
  
              if (options.prepared) {
                  // header value is
                  if (options.foldLines) {
                      headers.push(mimeFuncs.foldLines(key + ': ' + value));
                  } else {
                      headers.push(key + ': ' + value);
                  }
                  return;
              }
  
              switch (header.key) {
                  case 'Content-Disposition':
                      structured = mimeFuncs.parseHeaderValue(value);
                      if (this.filename) {
                          structured.params.filename = this.filename;
                      }
                      value = mimeFuncs.buildHeaderValue(structured);
                      break;
                  case 'Content-Type':
                      structured = mimeFuncs.parseHeaderValue(value);
  
                      this._handleContentType(structured);
  
                      if (structured.value.match(/^text\/plain\b/) && typeof this.content === 'string' && /[\u0080-\uFFFF]/.test(this.content)) {
                          structured.params.charset = 'utf-8';
                      }
  
                      value = mimeFuncs.buildHeaderValue(structured);
  
                      if (this.filename) {
                          // add support for non-compliant clients like QQ webmail
                          // we can't build the value with buildHeaderValue as the value is non standard and
                          // would be converted to parameter continuation encoding that we do not want
                          param = this._encodeWords(this.filename);
  
                          if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
                              // include value in quotes if needed
                              param = '"' + param + '"';
                          }
                          value += '; name=' + param;
                      }
                      break;
                  case 'Bcc':
                      if (!this.keepBcc) {
                          // skip BCC values
                          return;
                      }
                      break;
              }
  
              value = this._encodeHeaderValue(key, value);
  
              // skip empty lines
              if (!(value || '').toString().trim()) {
                  return;
              }
  
              if (typeof this.normalizeHeaderKey === 'function') {
                  let normalized = this.normalizeHeaderKey(key, value);
                  if (normalized && typeof normalized === 'string' && normalized.length) {
                      key = normalized;
                  }
              }
  
              headers.push(mimeFuncs.foldLines(key + ': ' + value, 76));
          });
  
          return headers.join('\r\n');
      }
  
      /**
       * Streams the rfc2822 message from the current node. If this is a root node,
       * mandatory header fields are set if missing (Date, Message-Id, MIME-Version)
       *
       * @return {String} Compiled message
       */
      createReadStream(options) {
          options = options || {};
  
          let stream = new PassThrough(options);
          let outputStream = stream;
          let transform;
  
          this.stream(stream, options, err => {
              if (err) {
                  outputStream.emit('error', err);
                  return;
              }
              stream.end();
          });
  
          for (let i = 0, len = this._transforms.length; i < len; i++) {
              transform = typeof this._transforms[i] === 'function' ? this._transforms[i]() : this._transforms[i];
              outputStream.once('error', err => {
                  transform.emit('error', err);
              });
              outputStream = outputStream.pipe(transform);
          }
  
          // ensure terminating newline after possible user transforms
          transform = new LastNewline();
          outputStream.once('error', err => {
              transform.emit('error', err);
          });
          outputStream = outputStream.pipe(transform);
  
          // dkim and stuff
          for (let i = 0, len = this._processFuncs.length; i < len; i++) {
              transform = this._processFuncs[i];
              outputStream = transform(outputStream);
          }
  
          return outputStream;
      }
  
      /**
       * Appends a transform stream object to the transforms list. Final output
       * is passed through this stream before exposing
       *
       * @param {Object} transform Read-Write stream
       */
      transform(transform) {
          this._transforms.push(transform);
      }
  
      /**
       * Appends a post process function. The functon is run after transforms and
       * uses the following syntax
       *
       *   processFunc(input) -> outputStream
       *
       * @param {Object} processFunc Read-Write stream
       */
      processFunc(processFunc) {
          this._processFuncs.push(processFunc);
      }
  
      stream(outputStream, options, done) {
          let transferEncoding = this.getTransferEncoding();
          let contentStream;
          let localStream;
  
          // protect actual callback against multiple triggering
          let returned = false;
          let callback = err => {
              if (returned) {
                  return;
              }
              returned = true;
              done(err);
          };
  
          // for multipart nodes, push child nodes
          // for content nodes end the stream
          let finalize = () => {
              let childId = 0;
              let processChildNode = () => {
                  if (childId >= this.childNodes.length) {
                      outputStream.write('\r\n--' + this.boundary + '--\r\n');
                      return callback();
                  }
                  let child = this.childNodes[childId++];
                  outputStream.write((childId > 1 ? '\r\n' : '') + '--' + this.boundary + '\r\n');
                  child.stream(outputStream, options, err => {
                      if (err) {
                          return callback(err);
                      }
                      setImmediate(processChildNode);
                  });
              };
  
              if (this.multipart) {
                  setImmediate(processChildNode);
              } else {
                  return callback();
              }
          };
  
          // pushes node content
          let sendContent = () => {
              if (this.content) {
                  if (Object.prototype.toString.call(this.content) === '[object Error]') {
                      // content is already errored
                      return callback(this.content);
                  }
  
                  if (typeof this.content.pipe === 'function') {
                      this.content.removeListener('error', this._contentErrorHandler);
                      this._contentErrorHandler = err => callback(err);
                      this.content.once('error', this._contentErrorHandler);
                  }
  
                  let createStream = () => {
                      if (['quoted-printable', 'base64'].includes(transferEncoding)) {
                          contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options);
  
                          contentStream.pipe(
                              outputStream,
                              {
                                  end: false
                              }
                          );
                          contentStream.once('end', finalize);
                          contentStream.once('error', err => callback(err));
  
                          localStream = this._getStream(this.content);
                          localStream.pipe(contentStream);
                      } else {
                          // anything that is not QP or Base54 passes as-is
                          localStream = this._getStream(this.content);
                          localStream.pipe(
                              outputStream,
                              {
                                  end: false
                              }
                          );
                          localStream.once('end', finalize);
                      }
  
                      localStream.once('error', err => callback(err));
                  };
  
                  if (this.content._resolve) {
                      let chunks = [];
                      let chunklen = 0;
                      let returned = false;
                      let sourceStream = this._getStream(this.content);
                      sourceStream.on('error', err => {
                          if (returned) {
                              return;
                          }
                          returned = true;
                          callback(err);
                      });
                      sourceStream.on('readable', () => {
                          let chunk;
                          while ((chunk = sourceStream.read()) !== null) {
                              chunks.push(chunk);
                              chunklen += chunk.length;
                          }
                      });
                      sourceStream.on('end', () => {
                          if (returned) {
                              return;
                          }
                          returned = true;
                          this.content._resolve = false;
                          this.content._resolvedValue = Buffer.concat(chunks, chunklen);
                          setImmediate(createStream);
                      });
                  } else {
                      setImmediate(createStream);
                  }
                  return;
              } else {
                  return setImmediate(finalize);
              }
          };
  
          if (this._raw) {
              setImmediate(() => {
                  if (Object.prototype.toString.call(this._raw) === '[object Error]') {
                      // content is already errored
                      return callback(this._raw);
                  }
  
                  // remove default error handler (if set)
                  if (typeof this._raw.pipe === 'function') {
                      this._raw.removeListener('error', this._contentErrorHandler);
                  }
  
                  let raw = this._getStream(this._raw);
                  raw.pipe(
                      outputStream,
                      {
                          end: false
                      }
                  );
                  raw.on('error', err => outputStream.emit('error', err));
                  raw.on('end', finalize);
              });
          } else {
              outputStream.write(this.buildHeaders() + '\r\n\r\n');
              setImmediate(sendContent);
          }
      }
  
      /**
       * Sets envelope to be used instead of the generated one
       *
       * @return {Object} SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
       */
      setEnvelope(envelope) {
          let list;
  
          this._envelope = {
              from: false,
              to: []
          };
  
          if (envelope.from) {
              list = [];
              this._convertAddresses(this._parseAddresses(envelope.from), list);
              list = list.filter(address => address && address.address);
              if (list.length && list[0]) {
                  this._envelope.from = list[0].address;
              }
          }
          ['to', 'cc', 'bcc'].forEach(key => {
              if (envelope[key]) {
                  this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to);
              }
          });
  
          this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
  
          let standardFields = ['to', 'cc', 'bcc', 'from'];
          Object.keys(envelope).forEach(key => {
              if (!standardFields.includes(key)) {
                  this._envelope[key] = envelope[key];
              }
          });
  
          return this;
      }
  
      /**
       * Generates and returns an object with parsed address fields
       *
       * @return {Object} Address object
       */
      getAddresses() {
          let addresses = {};
  
          this._headers.forEach(header => {
              let key = header.key.toLowerCase();
              if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
                  if (!Array.isArray(addresses[key])) {
                      addresses[key] = [];
                  }
  
                  this._convertAddresses(this._parseAddresses(header.value), addresses[key]);
              }
          });
  
          return addresses;
      }
  
      /**
       * Generates and returns SMTP envelope with the sender address and a list of recipients addresses
       *
       * @return {Object} SMTP envelope in the form of {from: 'from@example.com', to: ['to@example.com']}
       */
      getEnvelope() {
          if (this._envelope) {
              return this._envelope;
          }
  
          let envelope = {
              from: false,
              to: []
          };
          this._headers.forEach(header => {
              let list = [];
              if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
                  this._convertAddresses(this._parseAddresses(header.value), list);
                  if (list.length && list[0]) {
                      envelope.from = list[0].address;
                  }
              } else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
                  this._convertAddresses(this._parseAddresses(header.value), envelope.to);
              }
          });
  
          envelope.to = envelope.to.map(to => to.address);
  
          return envelope;
      }
  
      /**
       * Returns Message-Id value. If it does not exist, then creates one
       *
       * @return {String} Message-Id value
       */
      messageId() {
          let messageId = this.getHeader('Message-ID');
          // You really should define your own Message-Id field!
          if (!messageId) {
              messageId = this._generateMessageId();
              this.setHeader('Message-ID', messageId);
          }
          return messageId;
      }
  
      /**
       * Sets pregenerated content that will be used as the output of this node
       *
       * @param {String|Buffer|Stream} Raw MIME contents
       */
      setRaw(raw) {
          this._raw = raw;
  
          if (this._raw && typeof this._raw.pipe === 'function') {
              // pre-stream handler. might be triggered if a stream is set as content
              // and 'error' fires before anything is done with this stream
              this._contentErrorHandler = err => {
                  this._raw.removeListener('error', this._contentErrorHandler);
                  this._raw = err;
              };
              this._raw.once('error', this._contentErrorHandler);
          }
  
          return this;
      }
  
      /////// PRIVATE METHODS
  
      /**
       * Detects and returns handle to a stream related with the content.
       *
       * @param {Mixed} content Node content
       * @returns {Object} Stream object
       */
      _getStream(content) {
          let contentStream;
  
          if (content._resolvedValue) {
              // pass string or buffer content as a stream
              contentStream = new PassThrough();
              setImmediate(() => contentStream.end(content._resolvedValue));
              return contentStream;
          } else if (typeof content.pipe === 'function') {
              // assume as stream
              return content;
          } else if (content && typeof content.path === 'string' && !content.href) {
              if (this.disableFileAccess) {
                  contentStream = new PassThrough();
                  setImmediate(() => contentStream.emit('error', new Error('File access rejected for ' + content.path)));
                  return contentStream;
              }
              // read file
              return fs.createReadStream(content.path);
          } else if (content && typeof content.href === 'string') {
              if (this.disableUrlAccess) {
                  contentStream = new PassThrough();
                  setImmediate(() => contentStream.emit('error', new Error('Url access rejected for ' + content.href)));
                  return contentStream;
              }
              // fetch URL
              return fetch(content.href, { headers: content.httpHeaders });
          } else {
              // pass string or buffer content as a stream
              contentStream = new PassThrough();
              setImmediate(() => contentStream.end(content || ''));
              return contentStream;
          }
      }
  
      /**
       * Parses addresses. Takes in a single address or an array or an
       * array of address arrays (eg. To: [[first group], [second group],...])
       *
       * @param {Mixed} addresses Addresses to be parsed
       * @return {Array} An array of address objects
       */
      _parseAddresses(addresses) {
          return [].concat.apply(
              [],
              [].concat(addresses).map(address => {
                  // eslint-disable-line prefer-spread
                  if (address && address.address) {
                      address.address = this._normalizeAddress(address.address);
                      address.name = address.name || '';
                      return [address];
                  }
                  return addressparser(address);
              })
          );
      }
  
      /**
       * Normalizes a header key, uses Camel-Case form, except for uppercase MIME-
       *
       * @param {String} key Key to be normalized
       * @return {String} key in Camel-Case form
       */
      _normalizeHeaderKey(key) {
          key = (key || '')
              .toString()
              // no newlines in keys
              .replace(/\r?\n|\r/g, ' ')
              .trim()
              .toLowerCase()
              // use uppercase words, except MIME
              .replace(/^X-SMTPAPI$|^(MIME|DKIM)\b|^[a-z]|-(SPF|FBL|ID|MD5)$|-[a-z]/gi, c => c.toUpperCase())
              // special case
              .replace(/^Content-Features$/i, 'Content-features');
  
          return key;
      }
  
      /**
       * Checks if the content type is multipart and defines boundary if needed.
       * Doesn't return anything, modifies object argument instead.
       *
       * @param {Object} structured Parsed header value for 'Content-Type' key
       */
      _handleContentType(structured) {
          this.contentType = structured.value.trim().toLowerCase();
  
          this.multipart = this.contentType.split('/').reduce((prev, value) => (prev === 'multipart' ? value : false));
  
          if (this.multipart) {
              this.boundary = structured.params.boundary = structured.params.boundary || this.boundary || this._generateBoundary();
          } else {
              this.boundary = false;
          }
      }
  
      /**
       * Generates a multipart boundary value
       *
       * @return {String} boundary value
       */
      _generateBoundary() {
          return this.rootNode.boundaryPrefix + '-' + this.rootNode.baseBoundary + '-Part_' + this._nodeId;
      }
  
      /**
       * Encodes a header value for use in the generated rfc2822 email.
       *
       * @param {String} key Header key
       * @param {String} value Header value
       */
      _encodeHeaderValue(key, value) {
          key = this._normalizeHeaderKey(key);
  
          switch (key) {
              // Structured headers
              case 'From':
              case 'Sender':
              case 'To':
              case 'Cc':
              case 'Bcc':
              case 'Reply-To':
                  return this._convertAddresses(this._parseAddresses(value));
  
              // values enclosed in <>
              case 'Message-ID':
              case 'In-Reply-To':
              case 'Content-Id':
                  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
  
                  if (value.charAt(0) !== '<') {
                      value = '<' + value;
                  }
  
                  if (value.charAt(value.length - 1) !== '>') {
                      value = value + '>';
                  }
                  return value;
  
              // space separated list of values enclosed in <>
              case 'References':
                  value = [].concat
                      .apply(
                          [],
                          [].concat(value || '').map(elm => {
                              // eslint-disable-line prefer-spread
                              elm = (elm || '')
                                  .toString()
                                  .replace(/\r?\n|\r/g, ' ')
                                  .trim();
                              return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
                          })
                      )
                      .map(elm => {
                          if (elm.charAt(0) !== '<') {
                              elm = '<' + elm;
                          }
                          if (elm.charAt(elm.length - 1) !== '>') {
                              elm = elm + '>';
                          }
                          return elm;
                      });
  
                  return value.join(' ').trim();
  
              case 'Date':
                  if (Object.prototype.toString.call(value) === '[object Date]') {
                      return value.toUTCString().replace(/GMT/, '+0000');
                  }
  
                  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
                  return this._encodeWords(value);
  
              default:
                  value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
                  // encodeWords only encodes if needed, otherwise the original string is returned
                  return this._encodeWords(value);
          }
      }
  
      /**
       * Rebuilds address object using punycode and other adjustments
       *
       * @param {Array} addresses An array of address objects
       * @param {Array} [uniqueList] An array to be populated with addresses
       * @return {String} address string
       */
      _convertAddresses(addresses, uniqueList) {
          let values = [];
  
          uniqueList = uniqueList || [];
  
          [].concat(addresses || []).forEach(address => {
              if (address.address) {
                  address.address = this._normalizeAddress(address.address);
  
                  if (!address.name) {
                      values.push(address.address);
                  } else if (address.name) {
                      values.push(this._encodeAddressName(address.name) + ' <' + address.address + '>');
                  }
  
                  if (address.address) {
                      if (!uniqueList.filter(a => a.address === address.address).length) {
                          uniqueList.push(address);
                      }
                  }
              } else if (address.group) {
                  values.push(
                      this._encodeAddressName(address.name) + ':' + (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim() + ';'
                  );
              }
          });
  
          return values.join(', ');
      }
  
      /**
       * Normalizes an email address
       *
       * @param {Array} address An array of address objects
       * @return {String} address string
       */
      _normalizeAddress(address) {
          address = (address || '').toString().trim();
  
          let lastAt = address.lastIndexOf('@');
          if (lastAt < 0) {
              // Bare username
              return address;
          }
          let user = address.substr(0, lastAt);
          let domain = address.substr(lastAt + 1);
  
          // Usernames are not touched and are kept as is even if these include unicode
          // Domains are punycoded by default
          // 'jõgeva.ee' will be converted to 'xn--jgeva-dua.ee'
          // non-unicode domains are left as is
  
          return user + '@' + punycode.toASCII(domain.toLowerCase());
      }
  
      /**
       * If needed, mime encodes the name part
       *
       * @param {String} name Name part of an address
       * @returns {String} Mime word encoded string if needed
       */
      _encodeAddressName(name) {
          if (!/^[\w ']*$/.test(name)) {
              if (/^[\x20-\x7e]*$/.test(name)) {
                  return '"' + name.replace(/([\\"])/g, '\\$1') + '"';
              } else {
                  return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
              }
          }
          return name;
      }
  
      /**
       * If needed, mime encodes the name part
       *
       * @param {String} name Name part of an address
       * @returns {String} Mime word encoded string if needed
       */
      _encodeWords(value) {
          // set encodeAll parameter to true even though it is against the recommendation of RFC2047,
          // by default only words that include non-ascii should be converted into encoded words
          // but some clients (eg. Zimbra) do not handle it properly and remove surrounding whitespace
          return mimeFuncs.encodeWords(value, this._getTextEncoding(value), 52, true);
      }
  
      /**
       * Detects best mime encoding for a text value
       *
       * @param {String} value Value to check for
       * @return {String} either 'Q' or 'B'
       */
      _getTextEncoding(value) {
          value = (value || '').toString();
  
          let encoding = this.textEncoding;
          let latinLen;
          let nonLatinLen;
  
          if (!encoding) {
              // count latin alphabet symbols and 8-bit range symbols + control symbols
              // if there are more latin characters, then use quoted-printable
              // encoding, otherwise use base64
              nonLatinLen = (value.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\u0080-\uFFFF]/g) || []).length; // eslint-disable-line no-control-regex
              latinLen = (value.match(/[a-z]/gi) || []).length;
              // if there are more latin symbols than binary/unicode, then prefer Q, otherwise B
              encoding = nonLatinLen < latinLen ? 'Q' : 'B';
          }
          return encoding;
      }
  
      /**
       * Generates a message id
       *
       * @return {String} Random Message-ID value
       */
      _generateMessageId() {
          return (
              '<' +
              [2, 2, 2, 6].reduce(
                  // crux to generate UUID-like random strings
                  (prev, len) => prev + '-' + crypto.randomBytes(len).toString('hex'),
                  crypto.randomBytes(4).toString('hex')
              ) +
              '@' +
              // try to use the domain of the FROM address or fallback to server hostname
              (this.getEnvelope().from || this.hostname || os.hostname() || 'localhost').split('@').pop() +
              '>'
          );
      }
  }
  
  module.exports = MimeNode;
  