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

const { Readable } = require('stream')
const TLS = require('./starttls.js')
const split = require('./split.js')

const defaults = {
  helo: {
    acceptCode: 250, acceptMsg: 'ok\nSTARTTLS\nAUTH GSSAPI DIGEST-MD5',
    rejectCode: 451, rejectMsg: 'rejected',
  },
  heloTLS: {
    acceptCode: 250, acceptMsg: 'ok\nSTARTTLS\nAUTH GSSAPI DIGEST-MD5 PLAIN',
    rejectCode: 451, rejectMsg: 'rejected',
  },
  starttls: {
    acceptCode: 220, acceptMsg: 'ready to start tls',
    rejectCode: 451, rejectMsg: 'rejected',
  },
  from: {
    acceptCode: 250, acceptMsg: 'ok',
    rejectCode: 451, rejectMsg: 'rejected',
  },
  to: {
    acceptCode: 250, acceptMsg: 'ok',
    rejectCode: 451, rejectMsg: 'rejected',
  },
  msgdata: {
    acceptCode: 354, acceptMsg: 'ok',
    rejectCode: 451, rejectMsg: 'rejected',
  },
  authPlain: {
    acceptCode: 235, acceptMsg: '2.7.0 authentication successful',
    rejectCode: 451, rejectMsg: 'rejected',
  },
}

module.exports = Session
function Session(opts) {
  if (!(this instanceof Session)) return new Session(opts)
  TLS.call(this, opts)
  if (!opts) opts = {}
  this.domain = null
  this.from = null
  this.to = null
  this._data = null
  this._dataRead = false
  this._dataNext = null
  this._authPlain = null
  this._split = split((line,next) => {
    this._writeLine(line, next)
  })
  this._send(220, opts.domain + ' ESMTP')
}
Session.prototype = Object.create(TLS.prototype)

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

Session.prototype._writeLine = function (buf, next) {
  let self = this
  const line = buf.toString()
  if (this._data) {
    if (/^\.\r?$/.test(line)) {
      this._data.push(null)
      this._data = null
      this._send(250, 'ok')
    } else {
      this._data.push(line + '\n')
    }
    if (this._dataRead) {
      this._dataRead = false
      return next()
    } else {
      this._dataNext = next
      return
    }
  }
  if (this._authPlain) {
    let f = this._authPlain
    this._authPlain = null
    f(line)
    return next()
  }

  let m
  if (m = /^(helo|ehlo)(?:\s+(\S+))?\s*$/i.exec(line)) {
    let domain = m[2] === undefined ? null : m[2]
    let command = m[1].toLowerCase()
    let d = this._tlsClear ? defaults.heloTLS : defaults.helo
    this._ackEmit('helo', { command, domain }, d, (err, mode) => {
      if (err) {
        this._send(451, String(err.message || err))
        return next()
      }
      if (mode === 'accept') {
        this.domain = domain
      }
      next()
    })
  } else if (m = /^starttls\s*/i.exec(line)) {
    if (this._tlsClear) {
      this._send(503, 'already using tls')
      return next()
    }
    this._ackEmit('starttls', {}, defaults.starttls, (err, mode) => {
      if (err) {
        this._send(451, String(err.message || err))
        return next()
      }
      if (mode === 'accept') this._startTLS()
      next()
    })
  } else if (m = /^mail\s+from\s*:\s*(<[^>]*>|\S*)\s*$/i.exec(line)) {
    let from = m[1].replace(/^<|>$/g,'')
    this._ackEmit('from', from, defaults.from, (err, mode) => {
      if (err) {
        this._send(451, String(err.message || err))
        return next()
      }
      if (mode === 'accept') {
        this.from = from
      }
      next()
    })
  } else if (m = /^(?:rcpt|rpct)(?:\s+|-|)to\s*:\s*(<[^>]*>|\S*)\s*$/i.exec(line)) {
    let to = m[1].replace(/^<|>$/g,'')
    this._ackEmit('to', to, defaults.to, (err, mode) => {
      if (err) {
        this._send(451, String(err.message || err))
        return next()
      }
      if (mode === 'accept') {
        this.to = to
      }
      next()
    })
  } else if (m = /^data\s*$/i.exec(line)) {
    if (this.to === null) {
      this._send(503, 'bad sequence: rcpt-to not provided')
      return next()
    }
    this._ackEmit('msgdata', {}, defaults.msgdata, (err, mode) => {
      if (err) {
        this._send(451, String(err.message || err))
        return next()
      }
      if (mode !== 'accept') return next()
      this._data = new Readable({
        read(n) {
          if (self._dataNext) {
            let f = self._dataNext
            self._dataNext = null
            f()
          } else {
            self._dataRead = true
          }
        }
      })
      this.emit('message', {
        to: this.to,
        from: this.from,
        domain: this.domain,
        data: this._data
      })
      next()
    })
  } else if (m = /^rset\s*$/i.exec(line)) {
    this.domain = null
    this.from = null
    this.to = null
    this._send(250, 'ok')
    next()
  } else if (m = /^noop\s*$/i.exec(line)) {
    this._send(250, 'ok')
    next()
  } else if (m = /^quit\s*$/i.exec(line)) {
    this._send(221, 'bye')
    this._end()
    next()
  } else if (/^auth\s+plain\s*$/.test(line)) {
    this._send(334, '')
    this._authPlain = function (nline) {
      if (/^\*\s*$/.test(nline)) return this._send(501, 'auth plain aborted')
      this._ackEmit('auth', auth, defaults.authPlain, (err,mode) => {
        if (mode === 'accept') this._plainAuth(nline, next)
        else next()
      })
    }
    next()
  } else if (m = /^auth\s+plain\s+(\S+)\s*$/.exec(line)) {
    this._plainAuth(m[1], next)
  } else {
    this._send(500, 'command unrecognized')
    next()
  }
}

