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

const fs = require('fs')
const path = require('path')
const { randomBytes } = require('crypto')
const { pipeline, Writable, Readable, Transform } = require('stream')
const { nextTick } = process
const head = require('./head.js')
const body = require('./body.js')
const parseFetchParts = require('./parse-fetch-parts.js')

module.exports = Inbox

function Inbox(opts) {
  if (!(this instanceof Inbox)) return new Inbox(opts)
  if (typeof opts.maildir !== 'function') {
    throw new Error('opts.maildir must be a function')
  }
  this._maildir = opts.maildir
  this._nextuid = new Map
}

Inbox.prototype.write = function (id) {
  let md = this._maildir(id)
  if (!md) return null
  let ws = null, wbuf = null, wenc = null, wnext = null, wfinal = null
  let w = new Writable({
    write(buf, enc, next) {
      if (ws) {
        ws.write(buf)
        next()
      } else {
        wbuf = buf
        wenc = enc
        wnext = next
      }
    },
    final(next) {
      if (ws) {
        ws.end()
      } else {
        wfinal = next
      }
      next()
    },
  })
  this._getNextUID(id, (err,uid) => {
    if (err) return w.emit('error', err)
    let file = new Date().toISOString().replace(/:/,'h').replace(/:/,'m').replace(/\.\w+$/,'s')
      + '-' + String(uid) + '-' + randomBytes(3).toString('hex')
    w.file = path.join(md,file)
    w.emit('file', w.file)
    fs.mkdir(path.dirname(w.file), { recursive: true }, err => {
      if (err) return w.emit('error', err)
      ws = fs.createWriteStream(w.file)
      if (wnext !== null) {
        let buf = wbuf, enc = wenc, next = wnext
        wbuf = null
        wenc = null
        wnext = null
        ws.write(buf)
        next()
      }
      if (wfinal !== null) {
        ws.end()
        wfinal = null
      }
    })
  })
  return w
}

Inbox.prototype._getNextUID = function (id, cb) {
  let uid = this._nextuid.get(id)
  if (uid) return nextTick(cb, null, uid)
  let uidFile = this.path(id,'_nextuid')
  if (!uidFile) return null
  fs.stat(uidFile, (err, s) => {
    if (!s) return cb(null, 0)
    fs.readFile(uidFile, 'utf8', (err, src) => {
      if (err) return cb(err)
      let uid = Number(src.trim())
      this._nextuid.set(id, uid)
      cb(null, uid)
    })
  })
}

Inbox.prototype.rawWrite = function (id, file, buf, offset, len, pos, cb) {
  if (!cb) cb = noop
  let wfile = this.path(id,file)
  if (!wfile) return null
  fs.open(wfile, (e0,fd) => {
    if (e0) fs.close(fd, e1 => cb(e0))
    else fs.write(fd, buf, offset, len, pos, e1 => {
      fs.close(fd, e2 => {
        if (e1) cb(e1)
        else if (e2) cb(e2)
        else cb()
      })
    })
  })
}

Inbox.prototype.path = function (id, file) {
  let md = this._maildir(id)
  if (!md) return null
  return path.join(md,file)
}

Inbox.prototype.read = function (id, file) {
  return fs.createReadStream(this.path(id,file))
}

Inbox.prototype.head = function (id, file, cb) {
  return pipeline(this.read(id, file), head(f), err => { if (err) cb(err) })
  function f(err, buf) {
    let g = cb
    cb = noop
    g(err, buf)
  }
}

Inbox.prototype.body = function (id, file, cb) {
  if (!cb) cb = noop
  return pipeline(this.read(id, file), body(), cb)
}

Inbox.prototype.list = function (id, cb) {
  var dir = this._maildir(id)
  if (!dir) return cb(null, [])
  fs.readdir(dir, (err,files) => {
    if (err) return cb(err)
    cb(null, files
      .map(x => /^(\d{4})-(\d{2})-(\d{2})T(\d{2})h(\d{2})m(\d{2})s-(\d+)-([0-9a-f]{6})$/.exec(x))
      .filter(x => x)
      .map(x => ({
        file: x[0],
        time: `${x[1]}-${x[2]}-${x[3]} ${x[4]}:${x[5]}:${x[6]}`,
        uid: x[7],
      })))
  })
}

Inbox.prototype.fetch = function (id, range, query) {
  let self = this
  let parts = Array.isArray(query) ? query : parseFetchParts(query)
  let files = null, index = Number(range[0])
  let stream = new Readable({
    objectMode: true,
    read(n) {
      if (!files) {
        self.list(id, (err, lfiles) => {
          if (err) return this.emit('error', err)
          files = lfiles
          next(index)
          index++
        })
      } else {
        next(index)
        index++
      }
    },
  })
  return stream

  function next(i) {
    let f = files[i-1]
    if (i > Number(range[1]) || !f) return stream.push(null)
    // todo: calculate size and stream in better way
    let buffers = []
    pipeline(
      self.body(id, f.file),
      new Writable({
        write(buf, enc, next) {
          buffers.push(buf)
          next()
        },
      }),
      err => {
        if (err) return stream.emit('error', err)
        let buf = Buffer.concat(buffers)
        stream.push(Object.assign({}, f, { data: buf }))
      }
    )
  }
}

Inbox.prototype.remove = function (id, file, cb) {
  let d = this.path(id, file)
  if (!d) return null
  fs.unlink(d, cb)
}

function noop() {}