diff --git a/README.md b/README.md index ea0d795..1bae6d2 100644 --- a/README.md +++ b/README.md @@ -558,6 +558,7 @@ Connection Instance Methods * **markSeen** - < _boolean_ > - Mark message(s) as read when fetched. **Default:** false * **struct** - < _boolean_ > - Fetch the message structure. **Default:** false * **size** - < _boolean_ > - Fetch the RFC822 size. **Default:** false + * **modifiers** - < _object_ > - Fetch modifiers defined by IMAP extensions. **Default:** (none) * **bodies** - < _mixed_ > - A string or Array of strings containing the body part section to fetch. **Default:** (none) Example sections: * 'HEADER' - The message header @@ -596,12 +597,12 @@ Extensions Supported * Server capability: X-GM-EXT-1 - * search() criteria extensions + * search() criteria extensions: - * X-GM-RAW: string value which allows you to use Gmail's web interface search syntax, such as: "has:attachment in:unread" - * X-GM-THRID: allows you to search for a specific conversation/thread id which is associated with groups of messages - * X-GM-MSGID: allows you to search for a specific message given its account-wide unique id - * X-GM-LABELS: string value which allows you to search for specific messages that have the given label applied + * **X-GM-RAW** - < _string_ > - Gmail's custom search syntax. Example: 'has:attachment in:unread' + * **X-GM-THRID** - < _string_ > - Conversation/thread id + * **X-GM-MSGID** - < _string_ > - Account-wide unique id + * **X-GM-LABELS** - < _string_ > - Gmail label * fetch() will automatically retrieve the thread id, unique message id, and labels (named 'x-gm-thrid', 'x-gm-msgid', 'x-gm-labels' respectively) @@ -680,6 +681,42 @@ Extensions Supported } ``` +* **RFC4551** + + * Server capability: CONDSTORE + + * Connection event 'update' info may contain the additional property: + + * **modseq** - < _string_ > - The new modification sequence value for the message. + + * search() criteria extensions: + + * **MODSEQ** - < _string_ > - Modification sequence value. If this criteria is used, the callback parameters are then: < _Error_ >err, < _array_ >UIDs, < _string_ >modseq. The `modseq` callback parameter is the highest modification sequence value of all the messages identified in the search results. + + * fetch() will automatically retrieve the modification sequence value (named 'modseq') for each message. + + * fetch() modifier: + + * **CHANGEDSINCE** - < _string_ > - Only fetch messages that have changed since the specified modification sequence. + + * The _Box_ type can now have the following property when using openBox() or status(): + + * **highestmodseq** - < _string_ > - The highest modification sequence value of all messages in the mailbox. + + * Additional Connection instance methods (seqno-based counterparts exist): + + * **addFlagsSince**(< _mixed_ >source, < _mixed_ >flags, < _string_ >modseq, < _function_ >callback) - _(void)_ - Adds flag(s) to message(s) that have not changed since `modseq`. `source` can be a message UID, a message UID range (e.g. '2504:2507' or '\*' or '2504:\*'), or an _array_ of message UIDs and/or message UID ranges. `flags` is either a single flag or an _array_ of flags. `callback` has 1 parameter: < _Error_ >err. + + * **delFlagsSince**(< _mixed_ >source, < _mixed_ >flags, < _function_ >callback) - _(void)_ - Removes flag(s) from message(s) that have not changed since `modseq`. `source` can be a message UID, a message UID range (e.g. '2504:2507' or '\*' or '2504:\*'), or an _array_ of message UIDs and/or message UID ranges. `flags` is either a single flag or an _array_ of flags. `callback` has 1 parameter: < _Error_ >err. + + * **setFlagsSince**(< _mixed_ >source, < _mixed_ >flags, < _function_ >callback) - _(void)_ - Sets the flag(s) for message(s) that have not changed since `modseq`. `source` can be a message UID, a message UID range (e.g. '2504:2507' or '\*' or '2504:\*'), or an _array_ of message UIDs and/or message UID ranges. `flags` is either a single flag or an _array_ of flags. `callback` has 1 parameter: < _Error_ >err. + + * **addKeywordsSince**(< _mixed_ >source, < _mixed_ >keywords, < _function_ >callback) - _(void)_ - Adds keyword(s) to message(s) that have not changed since `modseq`. `source` can be a message UID, a message UID range (e.g. '2504:2507' or '\*' or '2504:\*'), or an _array_ of message UIDs and/or message UID ranges. `keywords` is either a single keyword or an _array_ of keywords. `callback` has 1 parameter: < _Error_ >err. + + * **delKeywordsSince**(< _mixed_ >source, < _mixed_ >keywords, < _function_ >callback) - _(void)_ - Removes keyword(s) from message(s) that have not changed since `modseq`. `source` can be a message UID, a message UID range (e.g. '2504:2507' or '\*' or '2504:\*'), or an _array_ of message UIDs and/or message UID ranges. `keywords` is either a single keyword or an _array_ of keywords. `callback` has 1 parameter: < _Error_ >err. + + * **setKeywordsSince**(< _mixed_ >source, < _mixed_ >keywords, < _function_ >callback) - _(void)_ - Sets keyword(s) for message(s) that have not changed since `modseq`. `source` can be a message UID, a message UID range (e.g. '2504:2507' or '\*' or '2504:\*'), or an _array_ of message UIDs and/or message UID ranges. `keywords` is either a single keyword or an _array_ of keywords. `callback` has 1 parameter: < _Error_ >err. + TODO ---- @@ -689,5 +726,4 @@ Several things not yet implemented in no particular order: * Support additional IMAP commands/extensions: * NOTIFY (via NOTIFY extension -- RFC5465) * STATUS addition to LIST (via LIST-STATUS extension -- RFC5819) - * CONDSTORE (RFC4551) * QRESYNC (RFC5162) diff --git a/lib/Connection.js b/lib/Connection.js index 89ab376..0002e95 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -296,7 +296,12 @@ Connection.prototype.openBox = function(name, readOnly, cb) { cmd = (readOnly ? 'EXAMINE' : 'SELECT'), self = this; - this._enqueue(cmd + ' "' + encname + '"', function(err) { + cmd += ' "' + encname + '"'; + + if (this.serverSupports('CONDSTORE')) + cmd += ' (CONDSTORE)'; + + this._enqueue(cmd, function(err) { if (err) { self._box = undefined; cb(err); @@ -382,7 +387,14 @@ Connection.prototype.status = function(boxName, cb) { boxName = escape(utf7.encode(''+boxName)); - this._enqueue('STATUS "' + boxName + '" (MESSAGES RECENT UNSEEN UIDVALIDITY)', + var info = [ 'MESSAGES', 'RECENT', 'UNSEEN', 'UIDVALIDITY' ]; + + if (this.serverSupports('CONDSTORE')) + info.push('HIGHESTMODSEQ'); + + info = info.join(' '); + + this._enqueue('STATUS "' + boxName + '" (' + info + ')', cb); }; @@ -492,6 +504,8 @@ Connection.prototype._store = function(which, uids, cfg, cb) { uids = uids.join(','); var modifiers = ''; + if (cfg.modseq !== undefined) + modifiers += 'UNCHANGEDSINCE ' + cfg.modseq + ' '; this._enqueue(which + 'STORE ' + uids + ' ' + modifiers @@ -618,6 +632,8 @@ Connection.prototype._fetch = function(which, uids, options) { fetching.push('X-GM-MSGID'); fetching.push('X-GM-LABELS'); } + if (this.serverSupports('CONDSTORE')) + fetching.push('MODSEQ'); fetching.push('UID'); fetching.push('FLAGS'); @@ -644,6 +660,19 @@ Connection.prototype._fetch = function(which, uids, options) { cmd += ')'; + var modifiers = options.modifiers, + modkeys = (typeof modifiers === 'object' ? Object.keys(modifiers) : []), + modstr = ' ('; + for (var i = 0, len = modkeys.length, key; i < len; ++i) { + key = modkeys[i].toUpperCase(); + if (key === 'CHANGEDSINCE' && this.serverSupports('CONDSTORE')) + modstr += key + ' ' + modifiers[modkeys[i]] + ' '; + } + if (modstr.length > 2) { + cmd += modstr.substring(0, modstr.length - 1); + cmd += ')'; + } + this._enqueue(cmd); var req = this._queue[this._queue.length - 1]; req.fetchCache = {}; @@ -864,6 +893,48 @@ Connection.prototype._thread = function(which, algorithm, criteria, cb) { req.lines = lines; } }; + +Connection.prototype.addFlagsSince = function(uids, flags, modseq, cb) { + this._store('UID ', + uids, + { mode: '+', flags: flags, modseq: modseq }, + cb); +}; + +Connection.prototype.delFlagsSince = function(uids, flags, modseq, cb) { + this._store('UID ', + uids, + { mode: '-', flags: flags, modseq: modseq }, + cb); +}; + +Connection.prototype.setFlagsSince = function(uids, flags, modseq, cb) { + this._store('UID ', + uids, + { mode: '', flags: flags, modseq: modseq }, + cb); +}; + +Connection.prototype.addKeywordsSince = function(uids, keywords, modseq, cb) { + this._store('UID ', + uids, + { mode: '+', keywords: keywords, modseq: modseq }, + cb); +}; + +Connection.prototype.delKeywordsSince = function(uids, keywords, modseq, cb) { + this._store('UID ', + uids, + { mode: '-', keywords: keywords, modseq: modseq }, + cb); +}; + +Connection.prototype.setKeywordsSince = function(uids, keywords, modseq, cb) { + this._store('UID ', + uids, + { mode: '', keywords: keywords, modseq: modseq }, + cb); +}; // END Extension methods ======================================================= // Namespace for seqno-based commands @@ -903,6 +974,7 @@ Connection.prototype.__defineGetter__('seq', function() { self._search('', options, cb); }, + // Extensions ============================================================== delLabels: function(seqnos, labels, cb) { self._storeLabels('', seqnos, labels, '-', cb); }, @@ -922,6 +994,44 @@ Connection.prototype.__defineGetter__('seq', function() { }, thread: function(algorithm, criteria, cb) { self._thread('', algorithm, criteria, cb); + }, + + delKeywordsSince: function(seqnos, keywords, modseq, cb) { + self._store('', + seqnos, + { mode: '-', keywords: keywords, modseq: modseq }, + cb); + }, + addKeywordsSince: function(seqnos, keywords, modseq, cb) { + self._store('', + seqnos, + { mode: '+', keywords: keywords, modseq: modseq }, + cb); + }, + setKeywordsSince: function(seqnos, keywords, modseq, cb) { + self._store('', + seqnos, + { mode: '', keywords: keywords, modseq: modseq }, + cb); + }, + + delFlagsSince: function(seqnos, flags, modseq, cb) { + self._store('', + seqnos, + { mode: '-', flags: flags, modseq: modseq }, + cb); + }, + addFlagsSince: function(seqnos, flags, modseq, cb) { + self._store('', + seqnos, + { mode: '+', flags: flags, modseq: modseq }, + cb); + }, + setFlagsSince: function(seqnos, flags, modseq, cb) { + self._store('', + seqnos, + { mode: '', flags: flags, modseq: modseq }, + cb); } }; }); @@ -937,9 +1047,16 @@ Connection.prototype._resUntagged = function(info) { 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') + else if (type === 'sort' || type === 'thread') this._curReq.cbargs.push(info.text); - else if (type === 'quota') { + 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') { var cbargs = this._curReq.cbargs; if (!cbargs.length) cbargs.push([]); @@ -995,6 +1112,8 @@ Connection.prototype._resUntagged = function(info) { 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') { var idx, permFlags, keywords; this._box.permFlags = permFlags = info.textCode.val; @@ -1067,6 +1186,8 @@ Connection.prototype._resUntagged = function(info) { box.messages.total = attrs.messages; 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') { @@ -1570,11 +1691,11 @@ function buildSearchQuery(options, extensions, info, isOrChild) { validateUIDList(args); searchargs += modifier + criteria + ' ' + args.join(','); break; - // -- Extensions criteria -- + // Extensions ========================================================== 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); + throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); @@ -1587,7 +1708,7 @@ function buildSearchQuery(options, extensions, info, isOrChild) { break; case 'X-GM-RAW': // Gmail search syntax if (extensions.indexOf('X-GM-EXT-1') === -1) - throw new Error('IMAP extension not available: ' + criteria); + throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); @@ -1598,7 +1719,15 @@ function buildSearchQuery(options, extensions, info, isOrChild) { break; case 'X-GM-LABELS': // Gmail labels if (extensions.indexOf('X-GM-EXT-1') === -1) - throw new Error('IMAP extension not available: ' + criteria); + throw new Error('IMAP extension not available for: ' + criteria); + if (!args || args.length !== 1) + throw new Error('Incorrect number of arguments for search option: ' + + criteria); + searchargs += modifier + criteria + ' ' + args[0]; + break; + case 'MODSEQ': + if (extensions.indexOf('CONDSTORE') === -1) + throw new Error('IMAP extension not available for: ' + criteria); if (!args || args.length !== 1) throw new Error('Incorrect number of arguments for search option: ' + criteria); diff --git a/lib/Parser.js b/lib/Parser.js index f726245..b13cdb0 100644 --- a/lib/Parser.js +++ b/lib/Parser.js @@ -20,7 +20,8 @@ var CH_LF = 10, RE_CRLF = /\r\n/g, RE_HDR = /^([^:]+):[ \t]?(.+)?$/, RE_ENCWORD = /=\?([^?]*?)\?([qb])\?(.*?)\?=/gi, - RE_QENC = /(?:=([a-fA-F0-9]{2}))|_/g; + RE_QENC = /(?:=([a-fA-F0-9]{2}))|_/g, + RE_SEARCH_MODSEQ = /^(.+) \(MODSEQ (.+?)\)$/i; function Parser(stream, debug) { if (!(this instanceof Parser)) @@ -213,13 +214,22 @@ Parser.prototype._resUntagged = function() { || type === 'capability' || type === 'sort') { if (m[5]) { - if (m[5][0] === '(') - val = RE_LISTCONTENT.exec(m[5])[1].split(' '); - else - val = m[5].split(' '); + if (type === 'search' && RE_SEARCH_MODSEQ.test(m[5])) { + // CONDSTORE search response + var p = RE_SEARCH_MODSEQ.exec(m[5]); + val = { + results: p[1].split(' '); + modseq: p[2] + }; + } else { + if (m[5][0] === '(') + val = RE_LISTCONTENT.exec(m[5])[1].split(' '); + else + val = m[5].split(' '); - if (type === 'search' || type === 'sort') - val = val.map(function(v) { return parseInt(v, 10); }); + if (type === 'search' || type === 'sort') + val = val.map(function(v) { return parseInt(v, 10); }); + } } else val = []; } else if (type === 'thread') { @@ -395,6 +405,8 @@ function parseFetch(text, literals) { val = parseFetchEnvelope(val); else if (key === 'internaldate') val = new Date(val); + else if (key === 'modseq') // always a list of one value + val = ''+val[0]; else if (key === 'body' || key === 'bodystructure') val = parseBodyStructure(val); attrs[key] = val;