You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1628 lines
48 KiB
JavaScript

var tls = require('tls'),
Socket = require('net').Socket,
EventEmitter = require('events').EventEmitter,
inherits = require('util').inherits,
inspect = require('util').inspect,
isDate = require('util').isDate,
utf7 = require('utf7').imap;
var Parser = require('./Parser').Parser,
parseHeader = require('./Parser').parseHeader;
var MAX_INT = 9007199254740992,
KEEPALIVE_INTERVAL = 10000,
MAX_IDLE_WAIT = 300000, // 5 minutes
MONTHS = ['Jan', 'Feb', 'Mar',
'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep',
'Oct', 'Nov', 'Dec'],
FETCH_ATTR_MAP = {
'RFC822.SIZE': 'size',
'BODY': 'struct',
'BODYSTRUCTURE': 'struct',
'UID': 'uid',
'INTERNALDATE': 'date',
'FLAGS': 'flags',
'X-GM-THRID': 'x-gm-thrid',
'X-GM-MSGID': 'x-gm-msgid',
'X-GM-LABELS': 'x-gm-labels'
},
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_NUM_RANGE = /^(?:[\d]+|\*):(?:[\d]+|\*)$/,
RE_BACKSLASH = /\\/g,
RE_DBLQUOTE = /"/g,
RE_ESCAPE = /\\\\/g,
RE_INTEGER = /^\d+$/;
function Connection(config) {
if (!(this instanceof Connection))
return new Connection(config);
EventEmitter.call(this);
config || (config = {});
this._config = {
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,
keepalive: (typeof config.keepalive === 'boolean'
? config.keepalive
: true)
};
this._sock = undefined;
this._tagcount = 0;
this._tmrConn = undefined;
this._queue = [];
this._box = undefined;
this._idle = {};
this._parser = undefined;
this._curReq = undefined;
this.delimiter = undefined;
this.namespaces = undefined;
this.state = 'disconnected';
this.debug = config.debug;
}
inherits(Connection, EventEmitter);
Connection.prototype.connect = function() {
var config = this._config, self = this, socket, parser, tlsOptions;
socket = new Socket();
socket.setKeepAlive(true);
socket.setTimeout(0);
this.state = 'disconnected';
if (config.tls) {
tlsOptions = {};
for (var k in config.tlsOptions)
tlsOptions[k] = config.tlsOptions[k];
tlsOptions.socket = socket;
}
if (config.tls)
this._sock = tls.connect(tlsOptions, onconnect);
else {
socket.once('connect', onconnect);
this._sock = socket;
}
function onconnect() {
clearTimeout(self._tmrConn);
self.state = 'connected';
self.debug && self.debug('[connection] Connected to host');
}
this._onError = function(err) {
clearTimeout(self._tmrConn);
clearTimeout(self._tmrKeepalive);
self.debug && self.debug('[connection] Error: ' + err);
err.source = 'socket';
self.emit('error', err);
};
this._sock.once('error', this._onError);
socket.once('close', function(had_err) {
clearTimeout(self._tmrConn);
clearTimeout(self._tmrKeepalive);
self.debug && self.debug('[connection] Closed');
self.emit('close', had_err);
});
socket.once('end', function() {
clearTimeout(self._tmrConn);
clearTimeout(self._tmrKeepalive);
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) {
var msg = self._curReq.fetchCache[info.seqno], 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;
var idx = toget.indexOf('BODY[' + info.which + ']');
if (idx > -1) {
toget.splice(idx, 1);
msg.msgEmitter.emit('body', stream, info);
} else
stream.resume(); // a body we didn't ask for?
});
parser.on('continue', function(info) {
// only needed for IDLE and APPEND
var type = self._curReq.type;
if (type === 'IDLE') {
// now idling
self._idle.started = Date.now();
} else if (/^AUTHENTICATE XOAUTH/.test(self._curReq.fullcmd)) {
self._curReq.oauthError = new Buffer(info.text, 'base64').toString('utf8');
self.debug && self.debug('=> ' + inspect(CRLF));
self._sock.write(CRLF);
} else if (type === 'APPEND') {
var val = self._curReq.appendData;
if (Buffer.isBuffer(self._curReq.appendData))
val = val.toString('utf8');
self.debug && self.debug('=> ' + inspect(self._curReq.appendData));
self._sock.write(self._curReq.appendData);
self._sock.write(CRLF);
} else if (self._curReq.lines && self._curReq.lines.length) {
var line = self._curReq.lines.shift() + '\r\n';
self.debug && self.debug('=> ' + inspect(line));
self._sock.write(line, 'binary');
}
});
parser.on('other', function(line) {
var m;
if (m = RE_IDLENOOPRES.exec(line)) {
if (m[1] === 'IDLE') {
// no longer idling
self._idle.enabled = false;
self._idle.started = undefined;
}
self._curReq = undefined;
if (self._queue.length === 0
&& self._config.keepalive
&& self.state === 'authenticated') {
self._idle.enabled = true;
self._doKeepaliveTimer(true);
}
self._processQueue();
}
});
this._tmrConn = setTimeout(function() {
var err = new Error('Connection timed out');
err.source = 'timeout';
self.emit('error', err);
socket.destroy();
}, config.connTimeout);
socket.connect(config.port, config.host);
};
Connection.prototype.serverSupports = function(cap) {
return (this._caps && this._caps.indexOf(cap) > -1);
};
Connection.prototype.end = function() {
this._sock.end();
};
Connection.prototype.append = function(data, options, cb) {
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;
}
var cmd = 'APPEND "' + escape(utf7.encode(''+options.mailbox)) + '"';
if (options.flags) {
if (!Array.isArray(options.flags))
options.flags = [options.flags];
if (options.flags.length > 0)
cmd += ' (\\' + options.flags.join(' \\') + ')';
}
if (options.date) {
if (!isDate(options.date))
throw new Error('`date` is not a Date object');
cmd += ' "';
cmd += options.date.getDate();
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 += '}';
this._enqueue(cmd, cb);
this._queue[this._queue.length - 1].appendData = data;
};
Connection.prototype.getBoxes = function(namespace, cb) {
if (typeof namespace === 'function') {
cb = namespace;
namespace = '';
}
namespace = escape(utf7.encode(''+namespace));
this._enqueue('LIST "' + namespace + '" "*"', cb);
};
Connection.prototype.openBox = function(name, readOnly, cb) {
if (this.state !== 'authenticated')
throw new Error('Not authenticated');
if (cb === undefined) {
cb = readOnly;
readOnly = false;
}
name = ''+name;
var encname = escape(utf7.encode(name)),
cmd = (readOnly ? 'EXAMINE' : 'SELECT'),
self = this;
this._enqueue(cmd + ' "' + encname + '"', function(err) {
if (err) {
self._box = undefined;
cb(err);
} else {
self._box.name = name;
cb(err, self._box);
}
});
};
Connection.prototype.closeBox = function(shouldExpunge, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected');
var 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
var badbox = 'NODEJSIMAPCLOSINGBOX' + Date.now();
this._enqueue('SELECT "' + badbox + '"', function(err) {
self._box = undefined;
cb();
});
}
}
};
Connection.prototype.addBox = function(name, cb) {
this._enqueue('CREATE "' + escape(utf7.encode(''+name)) + '"', cb);
};
Connection.prototype.delBox = function(name, cb) {
this._enqueue('DELETE "' + escape(utf7.encode(''+name)) + '"', cb);
};
Connection.prototype.renameBox = function(oldname, newname, cb) {
var destname = newname;
if (this._box
&& oldname === this._box.name
&& oldname.toUpperCase() !== 'INBOX')
destname = ''+oldname;
var encoldname = escape(utf7.encode(''+oldname)),
encnewname = escape(utf7.encode(''+newname)),
self = this;
this._enqueue('RENAME "' + encoldname + '" "' + encnewname + '"',
function(err) {
if (err)
return cb(err);
self._box.name = destname;
cb(err, self._box);
}
);
};
Connection.prototype.status = function(boxName, cb) {
if (this._box && this._box.name === boxName)
throw new Error('Cannot call status on currently selected mailbox');
boxName = escape(utf7.encode(''+boxName));
this._enqueue('STATUS "' + boxName + '" (MESSAGES RECENT UNSEEN UIDVALIDITY)',
cb);
};
Connection.prototype.expunge = function(uids, cb) {
if (typeof uids === 'function') {
cb = uids;
uids = undefined;
}
if (uids !== undefined) {
if (!Array.isArray(uids))
uids = [uids];
validateUIDList(uids);
uids = uids.join(',');
this._enqueue('UID EXPUNGE ' + uids, cb);
} else
this._enqueue('EXPUNGE', cb);
};
Connection.prototype.search = function(criteria, cb) {
this._search('UID ', criteria, cb);
};
Connection.prototype._search = function(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');
var cmd = which + 'SEARCH',
info = { hasUTF8: false /*output*/ },
query = buildSearchQuery(criteria, this._caps, info),
lines;
if (info.hasUTF8) {
cmd += ' CHARSET UTF-8';
lines = query.split(CRLF);
query = lines.shift();
}
cmd += query;
this._enqueue(cmd, cb);
if (info.hasUTF8) {
var req = this._queue[this._queue.length - 1];
req.lines = lines;
}
};
Connection.prototype.addFlags = function(uids, flags, cb) {
this._store('UID ', uids, { mode: '+', flags: flags }, cb);
};
Connection.prototype.delFlags = function(uids, flags, cb) {
this._store('UID ', uids, { mode: '-', flags: flags }, cb);
};
Connection.prototype.setFlags = function(uids, flags, cb) {
this._store('UID ', uids, { mode: '', flags: flags }, cb);
};
Connection.prototype.addKeywords = function(uids, keywords, cb) {
this._store('UID ', uids, { mode: '+', keywords: keywords }, cb);
};
Connection.prototype.delKeywords = function(uids, keywords, cb) {
this._store('UID ', uids, { mode: '-', keywords: keywords }, cb);
};
Connection.prototype.setKeywords = function(uids, keywords, cb) {
this._store('UID ', uids, { mode: '', keywords: keywords }, cb);
};
Connection.prototype._store = function(which, uids, cfg, cb) {
var mode = cfg.mode,
isFlags = (cfg.flags !== undefined),
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 ((!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 (var 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(',');
var modifiers = '';
this._enqueue(which + 'STORE ' + uids + ' '
+ modifiers
+ mode + 'FLAGS.SILENT (' + items + ')', cb);
};
Connection.prototype.copy = function(uids, boxTo, cb) {
this._copy('UID ', uids, boxTo, cb);
};
Connection.prototype._copy = function(which, uids, boxTo, cb) {
if (this._box === undefined)
throw new Error('No mailbox is currently selected');
if (!Array.isArray(uids))
uids = [uids];
validateUIDList(uids);
boxTo = escape(utf7.encode(''+boxTo));
this._enqueue(which + 'COPY ' + uids.join(',') + ' "'
+ boxTo + '"', cb);
};
Connection.prototype.move = function(uids, boxTo, cb) {
this._move('UID ', uids, boxTo, cb);
};
Connection.prototype._move = function(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);
uids = uids.join(',');
boxTo = escape(utf7.encode(''+boxTo));
this._enqueue(which + 'MOVE ' + uids + ' "' + boxTo + '"', cb);
} else if (this._box.permFlags.indexOf('\\Deleted') === -1) {
throw new Error('Cannot move message: '
+ 'server does not allow deletion of messages');
} else {
var deletedUIDs, task = 0, 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) {
var 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, cb);
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);
}
});
}
};
Connection.prototype.fetch = function(uids, options) {
return this._fetch('UID ', uids, options);
};
Connection.prototype._fetch = function(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);
uids = uids.join(',');
var cmd = which + 'FETCH ' + uids + ' (', fetching = [];
// always fetch GMail-specific bits of information when on GMail
if (this.serverSupports('X-GM-EXT-1')) {
fetching.push('X-GM-THRID');
fetching.push('X-GM-MSGID');
fetching.push('X-GM-LABELS');
}
fetching.push('UID');
fetching.push('FLAGS');
fetching.push('INTERNALDATE');
if (options) {
if (options.struct)
fetching.push('BODYSTRUCTURE');
if (options.size)
fetching.push('RFC822.SIZE');
cmd += fetching.join(' ');
if (options.bodies !== undefined) {
var bodies = options.bodies,
prefix = (options.markSeen ? '' : '.PEEK');
if (!Array.isArray(bodies))
bodies = [bodies];
for (var i = 0, len = bodies.length; i < len; ++i) {
fetching.push('BODY[' + bodies[i] + ']');
cmd += ' BODY' + prefix + '[' + bodies[i] + ']';
}
}
} else
cmd += fetching.join(' ');
cmd += ')';
this._enqueue(cmd);
var req = this._queue[this._queue.length - 1];
req.fetchCache = {};
req.fetching = fetching;
return (req.bodyEmitter = new EventEmitter());
};
// Extension methods ===========================================================
Connection.prototype.setLabels = function(uids, labels, cb) {
this._storeLabels('UID ', uids, labels, '', cb);
};
Connection.prototype.addLabels = function(uids, labels, cb) {
this._storeLabels('UID ', uids, labels, '+', cb);
};
Connection.prototype.delLabels = function(uids, labels, cb) {
this._storeLabels('UID ', uids, labels, '-', cb);
};
Connection.prototype._storeLabels = function(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 ((!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);
};
Connection.prototype.sort = function(sorts, criteria, cb) {
this._sort('UID ', sorts, criteria, cb);
};
Connection.prototype._sort = function(which, sorts, criteria, cb) {
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');
sorts = sorts.map(function(c) {
if (typeof c !== 'string')
throw new Error('Unexpected sort criteria data type. '
+ 'Expected string. Got: ' + typeof criteria);
var 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;
default:
throw new Error('Unexpected sort criteria: ' + c);
}
return modifier + c;
});
sorts = sorts.join(' ');
var info = { hasUTF8: false /*output*/ },
query = buildSearchQuery(criteria, this._caps, info),
charset = 'US-ASCII',
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) {
var req = this._queue[this._queue.length - 1];
req.lines = lines;
}
};
Connection.prototype.esearch = function(criteria, options, cb) {
this._search('UID ', criteria, options, cb);
};
Connection.prototype._esearch = function(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');
var info = { hasUTF8: false /*output*/ },
query = buildSearchQuery(criteria, this._caps, info),
charset = '',
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) {
var req = this._queue[this._queue.length - 1];
req.lines = lines;
}
};
Connection.prototype.setQuota = function(quotaRoot, limits, cb) {
if (typeof limits === 'function') {
cb = limits;
limits = {};
}
var triplets = '';
for (var 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[0]);
}
);
};
Connection.prototype.getQuota = function(quotaRoot, cb) {
quotaRoot = escape(utf7.encode(''+quotaRoot));
this._enqueue('GETQUOTA "' + quotaRoot + '"', function(err, quotalist) {
if (err)
return cb(err);
cb(err, quotalist[0]);
});
};
Connection.prototype.getQuotaRoot = function(boxName, cb) {
boxName = escape(utf7.encode(''+boxName));
this._enqueue('GETQUOTAROOT "' + boxName + '"', function(err, quotalist) {
if (err)
return cb(err);
var quotas = {};
for (var i = 0, len = quotalist.length; i < len; ++i)
quotas[quotalist[i].root] = quotalist[i].resources;
cb(err, quotas);
});
};
Connection.prototype.thread = function(algorithm, criteria, cb) {
this._thread('UID ', algorithm, criteria, cb);
};
Connection.prototype._thread = function(which, algorithm, criteria, cb) {
algorithm = algorithm.toUpperCase();
if (!this.serverSupports('THREAD=' + algorithm))
throw new Error('Server does not support that threading algorithm');
var info = { hasUTF8: false /*output*/ },
query = buildSearchQuery(criteria, this._caps, info),
charset = 'US-ASCII',
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) {
var req = this._queue[this._queue.length - 1];
req.lines = lines;
}
};
// END Extension methods =======================================================
// Namespace for seqno-based commands
Connection.prototype.__defineGetter__('seq', function() {
var self = this;
return {
delKeywords: function(seqnos, keywords, cb) {
self._store('', seqnos, { mode: '-', keywords: keywords }, cb);
},
addKeywords: function(seqnos, keywords, cb) {
self._store('', seqnos, { mode: '+', keywords: keywords }, cb);
},
setKeywords: function(seqnos, keywords, cb) {
self._store('', seqnos, { mode: '', keywords: keywords }, cb);
},
delFlags: function(seqnos, flags, cb) {
self._store('', seqnos, { mode: '-', flags: flags }, cb);
},
addFlags: function(seqnos, flags, cb) {
self._store('', seqnos, { mode: '+', flags: flags }, cb);
},
setFlags: function(seqnos, flags, cb) {
self._store('', seqnos, { mode: '', flags: flags }, cb);
},
move: function(seqnos, boxTo, cb) {
self._move('', seqnos, boxTo, cb);
},
copy: function(seqnos, boxTo, cb) {
self._copy('', seqnos, boxTo, cb);
},
fetch: function(seqnos, options, what, cb) {
return self._fetch('', seqnos, options, what, cb);
},
search: function(options, cb) {
self._search('', options, cb);
},
delLabels: function(seqnos, labels, cb) {
self._storeLabels('', seqnos, labels, '-', cb);
},
addLabels: function(seqnos, labels, cb) {
self._storeLabels('', seqnos, labels, '+', cb);
},
setLabels: function(seqnos, labels, cb) {
self._storeLabels('', seqnos, labels, '', cb);
},
fetch: function(seqnos, options, what, cb) {
return self._fetch('', seqnos, options, what, cb);
},
search: function(options, cb) {
self._search('', options, cb);
},
esearch: function(criteria, options, cb) {
self._esearch('', criteria, options, cb);
},
sort: function(sorts, options, cb) {
self._sort('', sorts, options, cb);
},
thread: function(algorithm, criteria, cb) {
self._thread('', algorithm, criteria, cb);
}
};
});
Connection.prototype._resUntagged = function(info) {
var type = info.type, i, len, box, attrs;
if (type === 'bye')
this._sock.end();
else if (type === 'namespace')
this.namespaces = 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 === 'search' || type === 'sort' || type === 'thread')
this._curReq.cbargs.push(info.text);
else if (type === 'quota') {
var 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();
this._box.messages.new = info.num;
} else if (type === 'flags') {
if (!this._box && RE_OPENBOX.test(this._curReq.type))
this._createCurrentBox();
this._box.flags = info.text;
} else if (type === 'bad' || type === 'no') {
if (this.state === 'connected' && !this._curReq) {
clearTimeout(this._tmrConn);
var 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();
var prev = this._box.messages.total,
now = info.num;
this._box.messages.total = now;
if (now > prev && this.state === 'authenticated') {
this._box.messages.new = now - prev;
this.emit('mail', this._box.messages.total);
}
} else if (type === 'expunge') {
if (this._box.messages.total > 0)
--this._box.messages.total;
if (!this._curReq)
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
&& typeof info.textCode === 'object'
&& (RE_OPENBOX.test(this._curReq.type))) {
// we're opening a mailbox
if (!this._box)
this._createCurrentBox();
var key = info.textCode.key.toUpperCase();
if (key === 'UIDVALIDITY')
this._box.uidvalidity = info.textCode.val;
else if (key === 'UIDNEXT')
this._box.uidnext = info.textCode.val;
else if (key === 'PERMANENTFLAGS') {
var 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 (typeof info.textCode === 'string'
&& info.textCode.toUpperCase() === 'UIDVALIDITY')
this.emit('uidvalidity', info.text);
} else if (type === 'list') {
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
};
var name = info.text.name,
curChildren = this._curReq.cbargs[0];
if (box.delimiter) {
var path = name.split(box.delimiter),
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,
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.uidvalidity !== undefined)
box.uidvalidity = attrs.uidvalidity;
}
this._curReq.cbargs.push(box);
} else if (type === 'fetch') {
if (/^(?:UID )?FETCH/.test(this._curReq.fullcmd)) {
// FETCH response sent as result of FETCH request
var msg = this._curReq.fetchCache[info.num],
keys = Object.keys(info.text),
keyslen = keys.length,
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') {
var labels = info.text[keys[j]];
for (var k = 0, lenk = labels.length; k < lenk; ++k)
labels[k] = (''+labels[k]).replace(RE_ESCAPE, '\\');
}
attrs[FETCH_ATTR_MAP[toget[i]]] = 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);
}
}
};
Connection.prototype._resTagged = function(info) {
var req = this._curReq, err;
this._curReq = undefined;
if (info.type === 'no' || info.type === 'bad') {
var errtext;
if (req.oauthError)
errtext = req.oauthError;
else
errtext = info.text;
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) {
var bodyEmitter = req.bodyEmitter;
if (err)
bodyEmitter.emit('error', err);
process.nextTick(function() {
bodyEmitter.emit('end');
});
} else {
req.cbargs.unshift(err);
req.cb && req.cb.apply(this, req.cbargs);
}
if (this._queue.length === 0
&& this._config.keepalive
&& this.state === 'authenticated') {
this._idle.enabled = true;
this._doKeepaliveTimer(true);
}
this._processQueue();
};
Connection.prototype._createCurrentBox = function() {
this._box = {
name: '',
flags: [],
readOnly: false,
uidvalidity: 0,
uidnext: 0,
permFlags: [],
keywords: [],
newKeywords: false,
messages: {
total: 0,
new: 0
}
};
};
Connection.prototype._doKeepaliveTimer = function(immediate) {
var self = this,
timerfn = function() {
if (self._idle.enabled) {
// unlike NOOP, IDLE is only a valid command after authenticating
if (!self.serverSupports('IDLE') || self.state !== 'authenticated')
self._enqueue('NOOP', true);
else {
if (self._idle.started === undefined) {
self._idle.started = 0;
self._enqueue('IDLE', true);
} else if (self._idle.started > 0) {
var timeDiff = Date.now() - self._idle.started;
if (timeDiff >= MAX_IDLE_WAIT) {
self._idle.enabled = false;
self.debug && self.debug('=> DONE');
self._sock.write('DONE' + CRLF);
return;
}
}
self._doKeepaliveTimer();
}
}
};
if (immediate)
timerfn();
else
this._tmrKeepalive = setTimeout(timerfn, KEEPALIVE_INTERVAL);
};
Connection.prototype._login = function() {
var self = this, checkedNS = false;
var reentry = function(err) {
if (err) {
self.emit('error', err);
return self._sock.destroy();
}
// 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') {
var err,
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();
if (self.serverSupports('LOGINDISABLED')) {
err = new Error('Logging in is disabled on this server');
err.source = 'authentication';
return reentry(err);
}
var 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) {
self._caps = undefined;
self._enqueue('LOGIN "' + escape(self._config.user) + '" "'
+ escape(self._config.password) + '"', checkCaps);
} else {
err = new Error('No supported authentication method(s) available. '
+ 'Unable to login.');
err.source = 'authentication';
return reentry(err);
}
} else
reentry();
});
};
Connection.prototype._starttls = function() {
var self = this;
this._enqueue('STARTTLS', function(err) {
if (err) {
self.emit('error', err);
return self._sock.end();
}
self._caps = undefined;
self._sock.removeAllListeners('error');
var tlsOptions = {};
for (var 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._parser.setStream(self._sock);
});
};
Connection.prototype._processQueue = function() {
if (this._curReq || !this._queue.length || !this._sock.writable)
return;
this._curReq = this._queue.shift();
if (this._tagcount === MAX_INT)
this._tagcount = 0;
var prefix;
if (this._curReq.type === 'IDLE' || this._curReq.type === 'NOOP')
prefix = this._curReq.type;
else
prefix = 'A' + (this._tagcount++);
var out = prefix + ' ' + this._curReq.fullcmd;
this.debug && this.debug('=> ' + inspect(out));
this._sock.write(out + CRLF, 'utf8');
};
Connection.prototype._enqueue = function(fullcmd, promote, cb) {
if (typeof promote === 'function') {
cb = promote;
promote = false;
}
var info = {
type: fullcmd.match(RE_CMD)[1],
fullcmd: fullcmd,
cb: cb,
cbargs: []
}, self = this;
if (promote)
this._queue.unshift(info);
else
this._queue.push(info);
if (!this._curReq
&& this.state !== 'disconnected'
&& this.state !== 'upgrading') {
// defer until next tick for requests like APPEND and FETCH where access to
// the request object is needed immediately after enqueueing
process.nextTick(function() { self._processQueue(); });
} else if (this._curReq.type === 'IDLE') {
this._idle.enabled = false;
this.debug && this.debug('=> DONE');
this._sock.write('DONE' + CRLF);
}
};
Connection.parseHeader = parseHeader; // from Parser.js
module.exports = Connection;
// utilities -------------------------------------------------------------------
function escape(str) {
return str.replace(RE_BACKSLASH, '\\\\').replace(RE_DBLQUOTE, '\\"');
}
function validateUIDList(uids, noThrow) {
for (var i = 0, len = uids.length, intval; i < len; ++i) {
if (typeof uids[i] === 'string') {
if (uids[i] === '*' || uids[i] === '*:*') {
if (len > 1)
uids = ['*'];
break;
} else if (RE_NUM_RANGE.test(uids[i]))
continue;
}
intval = parseInt(''+uids[i], 10);
if (isNaN(intval)) {
var err = new Error('UID/seqno must be an integer, "*", or a range: '
+ uids[i]);
if (noThrow)
return err;
else
throw err;
} else if (typeof uids[i] !== 'number')
uids[i] = intval;
}
}
function hasNonASCII(str) {
var ret = false;
for (var i = 0, len = str.length; i < len; ++i) {
if (str.charCodeAt(i) > 0x7F) {
ret = true;
break;
}
}
return ret;
}
function buildString(str) {
if (typeof str !== 'string')
str = ''+str;
if (hasNonASCII(str)) {
var buf = new Buffer(str, 'utf8');
return '{' + buf.length + '}\r\n' + buf.toString('binary');
} else
return '"' + escape(str) + '"';
}
function buildSearchQuery(options, extensions, info, isOrChild) {
var searchargs = '', err, val;
for (var i = 0, len = options.length; i < len; ++i) {
var criteria = (isOrChild ? options : options[i]),
args = null,
modifier = (isOrChild ? '' : ' ');
if (typeof criteria === 'string')
criteria = criteria.toUpperCase();
else if (Array.isArray(criteria)) {
if (criteria.length > 1)
args = criteria.slice(1);
if (criteria.length > 0)
criteria = criteria[0].toUpperCase();
} else
throw new Error('Unexpected search option data type. '
+ 'Expected string or array. Got: ' + typeof criteria);
if (criteria === 'OR') {
if (args.length !== 2)
throw new Error('OR must have exactly two arguments');
searchargs += ' OR (';
searchargs += buildSearchQuery(args[0], extensions, info, true);
searchargs += ') (';
searchargs += buildSearchQuery(args[1], extensions, info, true);
searchargs += ')';
} else {
if (criteria[0] === '!') {
modifier += 'NOT ';
criteria = criteria.substr(1);
}
switch(criteria) {
// -- Standard criteria --
case 'ALL':
case 'ANSWERED':
case 'DELETED':
case 'DRAFT':
case 'FLAGGED':
case 'NEW':
case 'SEEN':
case 'RECENT':
case 'OLD':
case 'UNANSWERED':
case 'UNDELETED':
case 'UNDRAFT':
case 'UNFLAGGED':
case 'UNSEEN':
searchargs += modifier + criteria;
break;
case 'BCC':
case 'BODY':
case 'CC':
case 'FROM':
case 'SUBJECT':
case 'TEXT':
case 'TO':
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
val = buildString(args[0]);
if (info && val[0] === '{')
info.hasUTF8 = true;
searchargs += modifier + criteria + ' ' + val;
break;
case 'BEFORE':
case 'ON':
case 'SENTBEFORE':
case 'SENTON':
case 'SENTSINCE':
case 'SINCE':
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
else if (!(args[0] instanceof Date)) {
if ((args[0] = new Date(args[0])).toString() === 'Invalid Date')
throw new Error('Search option argument must be a Date object'
+ ' or a parseable date string');
}
searchargs += modifier + criteria + ' ' + args[0].getDate() + '-'
+ MONTHS[args[0].getMonth()] + '-'
+ args[0].getFullYear();
break;
case 'KEYWORD':
case 'UNKEYWORD':
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
searchargs += modifier + criteria + ' ' + args[0];
break;
case 'LARGER':
case 'SMALLER':
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
var num = parseInt(args[0], 10);
if (isNaN(num))
throw new Error('Search option argument must be a number');
searchargs += modifier + criteria + ' ' + args[0];
break;
case 'HEADER':
if (!args || args.length !== 2)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
val = buildString(args[1]);
if (info && val[0] === '{')
info.hasUTF8 = true;
searchargs += modifier + criteria + ' "' + escape(''+args[0])
+ '" ' + val;
break;
case 'UID':
if (!args)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
validateUIDList(args);
searchargs += modifier + criteria + ' ' + args.join(',');
break;
// -- Extensions criteria --
case 'X-GM-MSGID': // Gmail unique message ID
case 'X-GM-THRID': // Gmail thread ID
if (extensions.indexOf('X-GM-EXT-1') === -1)
throw new Error('IMAP extension not available: ' + criteria);
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
else {
val = ''+args[0];
if (!(RE_INTEGER.test(args[0])))
throw new Error('Invalid value');
}
searchargs += modifier + criteria + ' ' + val;
break;
case 'X-GM-RAW': // Gmail search syntax
if (extensions.indexOf('X-GM-EXT-1') === -1)
throw new Error('IMAP extension not available: ' + criteria);
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
val = buildString(args[0]);
if (info && val[0] === '{')
info.hasUTF8 = true;
searchargs += modifier + criteria + ' ' + val;
break;
case 'X-GM-LABELS': // Gmail labels
if (extensions.indexOf('X-GM-EXT-1') === -1)
throw new Error('IMAP extension not available: ' + criteria);
if (!args || args.length !== 1)
throw new Error('Incorrect number of arguments for search option: '
+ criteria);
searchargs += modifier + criteria + ' ' + args[0];
break;
default:
// last hope it's a seqno set
// http://tools.ietf.org/html/rfc3501#section-6.4.4
var seqnos = (args ? [criteria].concat(args) : [criteria]);
if (!validateUIDList(seqnos, true))
searchargs += modifier + seqnos.join(',');
else
throw new Error('Unexpected search option: ' + criteria);
}
}
if (isOrChild)
break;
}
return searchargs;
}