Session.prototype._plainAuth = function (s, cb) {
  let p = null
  try { p = Buffer.from(s, 'base64') }
  catch (err) {
    this._send(535, '5.7.8 invalid auth plain base64 encoding')
    return cb()
  }
  let [authzid,authcid,password] = p.split('\0')
  let auth = { type: 'plain', authzid, authcid, password }
  this._ackEmit('auth', auth, defaults.authPlain, cb)
}

Session.prototype._send = function (code, msg) {
  let lines = msg.split(/\r?\n/)
  let data = lines.map((line,i) => {
    return code + (i < lines.length-1 ? '-' : ' ') + line
  }).join('\r\n') + '\r\n'
  this._push(data)
}

Session.prototype._ackEmit = function (evName, obj, defaults, cb) {
  let self = this
  let n = self.listeners(evName).length + 1
  let acceptCode = -1, acceptMsg = null
  let rejectCode = -1, rejectMsg = null
  self.emit(evName, obj, {
    accept: (code, msg) => {
      if (rejectCode >= 0) return
      if (code === undefined) {
        code = defaults.acceptCode
        if (msg === undefined) msg = defaults.acceptMsg
      }
      acceptCode = code
      acceptMsg = msg
      if (--n === 0) finish()
    },
    reject: (code, msg) => {
      if (rejectCode >= 0) return
      if (code === undefined) {
        code = defaults.rejectCode
        if (msg === undefined) msg = defaults.rejectMsg
      }
      rejectCode = code
      rejectMsg = msg
      self._send(code, msg)
      if (typeof cb === 'function') cb(null, 'reject', code, msg)
    },
  })
  if (--n === 0) return finish()
  function finish() {
    if (acceptCode >= 0) {
      self._send(acceptCode, acceptMsg)
      if (typeof cb === 'function') cb(null, 'accept', acceptCode, acceptMsg) 
    } else {
      self._send(defaults.acceptCode, defaults.acceptMsg)
      if (typeof cb === 'function') cb(null, 'accept', defaults.acceptCode, defaults.acceptMsg) 
    }
  }
}