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)
}
}
}