all-in-one smtp+imap with minimal setup
git clone http://git.nthia.dev/nthmail
const os = require('os')
const { pipeline } = require('stream')
const SMTP = require('./lib/smtp.js')
const IMAP = require('./lib/imap.js')
const Inbox = require('./lib/inbox.js')
const Outbox = require('./lib/outbox.js')
const Login = require('./lib/login.js')
module.exports = NthMail
function NthMail(opts) {
if (!(this instanceof NthMail)) return new NthMail(opts)
if (!opts) opts = {}
this._tls = {}
if (opts.key) this._tls.key = opts.key
if (opts.cert) this._tls.cert = opts.cert
if (opts.ca) this._tls.ca = opts.ca
this._createServer = opts.createServer
if (typeof this._createServer !== 'function') {
throw new Error('required opts.createServer function not provided')
}
this._servers = []
this._login = new Login({
accounts: opts.accounts,
logins: opts.logins,
domains: opts.domains,
})
this._inbox = new Inbox({
maildir: id => this._maildir(id, 'inbox'),
})
this._outbox = new Outbox({
outboxDir: id => this._maildir(id, 'outbox'),
undeliveredDir: id => this._maildir(id, 'unbox'),
inbox: this._inbox,
tls: () => this._tls,
})
}
NthMail.prototype._maildir = function (id, key) {
let a = this._login.getAccountFromId(id)
return a ? a[key] : null
}
NthMail.prototype.listen = function (ports, cb) {
let pending = 1
let handlers = [
[ 'smtp', this._handleSMTP.bind(this) ],
[ 'imap', this._handleIMAP.bind(this) ],
]
let servers = {
smtp: { clear: [], ssl: [] },
imap: { clear: [], ssl: [] },
}
handlers.forEach(([proto,handle]) => {
if (!ports[proto]) return
;['clear','ssl'].forEach(sslType => {
let ps = null
if (Array.isArray(ports[proto])) {
ps = ports[proto].filter(p => /^\+/.test(p) === (sslType === 'ssl'))
.map(p => Number(String(p).replace(/^\+/,'')))
} else if (ports[proto] && Array.isArray(ports[proto][sslType])) {
ps = ports[proto][sslType]
} else {
return
}
ps.forEach(port => {
let server = this._createServer(handle(sslType === 'ssl'))
servers[proto][sslType].push(server)
this._servers.push({ port, server })
pending++
server.listen(port, (err) => {
if (err) {
cb(err)
cb = noop
} else if (--pending === 0) {
cb(null, servers)
}
})
})
})
})
if (--pending === 0) cb(null)
}
NthMail.prototype.close = function (cb) {
if (!cb) cb = noop
let pending = 1 + this._servers.length
for (let i = 0; i < this._servers.length; i++) {
this._servers[i].server.close(done)
}
done()
function done() { if (--pending === 0) cb() }
}
NthMail.prototype._handleSMTP = function (ssl) {
let self = this
return function handle(stream) {
let smtp = new SMTP({
domain: 'nthia.dev',
tls: this._tls,
getFromEmail: (email, cb) => {
self._getFromEmail(email, cb)
},
})
pipeline(smtp, stream, smtp, (err) => {})
if (ssl) smtp._startTLS()
let fingerprint = null
let account = null
smtp.on('certificate', cert => {
if (!cert.fingerprint512) return
let login = { type: 'fingerprint', fingerprint512: cert.fingerprint512 }
self._login.checkLogin(login, (err, id) => {
if (err) return imap._push('* BAD ${err.message ?? err}\r\n')
if (id === null) return
let u = self._login.getAccountFromId(id)
if (u) imap.preauth(u)
})
})
smtp.on('to', (to,ack) => {
let toD = to.replace(/^[^@]*@/,'')
if (self._login.hasDomain(toD)) {
let a = self._login.getAccountFromEmail(to)
if (!a) ack.reject(550, `no such recipient`)
else ack.accept()
} else if (account) {
ack.accept()
} else {
ack.reject()
}
})
smtp.on('from', (from,ack) => {
let [fromU,fromD] = from.split('@')
if (self._login.hasDomain(fromD) && account && (account.names ?? []).includes(fromU)) {
ack.accept()
} else if (self._login.hasDomain(fromD)) {
ack.reject()
} else {
ack.accept()
}
})
smtp.on('msgdata', (md,ack) => {
if (!smtp.to) return ack.reject()
if (!smtp.from) return ack.reject()
let [toU,toD] = smtp.to.split('@')
toU = toU.replace(/\+.*/,'')
let [fromU,fromD] = smtp.from.split('@')
fromU = fromU.replace(/\+.*/,'')
if (!self._login.hasDomain(toD) && !self._login.hasDomain(fromD)) return ack.reject()
return ack.accept()
})
smtp.on('message', msg => {
console.log('to:', msg.to, 'from:', msg.from, 'domain:', msg.domain)
let [toU,toD] = smtp.to.split('@')
toU = toU.replace(/\+.*/,'')
if (self._login.hasDomain(toD)) {
let a = self._login.getAccountFromEmail(msg.to)
if (!a) return stream.destroy() // TODO: write error back to client
write(msg, self._inbox.write(a.id))
} else {
let a = self._login.getAccountFromEmail(msg.from)
if (!a) return stream.destroy() // TODO: write error back to client
write(msg, self._outbox.write(a.id))
}
})
function write(msg, w) {
let pre = [
'domain: ' + msg.domain,
'to: ' + msg.to,
'from: ' + msg.from,
'address: ' + stream.remoteAddress,
]
let toD = msg.to.replace(/^[^@]*@/,'')
if (!self._login.hasDomain(toD)) { // outgoing
pre.push('last-attempt: ' + new Date(0).toISOString())
pre.push('retry: 0000')
}
w.write(pre.join('\n') + '\n\n')
msg.data.pipe(w)
}
}
}
NthMail.prototype._handleIMAP = function (ssl) {
let self = this
return function handle(stream) {
let imap = new IMAP({
tls: self._tls,
inbox: self._inbox,
getAccountFromUsername: (username, cb) => {
cb(null, self._login.getAccountFromUsername(username))
},
})
let accounts = null
imap.on('certificate', cert => {
if (!cert.fingerprint512) return
if (ssl) {
let login = { type: 'fingerprint', fingerprint512: cert.fingerprint512 }
self._login.checkLogin(login, (err, id) => {
if (err) return imap._push('* BAD ${err.message ?? err}\r\n')
let u = self._login.getAccountFromId(id)
if (u) imap.preauth(u)
})
}
})
imap.on('login', (login,ack) => {
self._login.checkLogin(login, (err, id) => {
if (err) ack.reject(err && err.message || String(err))
else if (id !== undefined && id !== null) ack.accept('login completed', id)
else ack.reject('login failed')
})
})
pipeline(imap, stream, imap, (err) => {})
if (ssl) imap._startTLS()
}
}
function noop() {}