nthmail / lib / send.js
all-in-one smtp+imap with minimal setup
git clone http://git.nthia.dev/nthmail

const net = require('net')
const dns = require('dns')
const { Readable, Writable, pipeline } = require('stream')
const TLS = require('./starttls.js')
const split = require('./split.js')

module.exports = function (opts, onfinish) {
  let send = new Send(opts, (err, sent) => {
    let f = onfinish
    onfinish = noop
    f(err, sent)
  })
  let cb = send._onfinish
  dns.resolveMx(send.destination, (err, mx) => {
    mx.sort((a,b) => a.priority < b.priority ? -1 : +1)
    ;(function next(i) {
      if (i >= mx.length) return cb(new Error('exhausted list of mx records'))
      let socket = net.connect(opts.port ?? 25, mx[i].exchange)
      socket.once('connect', onconnect)
      socket.once('error', onerror)
      send.once('quit', () => {
        clear()
        socket.destroy()
      })
      pipeline(socket, send, socket, (err) => { if (err) cb(err, false) })
      function clear() {
        socket.removeListener('error', onerror)
        socket.removeListener('connect', onconnect)
      }
      function onconnect() { clear() }
      function onerror(err) {
        clear()
        next(i+1)
      }
    })(0)
  })
}

function Send(opts, onfinish) {
  if (!(this instanceof Send)) return new Send(opts, onfinish)
  let self = this
  TLS.call(this, Object.assign({ isServer: false }, opts))
  this._onfinish = onfinish || noop
  this._data = opts.data
  if (!opts) opts = {}
  this.from = opts.from
  this.to = opts.to
  this.source = opts.domain ?? String(this.from).replace(/^[^@]*@/,'')
  this.destination = String(this.to).replace(/^[^@]*@/,'')
  this._split = split((line,next) => {
    this._writeLine(line, next)
  })
  this._lines = []
  this._code = -1
  this._maxLines = 1024
  this._maxLineBytes = this._maxLines*256
  this._lineBytes = 0
  this._cap = null
  let sequence = [
    (next) => this._ehlo(next),
    (next) => this._upgrade(next),
    (next) => this._ehlo(next),
    (next) => this._sendFromTo(next),
    (next) => this._sendData((err,sent,code) => {
      if (err) return next(err)
      this._onfinish(null, sent, code)
      next()
    }),
    (next) => this._sendQuit(next),
    (next) => { this.destroy(); next() },
  ]
  this._cb = [
    (err,code,msg) => {
      if (err) return next(err)
      if (code < 200 || code >= 300) {
        return this._onfinish(new Error('non-2xx ehlo response: ' + code + ' ' + msg))
      }
      start(0)
    }
  ]
  function start(i) {
    if (i >= sequence.length) return
    sequence[i](err => {
      if (err) self._onfinish(err)
      else start(i+1)
    })
  }
}
Send.prototype = Object.create(TLS.prototype)

Send.prototype._upgrade = function (next) {
  this._send('STARTTLS\r\n', (err, code, msg) => {
    if (err) return next(err)
    if (code < 200 || code >= 300) {
      return next(new Error('non-2xx starttls response: ' + code + ' ' + msg))
    }
    this._startTLS()
    next()
  })
}

Send.prototype._ehlo = function (next) {
  this._send('EHLO ' + this.source + '\r\n', (err, code, msg) => {
    if (err) return next(err)
    if (code < 200 || code >= 300) {
      return next(new Error('non-2xx ehlo response: ' + code + ' ' + msg))
    }
    this._cap = msg.split(/\r?\n/)
    next()
  })
}

Send.prototype._sendFromTo = function (next) {
  this._send('MAIL FROM: <' + this.from + '>\r\n', (err, code, msg) => {
    if (err) return next(err)
    if (code < 200 || code >= 300) {
      return next(new Error('non-2xx mail from response: ' + code + ' ' + msg))
    }
    this._send('RCPT TO: <' + this.to + '>\r\n', (err, code, msg) => {
      if (err) return next(err)
      if (code < 200 || code >= 300) {
        return next(new Error('non-2xx rcpt-to response: ' + code + ' ' + msg))
      }
      next()
    })
  })
}

Send.prototype._sendData = function (next) {
  let self = this
  let sent = false
  this._send('DATA\r\n', (err, code, msg) => {
    if (err) return next(err)
    if (code < 300 || code >= 400) {
      return next(new Error('non-3xx data start response: ' + code + ' ' + msg))
    }
    let w = new Writable({
      write(buf, enc, cb) {
        self._push(buf)
        cb()
      },
      final(cb) {
        self._send('\r\n.\r\n', (err, code, msg) => {
          if (err) return next(err)
          if (code < 200 || code >= 300) {
            return next(new Error('non-2xx data end response: ' + code + ' ' + msg))
          }
          sent = true
          next(null, true, code)
        })
      },
    })
    pipeline(this._data, w, (err) => {
      if (err) next(err)
      else if (!sent) next(null, false, -1)
    })
  })
}

Send.prototype._sendQuit = function (next) {
  this._send('QUIT\r\n', (err, code, msg) => {
    if (err) return next(err)
    if (code < 200 || code >= 300) {
      return next(new Error('non-2xx data end response: ' + code + ' ' + msg))
    }
    this._end()
    next(null)
    this.emit('quit')
  })
}

Send.prototype._writeBuf = function (buf, enc, cb) {
  this._split(buf, cb)
}

Send.prototype._writeLine = function (buf, next) {
  const line = buf.toString()
  let m = null
  if (m = /^(\d+)-(.*)/.exec(line)) {
    this._code = Number(m[1])
    this._lines.push(m[2])
    this._lineBytes += Buffer.byteLength(m[2])
    if (this._lines.length > this._maxLines) {
      this._lines = []
      this._lineBytes = 0
      this._code = -1
      let cb = this._cb.shift() || noop
      cb(new Error('too many lines in reply'))
    }
  } else if (m = /^(\d+)\s(.*)/.exec(line)) {
    this._code = Number(m[1])
    this._lines.push(m[2])
    this._lineBytes += Buffer.byteLength(m[2])
    let msg = this._lines.join('\r\n')
    let code = this._code
    this._lines = []
    this._lineBytes = 0
    this._code = -1
    let cb = this._cb.shift() || noop
    cb(null, code, msg)
  } else {
    cb(new Error('no code in response line: ' + line))
  }
  next()
}

Send.prototype._send = function (msg, cb) {
  this._cb.push(cb)
  this._push(msg)
}

function noop() {}