node-imap
Version:
A fork of the famous and outdated IMAP module for node.js that makes communicating with IMAP servers easy
1,532 lines (1,512 loc) • 54.2 kB
JavaScript
'use strict'
const tls = require('tls'),
crypto = require('crypto'),
Socket = require('net').Socket,
EventEmitter = require('events').EventEmitter,
// inherits = require('util').inherits,
inspect = require('util').inspect,
isDate = require('util').isDate,
utf7 = require('utf7').imap,
MONTHS = require('util').MONTHS
const Parser = require('./Parser').Parser,
parseExpr = require('./Parser').parseExpr,
parseHeader = require('./Parser').parseHeader
const validateUIDList = require('./utils').validateUIDList,
_deepEqual = require('./utils')._deepEqual,
escape = require('./utils').escape,
buildSearchQuery = require('./utils').buildSearchQuery
const MAX_INT = Number.MAX_SAFE_INTEGER,
KEEPALIVE_INTERVAL = 10000,
MAX_IDLE_WAIT = 300000, // 5 minutes
FETCH_ATTR_MAP = {
'RFC822.SIZE': 'size',
'BODY': 'struct',
'BODYSTRUCTURE': 'struct',
'ENVELOPE': 'envelope',
'INTERNALDATE': 'date'
},
SPECIAL_USE_ATTRIBUTES = [
'\\All',
'\\Archive',
'\\Drafts',
'\\Flagged',
'\\Important',
'\\Junk',
'\\Sent',
'\\Trash'
],
CRLF = '\r\n',
RE_CMD = /^([^ ]+)(?: |$)/,
RE_UIDCMD_HASRESULTS = /^UID (?:FETCH|SEARCH|SORT)/,
RE_IDLENOOPRES = /^(IDLE|NOOP) /,
RE_OPENBOX = /^EXAMINE|SELECT$/,
RE_BODYPART = /^BODY\[/,
RE_INVALID_KW_CHARS = /[\(\)\{\\\"\]\%\*\x00-\x20\x7F]/,
RE_ESCAPE = /\\\\/g
/**
* Connection
* @param {*} config
*/
class Connection extends EventEmitter {
constructor(config) {
super()
if (!(this instanceof Connection))
return new Connection(config)
// EventEmitter.call(this);
config || (config = {})
this._config = {
localAddress: config.localAddress,
socket: config.socket,
socketTimeout: config.socketTimeout || 0,
host: config.host || 'localhost',
port: config.port || 143,
tls: config.tls,
tlsOptions: config.tlsOptions,
autotls: config.autotls,
user: config.user,
password: config.password,
xoauth: config.xoauth,
xoauth2: config.xoauth2,
connTimeout: config.connTimeout || 10000,
authTimeout: config.authTimeout || 5000,
keepalive: (config.keepalive === undefined || config.keepalive === null
? true
: config.keepalive)
}
this._sock = config.socket || undefined
this._tagcount = 0
this._tmrConn = undefined
this._tmrKeepalive = undefined
this._tmrAuth = undefined
this._queue = []
this._box = undefined
this._idle = { started: undefined, enabled: false }
this._parser = undefined
this._curReq = undefined
this.delimiter = undefined
this.namespaces = undefined
this.state = 'disconnected'
this.debug = config.debug
}
connect() {
const config = this._config, self = this
let socket, parser, tlsOptions
socket = config.socket || new Socket()
socket.setKeepAlive(true)
this._sock = undefined
this._tagcount = 0
this._tmrConn = undefined
this._tmrKeepalive = undefined
this._tmrAuth = undefined
this._queue = []
this._box = undefined
this._idle = { started: undefined, enabled: false }
this._parser = undefined
this._curReq = undefined
this.delimiter = undefined
this.namespaces = undefined
this.state = 'disconnected'
if (config.tls) {
tlsOptions = {}
tlsOptions.host = config.host
tlsOptions.servername = config.host
// Host name may be overridden the tlsOptions
for (let k in config.tlsOptions)
tlsOptions[k] = config.tlsOptions[k]
tlsOptions.socket = socket
}
if (config.tls) {
this._sock = tls.connect(tlsOptions, onconnect)
this._isTsl = true
} else {
socket.once('connect', onconnect)
this._sock = socket
}
function onconnect() {
clearTimeout(self._tmrConn)
self.state = 'connected'
self.debug && self.debug('[connection] Connected to host')
self._tmrAuth = setTimeout(function () {
const err = new Error('Timed out while authenticating with server')
err.source = 'timeout-auth'
self.emit('error', err)
socket.destroy()
}, config.authTimeout)
}
let additionalClose = !config.socket
this._onError = function (err) {
clearTimeout(self._tmrConn)
clearTimeout(self._tmrAuth)
self.debug && self.debug('[connection] Error: ' + err)
err.source = 'socket'
if (additionalClose && self._isTsl)
socket.destroy()
self.emit('error', err)
}
this._sock.on('error', this._onError)
this._onSocketTimeout = function () {
clearTimeout(self._tmrConn)
clearTimeout(self._tmrAuth)
clearTimeout(self._tmrKeepalive)
self.state = 'disconnected'
self.debug && self.debug('[connection] Socket timeout')
const err = new Error('Socket timed out while talking to server')
err.source = 'socket-timeout'
self.emit('error', err)
socket.destroy()
}
this._sock.on('timeout', this._onSocketTimeout)
socket.setTimeout(config.socketTimeout)
socket.once('close', function (had_err) {
clearTimeout(self._tmrConn)
clearTimeout(self._tmrAuth)
clearTimeout(self._tmrKeepalive)
self.state = 'disconnected'
self.debug && self.debug('[connection] Closed')
self.emit('close', had_err)
})
socket.once('end', function () {
clearTimeout(self._tmrConn)
clearTimeout(self._tmrAuth)
clearTimeout(self._tmrKeepalive)
self.state = 'disconnected'
self.debug && self.debug('[connection] Ended')
self.emit('end')
})
this._parser = parser = new Parser(this._sock, this.debug)
parser.on('untagged', function (info) {
self._resUntagged(info)
})
parser.on('tagged', function (info) {
self._resTagged(info)
})
parser.on('body', function (stream, info) {
let msg = self._curReq.fetchCache[info.seqno]
let toget
if (msg === undefined) {
msg = self._curReq.fetchCache[info.seqno] = {
msgEmitter: new EventEmitter(),
toget: self._curReq.fetching.slice(0),
attrs: {},
ended: false
}
self._curReq.bodyEmitter.emit('message', msg.msgEmitter, info.seqno)
}
toget = msg.toget
// here we compare the parsed version of the expression inside BODY[]
// because 'HEADER.FIELDS (TO FROM)' really is equivalent to
// 'HEADER.FIELDS ("TO" "FROM")' and some servers will actually send the
// quoted form even if the client did not use quotes
const thisbody = parseExpr(info.which.toUpperCase())
for (let i = 0, len = toget.length; i < len; ++i) {
if (_deepEqual(thisbody, toget[i])) {
toget.splice(i, 1)
msg.msgEmitter.emit('body', stream, info)
return
}
}
stream.resume() // a body we didn't ask for?
})
parser.on('continue', function (info) {
const type = self._curReq.type
if (type === 'IDLE') {
if (self._queue.length
&& self._idle.started === 0
&& self._curReq
&& self._curReq.type === 'IDLE'
&& self._sock
&& self._sock.writable
&& !self._idle.enabled) {
self.debug && self.debug('=> DONE')
self._sock.write('DONE' + CRLF)
return
}
// now idling
self._idle.started = Date.now()
}
else if (/^AUTHENTICATE XOAUTH/.test(self._curReq.fullcmd)) {
self._curReq.oauthError = Buffer.from(info.text, 'base64').toString('utf8') //new Buffer(info.text, 'base64').toString('utf8');
self.debug && self.debug('=> ' + inspect(CRLF))
self._sock.write(CRLF)
} else if (/^AUTHENTICATE CRAM-MD5/.test(self._curReq.fullcmd)) {
self._authCRAMMD5(info.text)
} else if (type === 'APPEND') {
self._sockWriteAppendData(self._curReq.appendData)
}
else if (self._curReq.lines && self._curReq.lines.length) {
const line = self._curReq.lines.shift() + '\r\n'
self.debug && self.debug('=> ' + inspect(line))
self._sock.write(line, 'binary')
}
})
parser.on('other', function (line) {
let m
if (m = RE_IDLENOOPRES.exec(line)) {
// no longer idling
self._idle.enabled = false
self._idle.started = undefined
clearTimeout(self._tmrKeepalive)
self._curReq = undefined
if (self._queue.length === 0
&& self._config.keepalive
&& self.state === 'authenticated'
&& !self._idle.enabled) {
self._idle.enabled = true
if (m[1] === 'NOOP')
self._doKeepaliveTimer()
else
self._doKeepaliveTimer(true)
}
self._processQueue()
}
})
this._tmrConn = setTimeout(function () {
const err = new Error('Timed out while connecting to server')
err.source = 'timeout'
self.emit('error', err)
socket.destroy()
}, config.connTimeout)
socket.connect({
port: config.port,
host: config.host,
localAddress: config.localAddress
})
}
serverSupports(cap) {
return (this._caps && this._caps.indexOf(cap) > -1)
}
destroy() {
this._queue = []
this._curReq = undefined
this._sock && this._sock.end()
}
end() {
const self = this
this._enqueue('LOGOUT', function () {
self._queue = []
self._curReq = undefined
self._sock.end()
})
}
append(data, options, cb) {
const literal = this.serverSupports('LITERAL+')
if (typeof options === 'function') {
cb = options
options = undefined
}
options = options || {}
if (!options.mailbox) {
if (!this._box)
throw new Error('No mailbox specified or currently selected')
else
options.mailbox = this._box.name
}
let cmd = 'APPEND "' + escape(utf7.encode('' + options.mailbox)) + '"'
if (options.flags) {
if (!Array.isArray(options.flags))
options.flags = [options.flags]
if (options.flags.length > 0) {
for (let i = 0, len = options.flags.length; i < len; ++i) {
if (options.flags[i][0] !== '$' && options.flags[i][0] !== '\\')
options.flags[i] = '\\' + options.flags[i]
}
cmd += ' (' + options.flags.join(' ') + ')'
}
}
if (options.date) {
if (!isDate(options.date))
throw new Error('`date` is not a Date object')
cmd += ' "'
cmd += ('0' + options.date.getDate()).slice(-2)
cmd += '-'
cmd += MONTHS[options.date.getMonth()]
cmd += '-'
cmd += options.date.getFullYear()
cmd += ' '
cmd += ('0' + options.date.getHours()).slice(-2)
cmd += ':'
cmd += ('0' + options.date.getMinutes()).slice(-2)
cmd += ':'
cmd += ('0' + options.date.getSeconds()).slice(-2)
cmd += ((options.date.getTimezoneOffset() > 0) ? ' -' : ' +')
cmd += ('0' + (-options.date.getTimezoneOffset() / 60)).slice(-2)
cmd += ('0' + (-options.date.getTimezoneOffset() % 60)).slice(-2)
cmd += '"'
}
cmd += ' {'
cmd += (Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data))
cmd += (literal ? '+' : '') + '}'
this._enqueue(cmd, cb)
if (literal)
this._queue[this._queue.length - 1].literalAppendData = data
else
this._queue[this._queue.length - 1].appendData = data
}
getSpecialUseBoxes(cb) {
this._enqueue('XLIST "" "*"', cb)
}
getBoxes(namespace, cb) {
if (typeof namespace === 'function') {
cb = namespace
namespace = ''
}
namespace = escape(utf7.encode('' + namespace))
this._enqueue('LIST "' + namespace + '" "*"', cb)
}
id(identification, cb) {
if (!this.serverSupports('ID'))
throw new Error('Server does not support ID')
let cmd = 'ID'
if ((identification === null) || (Object.keys(identification).length === 0))
cmd += ' NIL'
else {
if (Object.keys(identification).length > 30)
throw new Error('Max allowed number of keys is 30')
const kv = []
for (let k in identification) {
if (Buffer.byteLength(k) > 30)
throw new Error('Max allowed key length is 30')
if (Buffer.byteLength(identification[k]) > 1024)
throw new Error('Max allowed value length is 1024')
kv.push('"' + escape(k) + '"')
kv.push('"' + escape(identification[k]) + '"')
}
cmd += ' (' + kv.join(' ') + ')'
}
this._enqueue(cmd, cb)
}
openBox(name, readOnly, cb) {
if (this.state !== 'authenticated')
throw new Error('Not authenticated')
if (typeof readOnly === 'function') {
cb = readOnly
readOnly = false
}
name = '' + name
const encname = escape(utf7.encode(name))
let cmd = (readOnly ? 'EXAMINE' : 'SELECT'), self = this
cmd += ' "' + encname + '"'
if (this.serverSupports('CONDSTORE'))
cmd += ' (CONDSTORE)'
this._enqueue(cmd, function (err) {
if (err) {
self._box = undefined
cb(err)
}
else {
self._box.name = name
cb(err, self._box)
}
})
}
closeBox(shouldExpunge, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
const self = this
if (typeof shouldExpunge === 'function') {
cb = shouldExpunge
shouldExpunge = true
}
if (shouldExpunge) {
this._enqueue('CLOSE', function (err) {
if (!err)
self._box = undefined
cb(err)
})
}
else {
if (this.serverSupports('UNSELECT')) {
// use UNSELECT if available, as it claims to be "cleaner" than the
// alternative "hack"
this._enqueue('UNSELECT', function (err) {
if (!err)
self._box = undefined
cb(err)
})
}
else {
// "HACK": close the box without expunging by attempting to SELECT a
// non-existent mailbox
const badbox = 'NODEJSIMAPCLOSINGBOX' + Date.now()
this._enqueue('SELECT "' + badbox + '"', function (err) {
self._box = undefined
cb()
})
}
}
}
addBox(name, cb) {
this._enqueue('CREATE "' + escape(utf7.encode('' + name)) + '"', cb)
}
delBox(name, cb) {
this._enqueue('DELETE "' + escape(utf7.encode('' + name)) + '"', cb)
}
renameBox(oldname, newname, cb) {
const encoldname = escape(utf7.encode('' + oldname)), encnewname = escape(utf7.encode('' + newname)), self = this
this._enqueue('RENAME "' + encoldname + '" "' + encnewname + '"', function (err) {
if (err)
return cb(err)
if (self._box
&& self._box.name === oldname
&& oldname.toUpperCase() !== 'INBOX') {
self._box.name = newname
cb(err, self._box)
}
else
cb()
})
}
subscribeBox(name, cb) {
this._enqueue('SUBSCRIBE "' + escape(utf7.encode('' + name)) + '"', cb)
}
unsubscribeBox(name, cb) {
this._enqueue('UNSUBSCRIBE "' + escape(utf7.encode('' + name)) + '"', cb)
}
getSubscribedBoxes(namespace, cb) {
if (typeof namespace === 'function') {
cb = namespace
namespace = ''
}
namespace = escape(utf7.encode('' + namespace))
this._enqueue('LSUB "' + namespace + '" "*"', cb)
}
status(boxName, cb) {
if (this._box && this._box.name === boxName)
throw new Error('Cannot call status on currently selected mailbox')
boxName = escape(utf7.encode('' + boxName))
let info = ['MESSAGES', 'RECENT', 'UNSEEN', 'UIDVALIDITY', 'UIDNEXT']
if (this.serverSupports('CONDSTORE'))
info.push('HIGHESTMODSEQ')
info = info.join(' ')
this._enqueue('STATUS "' + boxName + '" (' + info + ')', cb)
}
expunge(uids, cb) {
if (typeof uids === 'function') {
cb = uids
uids = undefined
}
if (uids !== undefined) {
if (!Array.isArray(uids))
uids = [uids]
validateUIDList(uids)
if (uids.length === 0)
throw new Error('Empty uid list')
uids = uids.join(',')
if (!this.serverSupports('UIDPLUS'))
throw new Error('Server does not support this feature (UIDPLUS)')
this._enqueue('UID EXPUNGE ' + uids, cb)
}
else
this._enqueue('EXPUNGE', cb)
}
search(criteria, cb) {
this._search('UID ', criteria, cb)
}
_search(which, criteria, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
else if (!Array.isArray(criteria))
throw new Error('Expected array for search criteria')
let cmd = which + 'SEARCH', info = { hasUTF8: false /*output*/ }, query = buildSearchQuery(criteria, this._caps, info)
let lines
if (info.hasUTF8) {
cmd += ' CHARSET UTF-8'
lines = query.split(CRLF)
query = lines.shift()
}
cmd += query
this._enqueue(cmd, cb)
if (info.hasUTF8) {
const req = this._queue[this._queue.length - 1]
req.lines = lines
}
}
addFlags(uids, flags, cb) {
this._store('UID ', uids, { mode: '+', flags: flags }, cb)
}
delFlags(uids, flags, cb) {
this._store('UID ', uids, { mode: '-', flags: flags }, cb)
}
setFlags(uids, flags, cb) {
this._store('UID ', uids, { mode: '', flags: flags }, cb)
}
addKeywords(uids, keywords, cb) {
this._store('UID ', uids, { mode: '+', keywords: keywords }, cb)
}
delKeywords(uids, keywords, cb) {
this._store('UID ', uids, { mode: '-', keywords: keywords }, cb)
}
setKeywords(uids, keywords, cb) {
this._store('UID ', uids, { mode: '', keywords: keywords }, cb)
}
_store(which, uids, cfg, cb) {
const mode = cfg.mode, isFlags = (cfg.flags !== undefined)
let items = (isFlags ? cfg.flags : cfg.keywords)
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
else if (uids === undefined)
throw new Error('No messages specified')
if (!Array.isArray(uids))
uids = [uids]
validateUIDList(uids)
if (uids.length === 0) {
throw new Error('Empty '
+ (which === '' ? 'sequence number' : 'uid')
+ 'list')
}
if ((!Array.isArray(items) && typeof items !== 'string')
|| (Array.isArray(items) && items.length === 0))
throw new Error((isFlags ? 'Flags' : 'Keywords')
+ ' argument must be a string or a non-empty Array')
if (!Array.isArray(items))
items = [items]
for (let i = 0, len = items.length; i < len; ++i) {
if (isFlags) {
if (items[i][0] !== '\\')
items[i] = '\\' + items[i]
}
else {
// keyword contains any char except control characters (%x00-1F and %x7F)
// and: '(', ')', '{', ' ', '%', '*', '\', '"', ']'
if (RE_INVALID_KW_CHARS.test(items[i])) {
throw new Error('The keyword "' + items[i]
+ '" contains invalid characters')
}
}
}
items = items.join(' ')
uids = uids.join(',')
let modifiers = ''
if (cfg.modseq !== undefined && !this._box.nomodseq)
modifiers += 'UNCHANGEDSINCE ' + cfg.modseq + ' '
this._enqueue(which + 'STORE ' + uids + ' '
+ modifiers
+ mode + 'FLAGS.SILENT (' + items + ')', cb)
}
copy(uids, boxTo, cb) {
this._copy('UID ', uids, boxTo, cb)
}
_copy(which, uids, boxTo, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
if (!Array.isArray(uids))
uids = [uids]
validateUIDList(uids)
if (uids.length === 0) {
throw new Error('Empty '
+ (which === '' ? 'sequence number' : 'uid')
+ 'list')
}
boxTo = escape(utf7.encode('' + boxTo))
this._enqueue(which + 'COPY ' + uids.join(',') + ' "' + boxTo + '"', cb)
}
move(uids, boxTo, cb) {
this._move('UID ', uids, boxTo, cb)
}
_move(which, uids, boxTo, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
if (this.serverSupports('MOVE')) {
if (!Array.isArray(uids))
uids = [uids]
validateUIDList(uids)
if (uids.length === 0) {
throw new Error('Empty '
+ (which === '' ? 'sequence number' : 'uid')
+ 'list')
}
uids = uids.join(',')
boxTo = escape(utf7.encode('' + boxTo))
this._enqueue(which + 'MOVE ' + uids + ' "' + boxTo + '"', cb)
}
else if (this._box.permFlags.indexOf('\\Deleted') === -1
&& this._box.flags.indexOf('\\Deleted') === -1) {
throw new Error('Cannot move message: '
+ 'server does not allow deletion of messages')
}
else {
let deletedUIDs
let task = 0
const self = this
this._copy(which, uids, boxTo, function ccb(err, info) {
if (err)
return cb(err, info)
if (task === 0 && which && self.serverSupports('UIDPLUS')) {
// UIDPLUS gives us a 'UID EXPUNGE n' command to expunge a subset of
// messages with the \Deleted flag set. This allows us to skip some
// actions.
task = 2
}
// Make sure we don't expunge any messages marked as Deleted except the
// one we are moving
if (task === 0) {
self.search(['DELETED'], function (e, result) {
++task
deletedUIDs = result
ccb(e, info)
})
}
else if (task === 1) {
if (deletedUIDs.length) {
self.delFlags(deletedUIDs, '\\Deleted', function (e) {
++task
ccb(e, info)
})
}
else {
++task
ccb(err, info)
}
}
else if (task === 2) {
const cbMarkDel = function (e) {
++task
ccb(e, info)
}
if (which)
self.addFlags(uids, '\\Deleted', cbMarkDel)
else
self.seq.addFlags(uids, '\\Deleted', cbMarkDel)
}
else if (task === 3) {
if (which && self.serverSupports('UIDPLUS')) {
self.expunge(uids, function (e) {
cb(e, info)
})
}
else {
self.expunge(function (e) {
++task
ccb(e, info)
})
}
}
else if (task === 4) {
if (deletedUIDs.length) {
self.addFlags(deletedUIDs, '\\Deleted', function (e) {
cb(e, info)
})
}
else
cb(err, info)
}
})
}
}
fetch(uids, options) {
return this._fetch('UID ', uids, options)
}
_fetch(which, uids, options) {
if (uids === undefined
|| uids === null
|| (Array.isArray(uids) && uids.length === 0))
throw new Error('Nothing to fetch')
if (!Array.isArray(uids))
uids = [uids]
validateUIDList(uids)
if (uids.length === 0) {
throw new Error('Empty '
+ (which === '' ? 'sequence number' : 'uid')
+ 'list')
}
uids = uids.join(',')
let cmd = which + 'FETCH ' + uids + ' ('
const fetching = []
let i, len, key
if (this.serverSupports('X-GM-EXT-1')) {
fetching.push('X-GM-THRID')
fetching.push('X-GM-MSGID')
fetching.push('X-GM-LABELS')
}
if (this.serverSupports('CONDSTORE') && !this._box.nomodseq)
fetching.push('MODSEQ')
fetching.push('UID')
fetching.push('FLAGS')
fetching.push('INTERNALDATE')
let modifiers
if (options) {
modifiers = options.modifiers
if (options.envelope)
fetching.push('ENVELOPE')
if (options.struct)
fetching.push('BODYSTRUCTURE')
if (options.size)
fetching.push('RFC822.SIZE')
if (Array.isArray(options.extensions)) {
options.extensions.forEach(function (extension) {
fetching.push(extension.toUpperCase())
})
}
cmd += fetching.join(' ')
if (options.bodies !== undefined) {
let bodies = options.bodies, prefix = (options.markSeen ? '' : '.PEEK')
if (!Array.isArray(bodies))
bodies = [bodies]
for (i = 0, len = bodies.length; i < len; ++i) {
fetching.push(parseExpr('' + bodies[i]))
cmd += ' BODY' + prefix + '[' + bodies[i] + ']'
}
}
}
else
cmd += fetching.join(' ')
cmd += ')'
const modkeys = (typeof modifiers === 'object' ? Object.keys(modifiers) : [])
let modstr = ' ('
for (i = 0, len = modkeys.length, key; i < len; ++i) {
key = modkeys[i].toUpperCase()
if (key === 'CHANGEDSINCE' && this.serverSupports('CONDSTORE')
&& !this._box.nomodseq)
modstr += key + ' ' + modifiers[modkeys[i]] + ' '
}
if (modstr.length > 2) {
cmd += modstr.substring(0, modstr.length - 1)
cmd += ')'
}
this._enqueue(cmd)
const req = this._queue[this._queue.length - 1]
req.fetchCache = {}
req.fetching = fetching
return (req.bodyEmitter = new EventEmitter())
}
// Extension methods ===========================================================
setLabels(uids, labels, cb) {
this._storeLabels('UID ', uids, labels, '', cb)
}
addLabels(uids, labels, cb) {
this._storeLabels('UID ', uids, labels, '+', cb)
}
delLabels(uids, labels, cb) {
this._storeLabels('UID ', uids, labels, '-', cb)
}
_storeLabels(which, uids, labels, mode, cb) {
if (!this.serverSupports('X-GM-EXT-1'))
throw new Error('Server must support X-GM-EXT-1 capability')
else if (this._box === undefined)
throw new Error('No mailbox is currently selected')
else if (uids === undefined)
throw new Error('No messages specified')
if (!Array.isArray(uids))
uids = [uids]
validateUIDList(uids)
if (uids.length === 0) {
throw new Error('Empty '
+ (which === '' ? 'sequence number' : 'uid')
+ 'list')
}
if ((!Array.isArray(labels) && typeof labels !== 'string')
|| (Array.isArray(labels) && labels.length === 0))
throw new Error('labels argument must be a string or a non-empty Array')
if (!Array.isArray(labels))
labels = [labels]
labels = labels.map(function (v) {
return '"' + escape(utf7.encode('' + v)) + '"'
}).join(' ')
uids = uids.join(',')
this._enqueue(which + 'STORE ' + uids + ' ' + mode
+ 'X-GM-LABELS.SILENT (' + labels + ')', cb)
}
sort(sorts, criteria, cb) {
this._sort('UID ', sorts, criteria, cb)
}
_sort(which, sorts, criteria, cb) {
let displaySupported = false
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
else if (!Array.isArray(sorts) || !sorts.length)
throw new Error('Expected array with at least one sort criteria')
else if (!Array.isArray(criteria))
throw new Error('Expected array for search criteria')
else if (!this.serverSupports('SORT'))
throw new Error('Sort is not supported on the server')
if (this.serverSupports('SORT=DISPLAY')) {
displaySupported = true
}
sorts = sorts.map(function (c) {
if (typeof c !== 'string')
throw new Error('Unexpected sort criteria data type. '
+ 'Expected string. Got: ' + typeof criteria)
let modifier = ''
if (c[0] === '-') {
modifier = 'REVERSE '
c = c.substring(1)
}
switch (c.toUpperCase()) {
case 'ARRIVAL':
case 'CC':
case 'DATE':
case 'FROM':
case 'SIZE':
case 'SUBJECT':
case 'TO':
break
case 'DISPLAYFROM':
case 'DISPLAYTO':
if (!displaySupported) {
throw new Error('Unexpected sort criteria: ' + c)
}
break
default:
throw new Error('Unexpected sort criteria: ' + c)
}
return modifier + c
})
sorts = sorts.join(' ')
const info = { hasUTF8: false /*output*/ }
let query = buildSearchQuery(criteria, this._caps, info)
let charset = 'US-ASCII'
let lines
if (info.hasUTF8) {
charset = 'UTF-8'
lines = query.split(CRLF)
query = lines.shift()
}
this._enqueue(which + 'SORT (' + sorts + ') ' + charset + query, cb)
if (info.hasUTF8) {
const req = this._queue[this._queue.length - 1]
req.lines = lines
}
}
esearch(criteria, options, cb) {
this._esearch('UID ', criteria, options, cb)
}
_esearch(which, criteria, options, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected')
else if (!Array.isArray(criteria))
throw new Error('Expected array for search options')
const info = { hasUTF8: false /*output*/ }
let query = buildSearchQuery(criteria, this._caps, info)
let charset = ''
let lines
if (info.hasUTF8) {
charset = ' CHARSET UTF-8'
lines = query.split(CRLF)
query = lines.shift()
}
if (typeof options === 'function') {
cb = options
options = ''
}
else if (!options)
options = ''
if (Array.isArray(options))
options = options.join(' ')
this._enqueue(which + 'SEARCH RETURN (' + options + ')' + charset + query, cb)
if (info.hasUTF8) {
const req = this._queue[this._queue.length - 1]
req.lines = lines
}
}
setQuota(quotaRoot, limits, cb) {
if (typeof limits === 'function') {
cb = limits
limits = {}
}
let triplets = ''
for (let l in limits) {
if (triplets)
triplets += ' '
triplets += l + ' ' + limits[l]
}
quotaRoot = escape(utf7.encode('' + quotaRoot))
this._enqueue('SETQUOTA "' + quotaRoot + '" (' + triplets + ')', function (err, quotalist) {
if (err)
return cb(err)
cb(err, quotalist ? quotalist[0] : limits)
})
}
getQuota(quotaRoot, cb) {
quotaRoot = escape(utf7.encode('' + quotaRoot))
this._enqueue('GETQUOTA "' + quotaRoot + '"', function (err, quotalist) {
if (err)
return cb(err)
cb(err, quotalist[0])
})
}
getQuotaRoot(boxName, cb) {
boxName = escape(utf7.encode('' + boxName))
this._enqueue('GETQUOTAROOT "' + boxName + '"', function (err, quotalist) {
if (err)
return cb(err)
const quotas = {}
if (quotalist) {
for (let i = 0, len = quotalist.length; i < len; ++i)
quotas[quotalist[i].root] = quotalist[i].resources
}
cb(err, quotas)
})
}
thread(algorithm, criteria, cb) {
this._thread('UID ', algorithm, criteria, cb)
}
_thread(which, algorithm, criteria, cb) {
algorithm = algorithm.toUpperCase()
if (!this.serverSupports('THREAD=' + algorithm))
throw new Error('Server does not support that threading algorithm')
const info = { hasUTF8: false /*output*/ }
let query = buildSearchQuery(criteria, this._caps, info)
let charset = 'US-ASCII'
let lines
if (info.hasUTF8) {
charset = 'UTF-8'
lines = query.split(CRLF)
query = lines.shift()
}
this._enqueue(which + 'THREAD ' + algorithm + ' ' + charset + query, cb)
if (info.hasUTF8) {
const req = this._queue[this._queue.length - 1]
req.lines = lines
}
}
addFlagsSince(uids, flags, modseq, cb) {
this._store('UID ', uids, { mode: '+', flags: flags, modseq: modseq }, cb)
}
delFlagsSince(uids, flags, modseq, cb) {
this._store('UID ', uids, { mode: '-', flags: flags, modseq: modseq }, cb)
}
setFlagsSince(uids, flags, modseq, cb) {
this._store('UID ', uids, { mode: '', flags: flags, modseq: modseq }, cb)
}
addKeywordsSince(uids, keywords, modseq, cb) {
this._store('UID ', uids, { mode: '+', keywords: keywords, modseq: modseq }, cb)
}
delKeywordsSince(uids, keywords, modseq, cb) {
this._store('UID ', uids, { mode: '-', keywords: keywords, modseq: modseq }, cb)
}
setKeywordsSince(uids, keywords, modseq, cb) {
this._store('UID ', uids, { mode: '', keywords: keywords, modseq: modseq }, cb)
}
_resUntagged(info) {
const type = info.type
let i, len, box, attrs, key
if (type === 'bye')
this._sock.end()
else if (type === 'namespace')
this.namespaces = info.text
else if (type === 'id')
this._curReq.cbargs.push(info.text)
else if (type === 'capability')
this._caps = info.text.map(function (v) { return v.toUpperCase() })
else if (type === 'preauth')
this.state = 'authenticated'
else if (type === 'sort' || type === 'thread' || type === 'esearch')
this._curReq.cbargs.push(info.text)
else if (type === 'search') {
if (info.text.results !== undefined) {
// CONDSTORE-modified search results
this._curReq.cbargs.push(info.text.results)
this._curReq.cbargs.push(info.text.modseq)
}
else
this._curReq.cbargs.push(info.text)
}
else if (type === 'quota') {
const cbargs = this._curReq.cbargs
if (!cbargs.length)
cbargs.push([])
cbargs[0].push(info.text)
}
else if (type === 'recent') {
if (!this._box && RE_OPENBOX.test(this._curReq.type))
this._createCurrentBox()
if (this._box)
this._box.messages.new = info.num
}
else if (type === 'flags') {
if (!this._box && RE_OPENBOX.test(this._curReq.type))
this._createCurrentBox()
if (this._box)
this._box.flags = info.text
}
else if (type === 'bad' || type === 'no') {
if (info.textCode
&& info.textCode.key
&& info.textCode.key === 'WEBALERT'
) {
this.emit('webalert', {
url: info.textCode.val,
message: info.text
})
}
if (this.state === 'connected' && !this._curReq) {
clearTimeout(this._tmrConn)
clearTimeout(this._tmrAuth)
const err = new Error('Received negative welcome: ' + info.text)
err.source = 'protocol'
this.emit('error', err)
this._sock.end()
}
}
else if (type === 'exists') {
if (!this._box && RE_OPENBOX.test(this._curReq.type))
this._createCurrentBox()
if (this._box) {
let prev = this._box.messages.total, now = info.num
this._box.messages.total = now
if (this._box.name === '')
prev = now
if (now > prev && this.state === 'authenticated') {
this._box.messages.new = now - prev
this.emit('mail', this._box.messages.new)
}
}
}
else if (type === 'expunge') {
if (this._box) {
if (this._box.messages.total > 0)
--this._box.messages.total
this.emit('expunge', info.num)
}
}
else if (type === 'ok') {
if (this.state === 'connected' && !this._curReq)
this._login()
else if (typeof info.textCode === 'string'
&& info.textCode.toUpperCase() === 'ALERT')
this.emit('alert', info.text)
else if (this._curReq
&& info.textCode
&& (RE_OPENBOX.test(this._curReq.type))) {
// we're opening a mailbox
if (!this._box)
this._createCurrentBox()
if (info.textCode.key)
key = info.textCode.key.toUpperCase()
else
key = info.textCode
if (key === 'UIDVALIDITY')
this._box.uidvalidity = info.textCode.val
else if (key === 'UIDNEXT')
this._box.uidnext = info.textCode.val
else if (key === 'HIGHESTMODSEQ')
this._box.highestmodseq = '' + info.textCode.val
else if (key === 'PERMANENTFLAGS') {
let idx, permFlags, keywords
this._box.permFlags = permFlags = info.textCode.val
if ((idx = this._box.permFlags.indexOf('\\*')) > -1) {
this._box.newKeywords = true
permFlags.splice(idx, 1)
}
this._box.keywords = keywords = permFlags.filter(function (f) {
return (f[0] !== '\\')
})
for (i = 0, len = keywords.length; i < len; ++i)
permFlags.splice(permFlags.indexOf(keywords[i]), 1)
}
else if (key === 'UIDNOTSTICKY')
this._box.persistentUIDs = false
else if (key === 'NOMODSEQ')
this._box.nomodseq = true
}
else if (typeof info.textCode === 'string'
&& info.textCode.toUpperCase() === 'UIDVALIDITY')
this.emit('uidvalidity', info.text)
}
else if (type === 'list' || type === 'lsub' || type === 'xlist') {
if (this.delimiter === undefined)
this.delimiter = info.text.delimiter
else {
if (this._curReq.cbargs.length === 0)
this._curReq.cbargs.push({})
box = {
attribs: info.text.flags,
delimiter: info.text.delimiter,
children: null,
parent: null
}
for (i = 0, len = SPECIAL_USE_ATTRIBUTES.length; i < len; ++i)
if (box.attribs.indexOf(SPECIAL_USE_ATTRIBUTES[i]) > -1)
box.special_use_attrib = SPECIAL_USE_ATTRIBUTES[i]
let name = info.text.name
let curChildren = this._curReq.cbargs[0]
if (box.delimiter) {
const path = name.split(box.delimiter)
let parent = null
name = path.pop()
for (i = 0, len = path.length; i < len; ++i) {
if (!curChildren[path[i]])
curChildren[path[i]] = {}
if (!curChildren[path[i]].children)
curChildren[path[i]].children = {}
parent = curChildren[path[i]]
curChildren = curChildren[path[i]].children
}
box.parent = parent
}
if (curChildren[name])
box.children = curChildren[name].children
curChildren[name] = box
}
}
else if (type === 'status') {
box = {
name: info.text.name,
uidnext: 0,
uidvalidity: 0,
messages: {
total: 0,
new: 0,
unseen: 0
}
}
attrs = info.text.attrs
if (attrs) {
if (attrs.recent !== undefined)
box.messages.new = attrs.recent
if (attrs.unseen !== undefined)
box.messages.unseen = attrs.unseen
if (attrs.messages !== undefined)
box.messages.total = attrs.messages
if (attrs.uidnext !== undefined)
box.uidnext = attrs.uidnext
if (attrs.uidvalidity !== undefined)
box.uidvalidity = attrs.uidvalidity
if (attrs.highestmodseq !== undefined) // CONDSTORE
box.highestmodseq = '' + attrs.highestmodseq
}
this._curReq.cbargs.push(box)
}
else if (type === 'fetch') {
if (/^(?:UID )?FETCH/.test(this._curReq.fullcmd)) {
// FETCH response sent as result of FETCH request
const msg = this._curReq.fetchCache[info.num], keys = Object.keys(info.text), keyslen = keys.length
let toget, msgEmitter, j
if (msg === undefined) {
// simple case -- no bodies were streamed
toget = this._curReq.fetching.slice(0)
if (toget.length === 0)
return
msgEmitter = new EventEmitter()
attrs = {}
this._curReq.bodyEmitter.emit('message', msgEmitter, info.num)
}
else {
toget = msg.toget
msgEmitter = msg.msgEmitter
attrs = msg.attrs
}
i = toget.length
if (i === 0) {
if (msg && !msg.ended) {
msg.ended = true
process.nextTick(function () {
msgEmitter.emit('end')
})
}
return
}
if (keyslen > 0) {
while (--i >= 0) {
j = keyslen
while (--j >= 0) {
if (keys[j].toUpperCase() === toget[i]) {
if (!RE_BODYPART.test(toget[i])) {
if (toget[i] === 'X-GM-LABELS') {
const labels = info.text[keys[j]]
for (let k = 0, lenk = labels.length; k < lenk; ++k)
labels[k] = utf7.decode(('' + labels[k]).replace(RE_ESCAPE, '\\'))
}
key = FETCH_ATTR_MAP[toget[i]]
if (!key)
key = toget[i].toLowerCase()
attrs[key] = info.text[keys[j]]
}
toget.splice(i, 1)
break
}
}
}
}
if (toget.length === 0) {
if (msg)
msg.ended = true
process.nextTick(function () {
msgEmitter.emit('attributes', attrs)
msgEmitter.emit('end')
})
}
else if (msg === undefined) {
this._curReq.fetchCache[info.num] = {
msgEmitter: msgEmitter,
toget: toget,
attrs: attrs,
ended: false
}
}
}
else {
// FETCH response sent as result of STORE request or sent unilaterally,
// treat them as the same for now for simplicity
this.emit('update', info.num, info.text)
}
}
}
_resTagged(info) {
const req = this._curReq
let err
if (!req)
return
this._curReq = undefined
if (info.type === 'no' || info.type === 'bad') {
let errtext
if (info.text)
errtext = info.text
else
errtext = req.oauthError
err = new Error(errtext)
err.type = info.type
err.textCode = info.textCode
err.source = 'protocol'
}
else if (this._box) {
if (req.type === 'EXAMINE' || req.type === 'SELECT') {
this._box.readOnly = (typeof info.textCode === 'string'
&& info.textCode.toUpperCase() === 'READ-ONLY')
}
// According to RFC 3501, UID commands do not give errors for
// non-existant user-supplied UIDs, so give the callback empty results
// if we unexpectedly received no untagged responses.
if (RE_UIDCMD_HASRESULTS.test(req.fullcmd) && req.cbargs.length === 0)
req.cbargs.push([])
}
if (req.bodyEmitter) {
const bodyEmitter = req.bodyEmitter
if (err) {
bodyEmitter.emit('error', err)
}
process.nextTick(function () {
bodyEmitter.emit('end')
})
}
else {
req.cbargs.unshift(err)
if (info.textCode && info.textCode.key) {
const key = info.textCode.key.toUpperCase()
if (key === 'APPENDUID') // [uidvalidity, newUID]
req.cbargs.push(info.textCode.val[1])
else if (key === 'COPYUID') // [uidvalidity, sourceUIDs, destUIDs]
req.cbargs.push(info.textCode.val[2])
}
req.cb && req.cb.apply(this, req.cbargs)
}
if (this._queue.length === 0
&& this._config.keepalive
&& this.state === 'authenticated'
&& !this._idle.enabled) {
this._idle.enabled = true
this._doKeepaliveTimer(true)
}
this._processQueue()
}
_createCurrentBox() {
this._box = {
name: '',
flags: [],
readOnly: false,
uidvalidity: 0,
uidnext: 0,
permFlags: [],
keywords: [],
newKeywords: false,
persistentUIDs: true,
nomodseq: false,
messages: {
total: 0,
new: 0
}
}
}
_doKeepaliveTimer(immediate) {
const self = this, interval = this._config.keepalive.interval || KEEPALIVE_INTERVAL, idleWait = this._config.keepalive.idleInterval || MAX_IDLE_WAIT, forceNoop = this._config.keepalive.forceNoop || false, timerfn = function () {
if (self._idle.enabled) {
// unlike NOOP, IDLE is only a valid command after authenticating
if (!self.serverSupports('IDLE')
|| self.state !== 'authenticated'
|| forceNoop)
self._enqueue('NOOP', true)
else {
if (self._idle.started === undefined) {
self._idle.started = 0
self._enqueue('IDLE', true)
}
else if (self._idle.started > 0) {
const timeDiff = Date.now() - self._idle.started
if (timeDiff >= idleWait) {
self._idle.enabled = false
self.debug && self.debug('=> DONE')
self._sock.write('DONE' + CRLF)
return
}
}
self._tmrKeepalive = setTimeout(timerfn, interval)
}
}
}
if (immediate)
timerfn()
else
this._tmrKeepalive = setTimeout(timerfn, interval)
}
_login() {
const self = this
let checkedNS = false
const reentry = function (err) {
clearTimeout(self._tmrAuth)
if (err) {
self.emit('error', err)
return self._sock.end()
}
// 2. Get the list of available namespaces (RFC2342)
if (!checkedNS && self.serverSupports('NAMESPACE')) {
checkedNS = true
return self._enqueue('NAMESPACE', reentry)
}
// 3. Get the top-level mailbox hierarchy delimiter used by the server
self._enqueue('LIST "" ""', function () {
self.state = 'authenticated'
self.emit('ready')
})
}
// 1. Get the supported capabilities
self._enqueue('CAPABILITY', function () {
// No need to attempt the login sequence if we're on a PREAUTH connection.
if (self.state === 'connected') {
let err
const checkCaps = function (error) {
if (error) {
error.source = 'authentication'
return reentry(error)
}
if (self._caps === undefined) {
// Fetch server capabilities if they were not automatically
// provided after authentication
return self._enqueue('CAPABILITY', reentry)
}
else
reentry()
}
if (self.serverSupports('STARTTLS')
&& (self._config.autotls === 'always'
|| (self._config.autotls === 'required'
&& self.serverSupports('LOGINDISABLED')))) {
self._starttls()
return
}
if (self.serverSupports('LOGINDISABLED')) {
err = new Error('Logging in is disabled on this server')
err.source = 'authentication'
return reentry(err)
}
let cmd
if (self.serverSupports('AUTH=XOAUTH') && self._config.xoauth) {
self._caps = undefined
cmd = 'AUTHENTICATE XOAUTH'
// are there any servers that support XOAUTH/XOAUTH2 and not SASL-IR?
//if (self.serverSupports('SASL-IR'))
cmd += ' ' + escape(self._config.xoauth)
self._enqueue(cmd, checkCaps)
}
else if (self.serverSupports('AUTH=XOAUTH2') && self._config.xoauth2) {
self._caps = undefined
cmd = 'AUTHENTICATE XOAUTH2'
//if (self.serverSupports('SASL-IR'))
cmd += ' ' + escape(self._config.xoauth2)
self._enqueue(cmd, checkCaps)
}
else if (self._config.user && self._config.password) {
if (self.serverSupports('AUTH=CRAM-MD5')) {
cmd = 'AUTHENTICATE CRAM-MD5'
} else {
cmd = 'LOGIN "' + escape(self._config.user) + '" "'
+ escape(self._config.password) + '"'
}
self._caps = undefined
self._enqueue(cmd, checkCaps)
}
else {
err = new Error('No supported authentication method(s) available. '
+ 'Unable to login.')
err.source = 'authentication'
return reentry(err)
}
}
else
reentry()
})
}
_authCRAMMD5(secret) {
let decodedSecret, hmac, response
decodedSecret = Buffer.from(secret, 'base64').toString('utf8')
hmac = crypto.createHmac('md5', this._config.password)
.update(decodedSecret)
.digest('hex')
response = new Buffer(this._config.user + ' ' + hmac).toString('base64')
this.debug && this.debug('=> ' + response)
this._sock.write(response + CRLF, 'utf8')
}
_starttls() {
const self = this
this._enqueue('STARTTLS', function (err) {
if (err) {
self.emit('error', err)
return self._sock.end()
}
self._isTsl = true
self._caps = undefined
self._sock.removeAllListeners('error')
const tlsOptions = {}
tlsOptions.host = this._config.host
// Host name may be overridden the tlsOptions
for (let k in this._config.tlsOptions)
tlsOptions[k] = this._config.tlsOptions[k]
tlsOptions.socket = self._sock
self._sock = tls.connect(tlsOptions, function () {
self._login()
})
self._sock.on('error', self._onError)
self._sock.on('timeout', self._onSocketTimeout)
self._sock.setTimeout(self._config.socketTimeout)
self._parser.setStream(self._sock)
})
}
_processQueue() {
if (this._curReq || !this._queue.length || !this._sock || !this._sock.writable)
return
this._curReq = this._queue.shift()
if (this._tagcount === MAX_INT)
this._tagcount = 0
let prefix
if (this._curReq.type === 'IDLE' || this._curReq.type === 'NOOP')
prefix = this._curReq.type
else
prefix = 'A' + (this._tagcount++)
const out = prefix + ' ' + this._curReq.fullcmd
this.debug && this.debug('=> ' + inspect(out))
this._sock.write(out + CRLF, 'utf8')
if (this._curReq.literalAppendData) {
// LITERAL+: we are appending a mesage, and not waiting for a reply
this._sockWriteAppendData(this._curReq.literalAppendData)
}
}
_sockWriteAppendData(appendData) {
let val = appendData
if (Buffer.isBuffer(appendData))
val = val.toString('utf8')
this.debug && this.debug('=> ' + inspect(val))
this._sock.write(val)
this._sock.write(CRLF)
}
_enqueue(fullcmd, promote, cb) {
if (typeof promote === 'function') {
cb = promote
promote = false
}
const info = {
type: fullcmd.match(RE_CMD)[1],
fullcmd: fullcmd,
cb: cb,
cbargs: []
}, self = this
if (promote)
this._queue.unshift(info)