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

const TLS = require('./starttls.js')
const split = require('./split.js')
const { pipeline, Writable } = require('stream')
const parseFetchParts = require('./parse-fetch-parts.js')

module.exports = IMAP

function IMAP(opts) {
  if (!(this instanceof IMAP)) return new IMAP(opts)
  TLS.call(this, opts)
  this._authenticated = false
  this._inbox = opts.inbox
  this._split = split((line,next) => {
    this._writeLine(line, next)
  })
  this._id = null
  this._fingerprint512 = null
  this._getAccountFromUsername = opts.getAccountFromUsername
  this._push('* OK [CAPABILITY IMAP4rev2 STARTTLS LOGINDISABLED]\r\n')
  this.on('certificate', cert => {
    this._fingerprint512 = cert.fingerprint512
  })
}
IMAP.prototype = Object.create(TLS.prototype)

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

IMAP.prototype._writeLine = function (buf, next) {
  let self = this
  const line = buf.toString()
  console.log('LINE:',line)
  let m = /^(\S+)(?:\s+(.*))?\s*$/.exec(line)
  if (!m) {
    this._push('invalid syntax\r\n')
    return next()
  }
  let tag = m[1]
  let parts = String(m[2] ?? '')
    .match(/[^"\s]\S*|"[^"]+"/g)
    .map(x => x.replace(/^"(.*)"$/g,'$1'))
  let cmd = parts[0].toLowerCase()
  let args = parts.slice(1)
  if (cmd === 'capability') {
    let caps = [ 'STARTTLS' ]
    if (this._tlsClear) {
      caps.push('AUTH=PLAIN', 'AUTH=LOGIN')
    } else {
      caps.push('AUTH=PLAIN', 'AUTH=LOGIN')
      //caps.push('LOGINDISABLED')
    }
    this._push(
      `* CAPABILITY IMAP4rev2 ${caps.join(' ')}\r\n`
      + `${tag} OK CAPABILITY completed\r\n`
    )
    next()
  } else if (cmd === 'starttls') {
    if (this._tlsClear) {
      this._push(`${tag} BAD already using tls\r\n`)
      return next()
    }
    this._push(`${tag} OK STARTTLS completed\r\n`)
    this._startTLS()
    next()
  } else if (cmd === 'noop') {
    this._push(`${tag} OK NOOP completed\r\n`)
    next()
  } else if (cmd === 'authenticate') {
    // ...
  } else if (cmd === 'login') {
    let login = {
      type: 'plain',
      username: args[0],
      password: args[1],
    }
    if (this._fingerprint512) login.fingerprint512 = this._fingerprint512
    this._ackEmit('login', tag, login, (err, mode, msg, id) => {
      if (mode === 'accept' && id !== undefined && id !== null) {
        this._id = id
      }
      next()
    })
  } else if (cmd === 'logout') {
    this._id = null
    this._push(
      '* BYE IMAP4rev2 server logging out\r\n'
      + `${tag} OK LOGOUT completed\r\n`
    )
    this._end()
    next()
  } else if (cmd === 'list' && this._id === null) {
    this._authRequired(tag, 'list', next)
  } else if (cmd === 'list') {
    this._push(
      '* LIST () "/" inbox\r\n'
      + `${tag} OK LIST completed\r\n`
    )
    next()
  } else if (cmd === 'examine' && this._id === null) {
    this._authRequired(tag, 'examine', next)
  } else if (cmd === 'examine') {
    this._inbox.list(this._id, (err,list) => {
      if (err) return this._push(`${tag} BAD ${err.message ?? err}\r\n`)
      this._push(
        `* ${list.length} EXISTS\r\n`
        + `${tag} OK EXAMINE completed\r\n`
      )
      next()
    })
  } else if (cmd === 'fetch' && this._id === null) {
    this._authRequired(tag, 'fetch', next)
  } else if (cmd === 'fetch') {
    let q = args.slice(1).join(' ').replace(/^\(|\)$/,'')
    let parts = parseFetchParts(q)
    pipeline(
      this._inbox.fetch(this._id, args[0], q),
      new Writable({
        objectMode: true,
        write(row, enc, cb) {
          // todo: backpressure
          let response = [`* ${row.uid} FETCH (`]
          for (let i = 0; i < parts.length; i++) {
            let p = parts[i]
            console.log(JSON.stringify(p))
            if (p.name === 'UID') {
              response.push(`UID (${row.uid})`)
            } else if (p.name === 'FLAGS') {
              response.push(`FLAGS ()`)
            } else if (p.name === 'INTERNALDATE') {
              response.push(`INTERNALDATE "${row.time}"`)
            } else if (p.name === 'RFC822.SIZE') {
              response.push(`RFC822.SIZE ${row.data.length}`)
            } else if (p.name === 'BODY.PEEK') {
              response.push(`BODY.PEEK[${
                p.sections.map(s => s.name + ' (' + s.fields.join(' ') + ')')
              }] (\r\n${row.data}\r\n)`)
            }
          }
          self._push(response.join(' ') + '\r\n)\r\n')
          cb()
        },
        final(cb) {
          self._push(`${tag} OK FETCH completed\r\n`)
          cb()
        },
      }),
      next
    )
  } else {
    this._push('* BAD command unrecognized\r\n')
    next()
  }
}

IMAP.prototype._authRequired = function (tag, cmd, next) {
  this._push(`${tag} NO ${cmd.toUpperCase()} authorization required\r\n`)
  next()
}

IMAP.prototype._ackEmit = function (evName, tag, obj, cb) {
  let self = this
  let n = self.listeners(evName).length + 1
  let acceptMsg = null, acceptObj = null, rejected = false
  self.emit(evName, obj, {
    accept: (msg, aobj) => {
      acceptMsg = msg
      acceptObj = aobj
      //console.log('ACCEPT',msg)
      if (--n === 0) finish()
    },
    reject: (msg, aobj) => {
      if (rejected) return
      rejected = true
      //console.log('REJECT',msg)
      self._push(`${tag} NO${msg === undefined ? '' : ' ' + msg}\r\n`)
      if (typeof cb === 'function') cb(null, 'reject', msg, aobj)
    },
    error: (msg, aobj) => {
      if (rejected) return
      rejected = true
      //console.log('ERROR',msg)
      self._push(`${tag} BAD${msg === undefined ? '' : ' ' + msg}\r\n`)
      if (typeof cb === 'function') cb(null, 'error', msg, aobj)
    },
  })
  if (--n === 0) return finish()
  function finish() {
    if (rejected) return
    //console.log(`PUSH ${tag} OK${acceptMsg === undefined ? '' : ' ' + acceptMsg}\r\n`)
    self._push(`${tag} OK${acceptMsg === undefined ? '' : ' ' + acceptMsg}\r\n`)
    if (typeof cb === 'function') cb(null, 'accept', acceptMsg, acceptObj)
  }
}

IMAP.prototype.preauth = function (user) {
  this._push(`* PREAUTH IMAP4rev2 server logged in as ${user}\r\n`)
}

function noop() {}