From 4af7472a7baf6c04ed7afb19220c496756bad2d7 Mon Sep 17 00:00:00 2001 From: Brian White Date: Sun, 21 Nov 2010 05:15:52 -0500 Subject: [PATCH] Implement "OR" for message search. --- README.md | 6 +- imap.js | 223 ++++++++++++++++++++++++++++-------------------------- 2 files changed, 119 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 54fa1ee..dac9ef7 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ ImapConnection Functions * **closeBox**(Function) - _(void)_ - Closes the currently open mailbox. **Any messages marked as \Deleted in the mailbox will be removed if the mailbox was NOT opened in read-only mode.** Also, logging out or opening another mailbox without closing the current one first will NOT cause deleted messages to be removed. The Function parameter is the callback with one parameter: the error (null if none). -* **search**(Array, Function) - _(void)_ - Searches the currently open mailbox for messages using specific criterion. The Function parameter is the callback with three parameters: the error (null if none), the _Box_ object of the currently open mailbox, and an Array containing the message IDs matching the search criterion. The Array parameter is a list of Arrays containing the criterion (and also value(s) for some types of criteria) to be used. Prefix the criteria name with an "!" to negate. For example, to search for unread messages since April 20, 2010 you could use: [ ['UNSEEN'], ['SINCE', 'April 20, 2010'] ] +* **search**(Array, Function) - _(void)_ - Searches the currently open mailbox for messages using specific criterion. The Function parameter is the callback with three parameters: the error (null if none), the _Box_ object of the currently open mailbox, and an Array containing the message IDs matching the search criterion. The Array parameter is a list of Arrays containing the criterion (and any required arguments) to be used. Prefix the criteria name with an "!" to negate. For example, to search for unread messages since April 20, 2010 you could use: [ ['UNSEEN'], ['SINCE', 'April 20, 2010'] ]. To search for messages that are EITHER unread OR are dated April 20, 2010 or later, you could use: [ ['OR', ['UNSEEN'], ['SINCE', 'April 20, 2010'] ] ]. * The following message flags are valid criterion and do not require values: * 'ANSWERED' - Messages with the \Answered flag set. * 'DELETED' - Messages with the \Deleted flag set. @@ -236,6 +236,7 @@ ImapConnection Functions * The following are valid criterion that require an Integer value: * 'LARGER' - Messages with a size larger than the specified number of bytes. * 'SMALLER' - Messages with a size smaller than the specified number of bytes. + * **Note:** By default, all criterion are ANDed together. You can use the special 'OR' on **two** criterion to find messages matching either search criteria (see example above). * **fetch**(Integer, Object, Function) - _(void)_ - Fetches the message with the message ID specified by the Integer parameter in the currently open mailbox. The Function parameter is the callback with three parameters: the error (null if none), the _Box_ object of the currently open mailbox, and the _FetchResult_ containing the result of the fetch request. The Object parameter is a set of options used to determine how and what exactly to fetch. The valid options are: * **markSeen** - A Boolean indicating whether to mark the message as read when fetching it. **Default:** false @@ -257,11 +258,8 @@ TODO A bunch of things not yet implemented in no particular order: * Support AUTH=CRAM-MD5/AUTH=CRAM_MD5 authentication -* OR searching ability with () grouping -* HEADER.FIELDS.NOT capability during FETCH using "!" prefix * Support IMAP keywords (with a workaround for gmail's lack of support for IMAP keywords) * Support additional IMAP commands/extensions: - * APPEND (is this really useful?) * GETQUOTA (via QUOTA extension -- http://tools.ietf.org/html/rfc2087) * UNSELECT (via UNSELECT extension -- http://tools.ietf.org/html/rfc3691) * LIST (and XLIST via XLIST extension -- http://groups.google.com/group/Gmail-Help-POP-and-IMAP-en/browse_thread/thread/a154105c54f020fb) diff --git a/imap.js b/imap.js index 5295d3f..4b749d7 100644 --- a/imap.js +++ b/imap.js @@ -307,88 +307,7 @@ ImapConnection.prototype.search = function(options, cb) { throw new Error('No mailbox is currently selected'); if (!Array.isArray(options)) throw new Error('Expected array for search options'); - var searchargs = '', months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - for (var i=0,len=options.length; i 1) - args = criteria.slice(1); - if (criteria.length > 0) - criteria = criteria[0].toUpperCase(); - } else - throw new Error('Unexpected search option data type. Expected string, number, or array. Got: ' + typeof criteria); - if (criteria[0] === '!') { - modifier += 'NOT '; - criteria = criteria.substr(1); - } - switch(criteria) { - 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); - searchargs += modifier + criteria + ' "' + escape(''+args[0]) + '"'; - 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'); - } - 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]); - 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); - searchargs += modifier + criteria + ' "' + escape(''+args[0]) + '" "' + escape(''+args[1]) + '"'; - break; - default: - throw new Error('Unexpected search option: ' + criteria); - } - } - this._send('UID SEARCH' + searchargs, cb); + this._send('UID SEARCH' + buildSearchQuery(options), cb); }; ImapConnection.prototype.fetch = function(uid, options, cb) { @@ -570,6 +489,99 @@ ImapConnection.prototype._send = function(cmdstr, cb, bypass) { /****** Utility Functions ******/ +function buildSearchQuery(options, isOrChild) { + var searchargs = '', months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + for (var i=0,len=options.length; i 1) + args = criteria.slice(1); + if (criteria.length > 0) + criteria = criteria[0].toUpperCase(); + } else + throw new Error('Unexpected search option data type. Expected string, number, or array. Got: ' + typeof criteria); + if (criteria === 'OR') { + if (args.length !== 2) + throw new Error('OR must have exactly two arguments'); + searchargs += ' OR (' + buildSearchQuery(args[0], true) + ') (' + buildSearchQuery(args[1], true) + ')' + } else { + if (criteria[0] === '!') { + modifier += 'NOT '; + criteria = criteria.substr(1); + } + switch(criteria) { + 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); + searchargs += modifier + criteria + ' "' + escape(''+args[0]) + '"'; + 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]); + 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); + searchargs += modifier + criteria + ' "' + escape(''+args[0]) + '" "' + escape(''+args[1]) + '"'; + break; + default: + throw new Error('Unexpected search option: ' + criteria); + } + } + if (isOrChild) + break; + } + return searchargs; +} + function parseFetch(str, literalData, fetchData) { // passed in str === "... {xxxx}" or "... {xxxx} ..." or just "..." // where ... is any number of key-value pairs @@ -964,64 +976,63 @@ function extend() { var target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, options, name, src, copy; // Handle a deep copy situation - if ( typeof target === "boolean" ) { - deep = target; - target = arguments[1] || {}; - // skip the boolean and the target - i = 2; + if (typeof target === "boolean") { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; } // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !typeof target === 'function') { - target = {}; - } + if (typeof target !== "object" && !typeof target === 'function') + target = {}; - var isPlainObject = function( obj ) { + var isPlainObject = function(obj) { // Must be an Object. // Because of IE, we also have to check the presence of the constructor property. // Make sure that DOM nodes and window objects don't pass through, as well - if ( !obj || toString.call(obj) !== "[object Object]" || obj.nodeType || obj.setInterval ) + if (!obj || toString.call(obj) !== "[object Object]" || obj.nodeType || obj.setInterval) return false; var has_own_constructor = hasOwnProperty.call(obj, "constructor"); var has_is_property_of_method = hasOwnProperty.call(obj.constructor.prototype, "isPrototypeOf"); // Not own constructor property must be Object - if ( obj.constructor && !has_own_constructor && !has_is_property_of_method) + if (obj.constructor && !has_own_constructor && !has_is_property_of_method) return false; // Own properties are enumerated firstly, so to speed up, // if last one is own, then all properties are own. var last_key; - for ( key in obj ) + for (key in obj) last_key = key; - return typeof last_key === "undefined" || hasOwnProperty.call( obj, last_key ); + return typeof last_key === "undefined" || hasOwnProperty.call(obj, last_key); }; - for ( ; i < length; i++ ) { + for (; i < length; i++) { // Only deal with non-null/undefined values - if ( (options = arguments[ i ]) !== null ) { + if ((options = arguments[i]) !== null) { // Extend the base object - for ( name in options ) { - src = target[ name ]; - copy = options[ name ]; + for (name in options) { + src = target[name]; + copy = options[name]; // Prevent never-ending loop - if ( target === copy ) + if (target === copy) continue; // Recurse if we're merging object literal values or arrays - if ( deep && copy && ( isPlainObject(copy) || Array.isArray(copy) ) ) { - var clone = src && ( isPlainObject(src) || Array.isArray(src) ) ? src : Array.isArray(copy) ? [] : {}; + if (deep && copy && (isPlainObject(copy) || Array.isArray(copy))) { + var clone = src && (isPlainObject(src) || Array.isArray(src)) ? src : Array.isArray(copy) ? [] : {}; // Never move original objects, clone them - target[ name ] = extend( deep, clone, copy ); + target[name] = extend(deep, clone, copy); // Don't bring in undefined values - } else if ( typeof copy !== "undefined" ) - target[ name ] = copy; + } else if (typeof copy !== "undefined") + target[name] = copy; } } }