diff --git a/.gitignore b/.gitignore index b512c09..f1f71e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -node_modules \ No newline at end of file +node_modules +.DS_Store +tools/log.txt +.idea \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 00d0f06..993d115 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,12 @@ language: node_js node_js: - - 0.6 - - 0.7 + - 0.8 + - "0.10" + - "0.11" notifications: email: recipients: - - andris@node.ee + - andris@kreata.ee on_success: change on_failure: change diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cde8175 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +New features are accepted to the master branch if + + * The feature is complete. It does what is expected by the description, eg. in case of *SEARCH* the method should implement all (or a reasonable amount of) search conditions and it would accept several untagged *SEARCH* responses not just the first one - even though servers tend to respond with only one untagged response, the spec allows several + * The new feature follows the style of existing features + * The feature is properly tested. For tests you can use Nodeunit and Hoodiecrow, see [test/inbox.js](test/inbox.js) for an example. Tests for a complete feature should have its own test file in the [test](test/) folder. + +## Formatting + +Use 4 spaces instead of tabs. Commas last. Use double quotes instead of single quotes where possible. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8a4cfc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright (c) 2012-2013 Andris Reinman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 50d83b1..fc8542d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # inbox -This is a work in progress IMAP client for node.js. +This is a work in progress IMAP client for node.js. The project consists of two major parts @@ -8,6 +8,8 @@ The project consists of two major parts * IMAP control for accessing mailboxes (under construction) [![Build Status](https://secure.travis-ci.org/andris9/inbox.png)](http://travis-ci.org/andris9/inbox) +[![NPM version](https://badge.fury.io/js/inbox.png)](http://badge.fury.io/js/inbox) + ## Installation @@ -20,14 +22,15 @@ Install from npm **NB!** This API is preliminary and may change. Use **inbox** module - - var inbox = require("inbox"); - +```javascript +var inbox = require("inbox"); +``` ### Create new IMAP connection -Create connection object with - - inbox.createConnection(port, host, options) +Create connection object with +```javascript +inbox.createConnection(port, host, options) +``` where @@ -38,209 +41,576 @@ where * **options.auth** is an authentication object * **options.auth.user** is the IMAP username * **options.auth.pass** is the IMAP password + * **options.auth.XOAuth2** (optional) is either an object with {user, clientId, clientSecret, refreshToken} or *xoauth2.createXOAuth2Generator* object, see [xoauth2](https://github.com/andris9/xoauth2) for details * **options.auth.XOAuthToken** (optional) is either a String or *inbox.createXOAuthGenerator* object + * **options.clientId** is optional client ID params object + * **options.clientId.name** is is the name param etc. see [rfc 2971](http://tools.ietf.org/html/rfc2971#section-3.3) for possible field names Example: +```javascript +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + } +}); +``` + +Or for login with XOAUTH2 (see examples/xoauth2) +```javascript +// XOAUTH2 +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + XOAuth2:{ + user: "example.user@gmail.com", + clientId: "8819981768.apps.googleusercontent.com", + clientSecret: "{client_secret}", + refreshToken: "1/xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI", + accessToken: "vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==", + timeout: 3600 + } + } +}); +``` + - var client = inbox.createConnection(false, "imap.gmail.com", { - secureConnection: true, - auth:{ +Or for login with XOAUTH (see examples/xoauth-3lo.js and examples/xoauth-2lo.js) + +```javascript +// 3-legged- oauth +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + XOAuthToken: inbox.createXOAuthGenerator({ user: "test.nodemailer@gmail.com", - pass: "Nodemailer123" - } - }); + token: "1/Gr2OVA2Ol64fNyjZCns-bkRau5eLisbdlEa_HSuTaEk", + tokenSecret: "ymFpseHtEnrIsuL8Ppbfnnk3" + }) + } +}); +``` + +With 2-legged OAuth, consumerKey and consumerSecret need to have proper values, vs 3-legged OAuth where both default to "anonymous". +```javascript +// 2-legged- oauth +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + XOAuthToken: inbox.createXOAuthGenerator({ + user: "test.nodemailer@gmail.com", + requestorId: "test.nodemailer@gmail.com", + consumerKey: "1/Gr2OVA2Ol64fNyjZCns-bkRau5eLisbdlEa_HSuTaEk", + consumerSecret: "ymFpseHtEnrIsuL8Ppbfnnk3" + }) + } +}); +``` -Or when login with XOAUTH (see examples/xoauth.js) - - var client = inbox.createConnection(false, "imap.gmail.com", { - secureConnection: true, - auth:{ - XOAuthToken: inbox.createXOAuthGenerator({ - user: "test.nodemailer@gmail.com", - token: "1/Gr2OVA2Ol64fNyjZCns-bkRau5eLisbdlEa_HSuTaEk", - tokenSecret: "ymFpseHtEnrIsuL8Ppbfnnk3" - }) - } - }); - Once the connection object has been created, use connect() to create the actual connection. +```javascript +client.connect(); +``` - client.connect(); - When the connection has been successfully established a 'connect' event is emitted. +```javascript +client.on("connect", function(){ + console.log("Successfully connected to server"); +}); +``` - client.on("connect", function(){ - console.log("Successfully connected to server"); - }); +### Logout and disconnect + +Logout from IMAP and close NET connection. + +```javascript +client.close(); +client.on('close', function (){ + console.log('DISCONNECTED!'); +}); +``` ### List available mailboxes -To get the list of available mailboxes, use +To list the available mailboxes use +```javascript +client.listMailboxes(callback) +``` - client.getMailboxList() +Where -which returns the mailbox list + * **callback** *(error, mailboxes)* returns a list of root mailbox object -Example +Mailbox objects have the following properties + + * **name** - the display name of the mailbox + * **path** - the actual name of the mailbox, use it for opening the mailbox + * **type** - the type of the mailbox (if server hints about it) + * **hasChildren** - boolean indicator, if true, has child mailboxes + * **disabled** - boolean indicator, if true, can not be selected + +Additionally mailboxes have the following methods + + * **open** *([options, ]callback)* - open the mailbox (shorthand for *client.openMailbox*) + * **listChildren** *(callback)* - if the mailbox has children (*hasChildren* is true), lists the child mailboxes + +Example: +```javascript +client.listMailboxes(function(error, mailboxes){ + for(var i=0, len = mailboxes.length; i', + inReplyTo: '<4FB16D5A.30808@gmail.com>', + references: ['<4FB16D5A.30808@gmail.com>','<1299323903.19454@foo.bar>'], + + // bodystructure of the message + bodystructure: { + '1': { + part: '1', + type: 'text/plain', + parameters: {}, + encoding: 'quoted-printable', + size: 16 + }, + '2': { + part: '2', + type: 'text/html', + parameters: {}, + encoding: 'quoted-printable', + size: 248 + }, + type: 'multipart/alternative' + } + }, + ... +] +``` - // list newest 10 messages - client.listMessages(-10, function(err, messages){ - messages.forEach(function(message){ - console.log(message.UID + ": " + message.title); - }); +**NB!** If some properties are not present in a message, it may be not included +in the message object - for example, if there are no "cc:" addresses listed, +there is no "cc" field in the message object. + +### Listing messages by UID + +You can list messages by UID with + +```javascript +client.listMessagesByUID(firstUID, lastUID, callback) +``` + +Where + + * **firstUI** is the UID value to start listing from + * **lastUID** is the UID value to end listing with, can be a number or "*" + * **callback** is the same as with `listMessage` + +### Listing flags + +As a shorthand listing, you can also list only UID and Flags pairs +```javascript +client.listFlags(from[, limit], callback) +``` + +Where + + * **from** is the index of the first message (0 based), you can use negative numbers to count from the end (-10 indicates the 10 last messages) + * **limit** defines the maximum count of messages to fetch, if not set or 0 all messages from the starting position will be included + * **callback** *(error, messages)* is the callback function to run with the message array + +Example +```javascript +// list flags for newest 10 messages +client.listFlags(-10, function(err, messages){ + messages.forEach(function(message){ + console.log(message.UID, message.flags); }); +}); +``` + +Example output for a message listing +```javascript +[ + { + // if uidvalidity changes, all uid values are void! + UIDValidity: '664399135', + + // uid value of the message + UID: 52, + + // message flags (Array) + flags: [ '\\Flagged', '\\Seen' ] + }, + ... +] +``` ### Fetch message details To fetch message data (flags, title, etc) for a specific message, use +```javascript +client.fetchData(uid, callback) +``` - client.fetchData(uid, callback) - Where * **uid** is the UID value for the mail - * **callback** is the callback function to with the message data object (or null if the message was not found). Gets an error parameter if error occured + * **callback** *(error, message)* is the callback function to with the message data object (or null if the message was not found). Gets an error parameter if error occured Example - - client.fetchData(123, function(error, message){ - console.log(message.flags); - }); +```javascript +client.fetchData(123, function(error, message){ + console.log(message.flags); +}); +``` ### Fetch message contents Message listing only retrieves the envelope part of the message. To get the full RFC822 message body you need to fetch the message. +```javascript +var messageStream = client.createMessageStream(uid) +``` - var messageStream = client.createMessageStream(uid) - Where * **uid** is the UID value for the mail Example (output message contents to console) +```javascript +client.createMessageStream(123).pipe(process.stdout, {end: false}); +``` - client.reateMessageStream(123).pipe(process.stdout, {end: false}); - -**NB!** If the opened mailbox is not in read-only mode, the message will be +**NB!** If the opened mailbox is not in read-only mode, the message will be automatically marked as read (\Seen flag is set) when the message is fetched. +### Searching for messages + +You can search for messages with + +```javascript +client.search(query[, isUID], callback) +``` + +Where + + * **query** is the search term as an object + * **isUID** is an optional boolean value - if set to true perform `UID SEARCH` instead of `SEARCH` + * **callback** is the callback function with error object and an array of matching seq or UID numbers + +**Queries** + +Queries are composed as objects where keys are search terms and values are term arguments. +Only strings, numbers and Dates are used. If the value is an array, the members of it are processed separately +(use this for terms that require multiple params). If the value is a Date, it is converted to the form of "01-Jan-1970". +Subqueries (OR, NOT) are made up of objects + +Examples: + +```javascript +// SEARCH UNSEEN +query = {unseen: true} +// SEARCH KEYWORD "flagname" +query = {keyword: "flagname"} +// SEARCH HEADER "subject" "hello world" +query = {header: ["subject", "hello world"]}; +// SEARCH UNSEEN HEADER "subject" "hello world" +query = {unseen: true, header: ["subject", "hello world"]}; +// SEARCH OR UNSEEN SEEN +query = {or: {unseen: true, seen: true}}; +// SEARCH UNSEEN NOT SEEN +query = {unseen: true, not: {seen: true}} +``` + +Returned list is already sorted and all values are numbers. + ### Message flags You can add and remove message flags like `\Seen` or `\Answered` with `client.addFlags()` and `client.removeFlags()` -**Add flags** +**List flags** +```javascript +client.fetchFlags(uid, callback) +``` + +Where - client.addFlags(uid, flags, callback) + * **uid** is the message identifier + * **callback** *(error, flags)* is the callback to run, gets message flags array as a parameter + +**Add flags** +```javascript +client.addFlags(uid, flags, callback) +``` Where * **uid** is the message identifier * **flags** is the array of flags to be added - * **callback** *(err, flags)* is the callback to run, gets message flags array as a parameter + * **callback** *(error, flags)* is the callback to run, gets message flags array as a parameter **Remove flags** - - client.removeFlags(uid, flags, callback) +```javascript +client.removeFlags(uid, flags, callback) +``` Where * **uid** is the message identifier * **flags** is the array of flags to be removed - * **callback** *(err, flags)* is the callback to run, gets message flags array as a parameter + * **callback** *(error, flags)* is the callback to run, gets message flags array as a parameter Example +```javascript +// add \Seen and \Flagged flag to a message +client.addFlags(123, ["\\Seen", "\\Flagged"], function(err, flags){ + console.log("Current flags for a message: ", flags); +}); - // add \Seen and \Flagged flag to a message - client.addFlags(123, ["\\Seen", "\\Flagged"], function(err, flags){ - console.log("Current flags for a message: ", flags); - }); - - // remove \Flagged flag from a message - client.removeFlags(123, ["\\Flagged"], function(err, flags){ - console.log("Current flags for a message: ", flags); - }); +// remove \Flagged flag from a message +client.removeFlags(123, ["\\Flagged"], function(err, flags){ + console.log("Current flags for a message: ", flags); +}); +``` + +### Upload a message + +You can upload a message to current mailbox with `client.storeMessage()` +```javascript +client.storeMessage(message[, flags], callback) +``` + +Where + + * **message** is the message to be uploaded either as a string or a Buffer. + * **flags** is an array of flags to set to the message (ie. `["\\Seen"]`) + * **callback** is the callback function, gets message UID and UID and UIDValitity as a param + +Example +```javascript +client.storeMessage("From: ....", ["\\Seen"], function(err, params){ + console.log(err || params.UIDValidity +", "+ params.UID); +}); +``` + +When adding a message to the mailbox, the new message event is also raised after +the mail has been stored. + +### Copy a message + +You can copy a message from the current mailbox to a selected one with `client.copyMessage()` +```javascript +client.copyMessage(uid, destination, callback) +``` + +Where + + * **uid** is the message identifier. + * **destination** is the path to the destination mailbox + * **callback** is the callback function + +Example +```javascript +client.copyMessage(123, "[GMail]/Junk", function(err){ + console.log(err || "success, copied to junk"); +}); +``` + +### Move a message + +You can move a message from current mailbox to a selected one with `client.moveMessage()` +```javascript +client.moveMessage(uid, destination, callback) +``` + +Where + + * **uid** is the message identifier. + * **destination** is the path to the destination mailbox + * **callback** is the callback function + +Example +```javascript +client.moveMessage(123, "[GMail]/Junk", function(err){ + console.log(err || "success, moved to junk"); +}); +``` + +### Delete a message + +You can delete a message from current mailbox with `client.deleteMessage()` +```javascript +client.deleteMessage(uid, callback) +``` + +Where + + * **uid** is the message identifier. + * **callback** is the callback function + +Example +```javascript +client.deleteMessage(123, function(err){ + console.log(err || "success, message deleted"); +}); +``` ### Wait for new messages You can listen for new incoming e-mails with event "new" +```javascript +client.on("new", function(message){ + console.log("New incoming message " + message.title); +}); +``` - client.on("new", function(message){ - console.log("New incoming message " + message.title); - }); - ## Complete example Listing newest 10 messages: +```javascript +var inbox = require("inbox"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + } +}); - var inbox = require("inbox"); - - var client = inbox.createConnection(false, "imap.gmail.com", { - secureConnection: true, - auth:{ - user: "test.nodemailer@gmail.com", - pass: "Nodemailer123" - } - }); - - client.connect(); - - client.on("connect", function(){ - client.openMailbox("INBOX", function(error, mailbox){ - if(error) throw error; - - client.listMessages(-10, function(err, messages){ - messages.forEach(function(message){ - console.log(message.UID + ": " + message.title); - }); - }); +client.connect(); + +client.on("connect", function(){ + client.openMailbox("INBOX", function(error, info){ + if(error) throw error; + client.listMessages(-10, function(err, messages){ + messages.forEach(function(message){ + console.log(message.UID + ": " + message.title); + }); }); - }); \ No newline at end of file + + }); +}); +``` + +## License + +**MIT** diff --git a/examples/append.js b/examples/append.js new file mode 100644 index 0000000..a0b7ca9 --- /dev/null +++ b/examples/append.js @@ -0,0 +1,37 @@ +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + }, + debug: false +}); + +client.connect(); + +client.on("connect", function(){ + + client.openMailbox("[Gmail]/Sent Mail", function(error, mailbox){ + if(error) throw error; + + client.storeMessage("From: andris@node.ee\r\n"+ + "To: andris@kreata.ee\r\n"+ + "Message-Id: 1234\r\n"+ + "Subject: test 2\r\n"+ + "\r\n"+ + "Tere tere 2!", ["\\Seen"], console.log); + + }); + + // on new messages, print to console + client.on("new", function(message){ + console.log("New message:"); + console.log(util.inspect(message, false, 7)); + + client.createMessageStream(message.UID).pipe(process.stdout, {end: false}); + + }); +}); diff --git a/examples/copy.js b/examples/copy.js new file mode 100644 index 0000000..0229041 --- /dev/null +++ b/examples/copy.js @@ -0,0 +1,33 @@ +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + }, + debug: true +}); + +client.connect(); + +client.on("connect", function(){ + + client.openMailbox("INBOX", function(error, mailbox){ + if(error) throw error; + + client.listMessages(-1, function(error, messages){ + messages.forEach(function(message){ + console.log("Message") + console.log(message); + + client.copyMessage(message.UID, "[Gmail]/Saadetud kirjad", function(error){ + console.log(arguments); + }) + }) + }) + + }); + +}); diff --git a/examples/delete.js b/examples/delete.js new file mode 100644 index 0000000..11c204e --- /dev/null +++ b/examples/delete.js @@ -0,0 +1,31 @@ +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + }, + debug: true +}); + +client.connect(); + +client.on("connect", function(){ + + client.openMailbox("INBOX", function(error, mailbox){ + if(error) throw error; + + client.listMessages(-1, function(error, messages){ + messages.forEach(function(message){ + console.log("Message") + console.log(message); + + client.deleteMessage(message.UID, function(error){ + console.log(arguments); + }); + }); + }); + }); +}); diff --git a/examples/id.js b/examples/id.js new file mode 100644 index 0000000..c1b8d82 --- /dev/null +++ b/examples/id.js @@ -0,0 +1,27 @@ +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + }, + clientId: { + name: "test", + "support-url": "test2" + }, + debug: false +}); + +client.connect(); + +client.on("connect", function(){ + + client.openMailbox("INBOX", function(error, mailbox){ + if(error) throw error; + + + }); + +}); diff --git a/examples/list.js b/examples/list.js index 05cd3ec..24c895e 100644 --- a/examples/list.js +++ b/examples/list.js @@ -1,36 +1,53 @@ var inbox = require(".."), util = require("util"); - + var client = inbox.createConnection(false, "imap.gmail.com", { secureConnection: true, auth:{ user: "test.nodemailer@gmail.com", pass: "Nodemailer123" }, - debug: false + debug: true }); client.connect(); client.on("connect", function(){ - console.log(util.inspect(client.getMailboxList(), false, 7)); + + client.listMailboxes(console.log); + client.openMailbox("INBOX", function(error, mailbox){ if(error) throw error; - + // List newest 10 messages client.listMessages(-10, function(err, messages){ messages.forEach(function(message){ console.log(message.UID+": "+message.title); }); - }); + + client.listFlags(-10, function(err, messages){ + messages.forEach(function(message){ + console.log(message); + }); + //client.close(); + }); + }); }); - + // on new messages, print to console client.on("new", function(message){ console.log("New message:"); console.log(util.inspect(message, false, 7)); - + client.createMessageStream(message.UID).pipe(process.stdout, {end: false}); - }); }); + +client.on('error', function (err){ + console.log('Error'); + console.log(err) +}); + +client.on('close', function (){ + console.log('DISCONNECTED!'); +}); diff --git a/examples/move.js b/examples/move.js new file mode 100644 index 0000000..2b9b9de --- /dev/null +++ b/examples/move.js @@ -0,0 +1,33 @@ +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + user: "test.nodemailer@gmail.com", + pass: "Nodemailer123" + }, + debug: true +}); + +client.connect(); + +client.on("connect", function(){ + + client.openMailbox("INBOX", function(error, mailbox){ + if(error) throw error; + + client.listMessages(-1, function(error, messages){ + messages.forEach(function(message){ + console.log("Message") + console.log(message); + + client.moveMessage(message.UID, "[Gmail]/Saadetud kirjad", function(error){ + console.log(arguments); + }) + }) + }) + + }); + +}); diff --git a/examples/xoauth-2lo.js b/examples/xoauth-2lo.js new file mode 100644 index 0000000..6ecb067 --- /dev/null +++ b/examples/xoauth-2lo.js @@ -0,0 +1,48 @@ + +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + XOAuthToken: inbox.createXOAuthGenerator({ + user: "user@example.com", + requestorId: "user@example.com", // required for 2 legged oauth + consumerKey: "abc", + consumerSecret: "def" + }) + }, + debug: true +}); + +client.connect(); + +client.on("error", function(err){ + console.log(err) +}); + +client.on("connect", function(){ + + client.listMailboxes(console.log); + + client.openMailbox("INBOX", function(error, mailbox){ + if(error) throw error; + + // List newest 10 messages + client.listMessages(-10, function(err, messages){ + messages.forEach(function(message){ + console.log(message.UID+": "+message.title); + }); + }); + + }); + + // on new messages, print to console + client.on("new", function(message){ + console.log("New message:"); + console.log(util.inspect(message, false, 7)); + + client.createMessageStream(message.UID).pipe(process.stdout, {end: false}); + + }); +}); diff --git a/examples/xoauth.js b/examples/xoauth-3lo.js similarity index 92% rename from examples/xoauth.js rename to examples/xoauth-3lo.js index 9e2c8cd..bc07dc2 100644 --- a/examples/xoauth.js +++ b/examples/xoauth-3lo.js @@ -1,7 +1,6 @@ - var inbox = require(".."), util = require("util"); - + var client = inbox.createConnection(false, "imap.gmail.com", { secureConnection: true, auth:{ @@ -21,25 +20,27 @@ client.on("error", function(err){ }); client.on("connect", function(){ - console.log(client.getMailboxList()); + + client.listMailboxes(console.log); + client.openMailbox("INBOX", function(error, mailbox){ if(error) throw error; - + // List newest 10 messages client.listMessages(-10, function(err, messages){ messages.forEach(function(message){ console.log(message.UID+": "+message.title); }); }); - + }); - + // on new messages, print to console client.on("new", function(message){ console.log("New message:"); console.log(util.inspect(message, false, 7)); - + client.createMessageStream(message.UID).pipe(process.stdout, {end: false}); - + }); -}); +}); \ No newline at end of file diff --git a/examples/xoauth2.js b/examples/xoauth2.js new file mode 100644 index 0000000..79af9a4 --- /dev/null +++ b/examples/xoauth2.js @@ -0,0 +1,50 @@ + +var inbox = require(".."), + util = require("util"); + +var client = inbox.createConnection(false, "imap.gmail.com", { + secureConnection: true, + auth:{ + XOAuth2:{ + user: "example.user@gmail.com", + clientId: "8819981768.apps.googleusercontent.com", + clientSecret: "{client_secret}", + refreshToken: "1/xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI", + accessToken: "vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg==", + timeout: 3600 + } + }, + debug: true +}); + +client.connect(); + +client.on("error", function(err){ + console.log(err) +}); + +client.on("connect", function(){ + + client.listMailboxes(console.log); + + client.openMailbox("INBOX", function(error, mailbox){ + if(error) throw error; + + // List newest 10 messages + client.listMessages(-10, function(err, messages){ + messages.forEach(function(message){ + console.log(message.UID+": "+message.title); + }); + }); + + }); + + // on new messages, print to console + client.on("new", function(message){ + console.log("New message:"); + console.log(util.inspect(message, false, 7)); + + client.createMessageStream(message.UID).pipe(process.stdout, {end: false}); + + }); +}); diff --git a/lib/client.js b/lib/client.js index 19db7c3..6e93d40 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,3 +1,5 @@ +"use strict"; + /** * @fileOverview Provides an simple API for IMAP mailbox access * @author Andris Reinman @@ -12,7 +14,16 @@ var Stream = require("stream").Stream, starttls = require("./starttls").starttls, IMAPLineParser = require("./lineparser"), mimelib = require("mimelib"), - xoauth = require("./xoauth"); + xoauth = require("./xoauth"), + xoauth2 = require("xoauth2"), + packageData = require("../package.json"), + utf7 = require('utf7').imap, + mailboxlib = require("./mailbox"), + Mailbox = mailboxlib.Mailbox, + detectMailboxType = mailboxlib.detectMailboxType; + +var X_CLIENT_NAME = "inbox", + X_CLIENT_URL = "https://github.com/andris9/inbox"; /** * Expose to the world @@ -24,7 +35,7 @@ module.exports.IMAPClient = IMAPClient; /** * Create an IMAP inbox object, shorthand for new IMAPClient. - * + * * @memberOf inbox * @param {Number} port IMAP server port to connect to * @param {String} host IMAP server hostname @@ -36,7 +47,7 @@ function createConnection(port, host, options){ /** * Create a XOAUTH login token generator - * + * * @memberOf inbox * @param {Object} options Options object, see {@see xoauth} */ @@ -44,10 +55,9 @@ function createXOAuthGenerator(options){ return new xoauth.XOAuthGenerator(options); } - /** * Creates an IMAP connection object for communicating with the server - * + * * @constructor * @memberOf inbox * @param {Number} port IMAP server port to connect to @@ -61,32 +71,51 @@ function IMAPClient(port, host, options){ * Make this stream writeable. For future reference only, currently not needed */ this.writable = true; - + /** * Make this stream readable. Should be on by default though */ this.readable = true; - + /** * Options object for this instance */ this.options = options || {}; - + /** * Port to use for connecting to the server */ this.port = port || (this.options.secureConnection ? 993 : 143); - + /** * Server hostname */ this.host = host || "localhost"; - + /** * If set to true, print traffic between client and server to the console */ this.debug = !!this.options.debug; - + + /** + * XOAuth2 token generator if XOAUTH2 auth is used + * @private + */ + this._xoauth2 = false; + this._xoauth2RetryCount = 0; + this._xoauth2UntaggedResponse = false; + + if(typeof this.options.auth && typeof this.options.auth.XOAuth2){ + if(typeof this.options.auth.XOAuth2 == "object" && typeof this.options.auth.XOAuth2.getToken == "function"){ + this._xoauth2 = this.options.auth.XOAuth2; + }else if(typeof this.options.auth.XOAuth2 == "object"){ + if(!this.options.auth.XOAuth2.user && this.options.auth.user){ + this.options.auth.XOAuth2.user = this.options.auth.user; + } + this._xoauth2 = xoauth2.createXOAuth2Generator(this.options.auth.XOAuth2); + } + } + this._init(); } utillib.inherits(IMAPClient, Stream); @@ -113,7 +142,7 @@ IMAPClient.prototype.modes = { /** * Delay for breaking IDLE loop and running NOOP */ -IMAPClient.prototype.IDLE_TIMEOUT = 15 * 1000; +IMAPClient.prototype.IDLE_TIMEOUT = 60 * 1000; /** * Delay for entering IDLE mode after any command @@ -129,173 +158,214 @@ IMAPClient.prototype.GREETING_TIMEOUT = 15 * 1000; * Reset instance variables */ IMAPClient.prototype._init = function(){ - + /** * Should the connection be over TLS or NET */ this.options.secureConnection = !!this.options.secureConnection; - + /** * Authentication details */ this.options.auth = this.options.auth || {user: "", pass:""}; - + /** * Connection socket to the server */ this._connection = false; - + /** * Is the connection currently in secure mode, changes with STARTTLS */ this._secureMode = !!this.options.secureConnection; - + /** * Current protocol state. */ this._currentState = this.states.NONE; - + /** - * Current stream mode for incoming data + * Current stream mode for incoming data */ this._currentMode = this.modes.COMMAND; - + /** * Expected remaining data length on stream data mode */ this._expectedDataLength = 0; - + /** * Data that was not part of the last command */ this._remainder = ""; - + /** * Counter for generating unique command tags */ this._tagCounter = 0; - + /** * Currently active command */ this._currentRequest = false; - + /** * Unprocessed commands */ this._commandQueue = []; - + /** * Server capabilities */ this._capabilities = []; - + /** * Are the capabilities updated */ this._updatedCapabilities = false; - + /** * Currently in idle */ this.idling = false; - + + /** + * Currently "nooping" when idle not available + */ + this.nooping = false; + /** * Waiting for idle start after issuing IDLE command */ this._idleWait = false; - + /** * Waiting for the idle to end */ this._idleEnd = false; - + /** * Timer to run NOOP when in idle */ this._idleTimer = false; - + /** * Timer for entering idle mode after other commands */ this._shouldIdleTimer = true; - + + /** + * If true check mail before entering idle + */ + this._shouldCheckOnIdle = false; + /** * Timeout to wait for a successful greeting from the server */ this._greetingTimeout = false; - + + /** + * Server ID + */ + this._serverId = {}; + /** * Should the FETCH responses collected into an array */ this._collectMailList = false; - + /** * An array of collected FETCH responses */ this._mailList = []; - + /** * If set to true emit FETCH responses as new emails */ this._checkForNewMail = false; - + /** * Currently selected mailbox data */ this._selectedMailbox = {}; - + /** * Currently streaming possible literal values */ this._literalStreaming = false; - + /** * Message Stream object for streaming requested messages */ this._messageStream = false; - + + /** + * Literal handler + */ + this._literalHandler = false; + /** * Personal mailbox root */ - this._mailboxRoot = ""; - + this._rootPath = ""; + /** * Delimiter for mailbox hierarchy */ this._mailboxDelimiter = "/"; - + /** * Default INBOX name */ this._inboxName = "INBOX"; - + /** * Default Sent folder name */ this._outgoingName = this.options.outgoingName || ""; - + /** * Active mailbox list */ - this._mailboxList = {}; - + this._mailboxList = []; + + /** + * Is CONDSTORE enabled or not + */ + this._condstoreEnabled = false; + /** * Ignore all incoming data while in TLS negotiations */ this._ignoreData = false; - + + /** + * Keep IMAP log for error trace + */ + this._log = []; + + /** + * IMAP log length in lines + */ + this._logLength = 10; + + /** + * Root mailbox + */ + this._rootMailbox = new Mailbox({client: this}); + /** * Lineparser object to feed the incoming data to */ this.lineparser = new IMAPLineParser(); - + /** * Initially send the incoming data to greeting handler */ this._currentHandler = this._handlerGreeting; - + this.lineparser.on("line", this._onServerResponse.bind(this)); + this.lineparser.on("log", this._onServerLog.bind(this, "S")); }; /** @@ -304,14 +374,14 @@ IMAPClient.prototype._init = function(){ IMAPClient.prototype.connect = function(){ if(this.options.secureConnection){ - this._connection = tls.connect(this.port, this.host, {}, this._onConnect.bind(this)); + this._connection = tls.connect(this.port, this.host, {rejectUnauthorized: false}, this._onConnect.bind(this)); }else{ this._connection = net.connect(this.port, this.host); this._connection.on("connect", this._onConnect.bind(this)); } - + this._connection.on("error", this._onError.bind(this)); - + this._greetingTimeout = setTimeout(this._handleGreetingTimeout.bind(this), this.GREETING_TIMEOUT); }; @@ -319,21 +389,25 @@ IMAPClient.prototype.connect = function(){ /** * 'connect' event for the connection to the server. Setup other events when connected - * + * * @event */ IMAPClient.prototype._onConnect = function(){ + if(this.debug){ + console.log("Connection to server opened"); + } + if("setKeepAlive" in this._connection){ this._connection.setKeepAlive(true); }else if(this._connection.socket && "setKeepAlive" in this._connection.socket){ this._connection.socket.setKeepAlive(true); // secure connection } - + this._connection.on("data", this._onData.bind(this)); this._connection.on("close", this._onClose.bind(this)); this._connection.on("end", this._onEnd.bind(this)); - + }; /** @@ -341,7 +415,7 @@ IMAPClient.prototype._onConnect = function(){ * and if in COMMAND mode pass the line to the line parser and when in DATA * mode, pass it as a literal or stream if needed. If there's a remainder left from * the end of the line, rerun the function with it - * + * * @event * @param {Buffer} chunk incoming binary data chunk */ @@ -350,76 +424,71 @@ IMAPClient.prototype._onData = function(chunk){ // TLS negotiations going on, ignore everything received return; } - + var data = chunk && chunk.toString("binary") || "", line, match; - + if(this._remainder){ data = this._remainder + data; this._remainder = ""; } - + if(this._currentMode == this.modes.DATA){ if(this._expectedDataLength <= data.length){ - + if(this._expectedDataLength){ - + if(!this._literalStreaming){ this.lineparser.writeLiteral(data.substr(0, this._expectedDataLength)); }else{ this._messageStream.emit("data", new Buffer(data.substr(0, this._expectedDataLength), "binary")); } - + this._remainder = data.substr(this._expectedDataLength); this._expectedDataLength = 0; }else{ this._remainder = data; } - - if(this._literalStreaming){ - this._messageStream.emit("end"); - this._messageStream.removeAllListeners(); - } - + this._currentMode = this.modes.COMMAND; - + return this._onData(); // rerun with the remainder }else{ - + if(!this._literalStreaming){ this.lineparser.writeLiteral(data); }else{ this._messageStream.emit("data", new Buffer(data, "binary")); } - + this._expectedDataLength -= data.length; return; } } - + if(this._currentMode == this.modes.COMMAND){ if((match = data.match(/\r?\n/))){ // find the line ending line = data.substr(0, match.index); this._remainder = data.substr(match.index + match[0].length) || ""; - + if(this.debug){ console.log("SERVER: "+line); } - + // check if the line ends with a literal notion if((match = line.match(/\{(\d+)\}\s*$/))){ this._expectedDataLength = Number(match[1]); this.lineparser.write(line); - + this._currentMode = this.modes.DATA; - + if(this._literalStreaming){ this.lineparser.writeLiteral(""); // create empty literal object } }else{ this.lineparser.end(line); } - + if(this._remainder){ return this._onData(); // rerun with the remainder } @@ -437,6 +506,8 @@ IMAPClient.prototype._onClose = function(){ if(this.debug){ console.log("EVENT: CLOSE"); } + + this._close(); }; /** @@ -445,10 +516,12 @@ IMAPClient.prototype._onClose = function(){ */ IMAPClient.prototype._onEnd = function(){ this.emit("end"); - + if(this.debug){ console.log("EVENT: END"); } + + this._close(); }; /** @@ -466,34 +539,51 @@ IMAPClient.prototype._onError = function(error){ * When the input command has been parsed pass it to the current command handler. * Basically there's just two - the initial greeting handler and universal * response router - * + * * @param {Array} data Parsed command, split into parameters */ IMAPClient.prototype._onServerResponse = function(data){ this._currentHandler(data); }; +/* + * Log IMAP commands into ._log array + * + * @param {String} data IMAP command line + */ +IMAPClient.prototype._onServerLog = function(type, data){ + this._log.unshift((type?type + ": " :"") + (data || "").toString().trim()); + if(this._log.length > this._logLength){ + this._log.pop(); + } +}; + /** * Run as the handler for the initial command coming from the server. If it * is a greeting with status OK, enter PREAUTH state and run CAPABILITY * command - * + * * @param {Array} data Parsed command */ IMAPClient.prototype._handlerGreeting = function(data){ clearTimeout(this._greetingTimeout); - + if(!data || !Array.isArray(data)){ throw new Error("Invalid input"); } if(data[0] != "*" && data[1] != "OK"){ - return this.emit("error", "Bad greeting from the server"); + var error = new Error("Bad greeting from the server"); + error.errorType = "ProtocolError"; + error.errorLog = this._log.slice(0, this._logLength); + this.emit("error", error); + this._close(); + return; } - + this._currentState = this.states.PREAUTH; this._currentHandler = this._responseRouter; - + this._send("CAPABILITY", this._handlerTaggedCapability.bind(this)); }; @@ -502,41 +592,52 @@ IMAPClient.prototype._handlerGreeting = function(data){ * emit an error and close the socket */ IMAPClient.prototype._handleGreetingTimeout = function(){ - this.emit("error", "Timeout waiting for a greeting"); - this.close(); + var error = new Error("Timeout waiting for a greeting"); + error.errorType = "TimeoutError"; + this.emit("error", error); + this._close(); }; /** * Checks the command data and routes it to the according handler - * + * * @param {Array} data Parsed command */ IMAPClient.prototype._responseRouter = function(data){ if(!data || !Array.isArray(data)){ return; } - + // Handle tagged commands if(this._currentRequest && this._currentRequest.tag == data[0]){ this._currentRequest.callback(data[1], data.slice(2)); return; } - + // handle commands tagged with + if(data[0]=="+"){ if(this._idleWait){ this._handlerUntaggedIdle(); } + + if(this._literalHandler){ + this._literalHandler(data.slice(1)); + } + }else if(this._literalHandler){ + this._literalHandler = null; } - + // handle untagged commands (tagged with *) if(data[0]=="*"){ switch(data[1]){ case "CAPABILITY": this._handlerUntaggedCapability(data.slice(2)); return; - case "NAMESPACE": - this._handlerUntaggedNamespace(data.slice(2)); + case "ID": + this._handlerUntaggedId(data.slice(2)); + return; + case "ENABLED": + this._handlerUntaggedEnabled(data.slice(2)); return; case "FLAGS": this._selectedMailbox.flags = data[2] || []; @@ -548,6 +649,9 @@ IMAPClient.prototype._responseRouter = function(data){ case "LIST": this._handlerUntaggedList(data.slice(2)); return; + case "LSUB": + this._handlerUntaggedLsub(data.slice(2)); + return; case "OK": if(typeof data[2] == "object"){ if(Array.isArray(data[2].params)){ @@ -557,6 +661,9 @@ IMAPClient.prototype._responseRouter = function(data){ }else if(data[2].params[0] == "UIDNEXT"){ this._selectedMailbox.UIDNext = data[2].params[1]; return; + }else if(data[2].params[0] == "HIGHESTMODSEQ"){ + this._selectedMailbox.highestModSeq = Number(data[2].params[1]); + return; }else if(data[2].params[0] == "UNSEEN"){ this._selectedMailbox.unseen = data[2].params[1]; return; @@ -568,28 +675,28 @@ IMAPClient.prototype._responseRouter = function(data){ } return; } - + if(!isNaN(data[1]) && data[2] == "FETCH"){ this._handlerUntaggedFetch(data); return; } - + if(!isNaN(data[1]) && data[2] == "EXPUNGE"){ if(this._selectedMailbox.count){ this._selectedMailbox.count--; } } - + if(!isNaN(data[1]) && data[2] == "EXISTS"){ if(this._selectedMailbox.count != Number(data[1])){ this._selectedMailbox.count = Number(data[1]) || this._selectedMailbox.count || 0; - if(this.idling){ + if(this.idling || this.nooping){ this._checkNewMail(); } - } + } return; } - + } }; @@ -597,7 +704,7 @@ IMAPClient.prototype._responseRouter = function(data){ /** * Prepend a tag for a command and put into command queue - * + * * @param {String} data Command to be sent to the server * @param {Function} [callback] Callback function to run when the command is completed * @param {Function} [prewrite] Function to run before the command is sent @@ -605,7 +712,7 @@ IMAPClient.prototype._responseRouter = function(data){ IMAPClient.prototype._send = function(data, callback, prewrite){ data = (data || "").toString(); var tag = "A" + (++this._tagCounter); - + this._commandQueue.push({tag: tag, data: tag + " " + data + "\r\n", callback: callback, prewrite: prewrite}); if(this.idling || !this._currentRequest){ @@ -617,8 +724,8 @@ IMAPClient.prototype._send = function(data, callback, prewrite){ * Send a command form the command queue to the server */ IMAPClient.prototype._processCommandQueue = function(){ - - if(!this._commandQueue.length){ + + if(!this._commandQueue.length || !this._connection){ return; } @@ -630,6 +737,7 @@ IMAPClient.prototype._processCommandQueue = function(){ if(this.debug){ console.log("CLIENT: DONE"); } + this._onServerLog("C", "DONE"); this._connection.write("DONE\r\n"); this._idleEnd = true; } @@ -638,27 +746,28 @@ IMAPClient.prototype._processCommandQueue = function(){ } var command = this._commandQueue.shift(); - + if(typeof command.prewrite == "function"){ command.prewrite(); } - + + this._onServerLog("C", command.data); this._connection.write(command.data); - + if(this.debug){ console.log("CLIENT: "+ (command.data || "").trim()); } - + this._currentRequest = { tag: command.tag, callback: (function(status, params){ - + clearTimeout(this._shouldIdleTimer); clearTimeout(this._idleTimer); if(!this.idling && !this._idleWait && this._currentState == this.states.SELECTED){ this._shouldIdleTimer = setTimeout(this.idle.bind(this), this.ENTER_IDLE); } - + if(typeof command.callback == "function"){ command.callback(status, params); } @@ -674,7 +783,7 @@ IMAPClient.prototype._processCommandQueue = function(){ /** * Handle tagged CAPABILITY. If in plaintext mode and STARTTLS is advertised, * run STARTTLS, otherwise report success to _postCapability() - * + * * @param {String} status If "OK" then the command succeeded */ IMAPClient.prototype._handlerTaggedCapability = function(status){ @@ -683,18 +792,21 @@ IMAPClient.prototype._handlerTaggedCapability = function(status){ this._send("STARTTLS", this._handlerTaggedStartTLS.bind(this)); return; } - + this._postCapability(); }else{ - this.emit("error", new Error("Invalid capability response")); - this.close(); + var error = new Error("Invalid capability response"); + error.errorType = "ProtocolError"; + error.errorLog = this._log.slice(0, this._logLength); + this.emit("error", error); + this._close(); } }; /** * Handle tagged STARTTLS. If status is OK perform a TLS handshake and rerun * CAPABILITY on success. - * + * * @param {String} status If "OK" then the command succeeded */ IMAPClient.prototype._handlerTaggedStartTLS = function(status){ @@ -706,28 +818,33 @@ IMAPClient.prototype._handlerTaggedStartTLS = function(status){ this._ignoreData = false; this._secureMode = true; this._connection.on("data", this._onData.bind(this)); - + if("setKeepAlive" in this._connection){ this._connection.setKeepAlive(true); }else if(this._connection.socket && "setKeepAlive" in this._connection.socket){ this._connection.socket.setKeepAlive(true); // secure connection } - + this._send("CAPABILITY", this._handlerTaggedCapability.bind(this)); }).bind(this)); }else{ - this.emit("error", new Error("Invalid starttls response")); - this.close(); + var error = new Error("Invalid starttls response"); + error.errorType = "TLSError"; + error.errorLog = this._log.slice(0, this._logLength); + this.emit("error", error); + this._close(); } }; /** * Handle LOGIN response. If status is OK, consider the user logged in. - * + * * @param {String} status If "OK" then the command succeeded */ IMAPClient.prototype._handlerTaggedLogin = function(status){ if(status == "OK"){ + this._xoauth2RetryCount = 0; + this._xoauth2UntaggedResponse = false; this._currentState = this.states.AUTH; if(!this._updatedCapabilities){ this._send("CAPABILITY", this._handlerTaggedCapability.bind(this)); @@ -735,27 +852,140 @@ IMAPClient.prototype._handlerTaggedLogin = function(status){ this._postAuth(); } }else{ - this.emit("error", new Error("Authentication failed")); - this.close(); + if(this._xoauth2 && this._xoauth2UntaggedResponse && this._xoauth2RetryCount && this._xoauth2RetryCount < 3){ + this._xoauth2.generateToken((function(err){ + var error; + if(err){ + if(typeof err != "object"){ + error = new Error(err.toString()); + }else{ + error = err; + } + error.errorType = "XOAUTH2Error"; + error.errorLog = this._log.slice(0, this._logLength); + this.emit("error", error); + return; + } + this._postCapability(); + }).bind(this)); + }else{ + var error = new Error("Authentication failed"); + error.errorType = "AuthenticationError"; + error.errorLog = this._log.slice(0, this._logLength); + this.emit("error", error); + this._close(); + } } }; /** - * Handle NAMESPACE command. We don't reaaly care if the NAMESPACE succeeded or + * Handle ID command. We don't reaaly care if the ID succeeded or + * not as it is just some informational data. If it failed we still might be + * able to access the mailbox + */ +IMAPClient.prototype._handlerTaggedId = function(){ + this._postReady(); +}; + +/** + * Handle CONDSTORE command. We don't reaaly care if the CONDSTORE succeeded or * not as it is just some informational data. If it failed we still might be * able to access the mailbox - * + */ +IMAPClient.prototype._handlerTaggedCondstore = function(){ + var clientData = {}; + + if(this.options.clientId){ + Object.keys(this.options.clientId).forEach((function(key){ + clientData[key] = this.options.clientId[key]; + }).bind(this)); + } + + clientData = Object.keys(clientData).map((function(key){ + return this._escapeString(key) + " " + this._escapeString(clientData[key]); + }).bind(this)).join(" "); + + if(this._capabilities.indexOf("ID")>=0){ + if(clientData.length){ + this._send("ID (" + clientData + ")", this._handlerTaggedId.bind(this)); + }else{ + this._send("ID NIL", this._handlerTaggedId.bind(this)); + } + }else{ + this._postReady(); + } +}; + +/** + * Handle mailbox listing with LSUB + * * @param {String} status If "OK" then the command succeeded */ -IMAPClient.prototype._handlerTaggedNamespace = function(status){ - this.fetchMailboxList(this._postReady.bind(this)); +IMAPClient.prototype._handlerTaggedLsub = function(xinfo, callback, status){ + if(status != "OK"){ + if(typeof callback == "function"){ + callback(new Error("Mailbox listing failed")); + } + return; + } + + var curXinfo, curName; + + for(var i=0, len = xinfo.length; i=0){ + curName = "INBOX"; + } + + for(var j=0, jlen = this._mailboxList.length; j=0){ + this._mailboxList[j].disabled = true; + } + + if(curXinfo.tags.indexOf("\\Inbox")>=0){ + this._mailboxList[j].type = "Inbox"; + }else if(curXinfo.tags.indexOf("\\All")>=0 || curXinfo.tags.indexOf("\\AllMail")>=0){ + this._mailboxList[j].type = "All Mail"; + }else if(curXinfo.tags.indexOf("\\Archive")>=0){ + this._mailboxList[j].type = "Archive"; + }else if(curXinfo.tags.indexOf("\\Drafts")>=0){ + this._mailboxList[j].type = "Drafts"; + }else if(curXinfo.tags.indexOf("\\Sent")>=0){ + this._mailboxList[j].type = "Sent"; + }else if(curXinfo.tags.indexOf("\\Junk")>=0 || curXinfo.tags.indexOf("\\Spam")>=0){ + this._mailboxList[j].type = "Junk"; + }else if(curXinfo.tags.indexOf("\\Flagged")>=0 || curXinfo.tags.indexOf("\\Starred")>=0){ + this._mailboxList[j].type = "Flagged"; + }else if(curXinfo.tags.indexOf("\\Important")>=0){ + this._mailboxList[j].type = "Important"; + }else if(curXinfo.tags.indexOf("\\Trash")>=0){ + this._mailboxList[j].type = "Trash"; + } + + break; + } + } + } + + if(typeof callback == "function"){ + callback(null, this._mailboxList); + } }; /** * Handle SELECT and EXAMINE commands. If succeeded, move to SELECTED state. * If callback is set runs it with selected mailbox data - * - * + * + * * @param {Function} callback Callback function to run on completion * @param {String} status If "OK" then the command succeeded * @params {Array} params Parsed params excluding tag and SELECT @@ -763,7 +993,7 @@ IMAPClient.prototype._handlerTaggedNamespace = function(status){ IMAPClient.prototype._handlerTaggedSelect = function(callback, status, params){ if(status == "OK"){ this._currentState = this.states.SELECTED; - + if(Array.isArray(params) && params[0] && params[0].params){ if(params[0].params[0] == "READ-WRITE"){ this._selectedMailbox.readOnly = false; @@ -782,146 +1012,111 @@ IMAPClient.prototype._handlerTaggedSelect = function(callback, status, params){ this.emit("mailbox", this._selectedMailbox); } }else{ + var error = new Error("Mailbox select failed"); + error.errorType = "MailboxError"; + error.errorLog = this._log.slice(0, this._logLength); if(typeof callback == "function"){ - callback(null, new Error("Mailbox select failed")); + callback(error); }else{ - this.emit("error", new Error("Mailbox select failed")); + this.emit("error", error); } } }; + +// HANDLERS FOR UNTAGGED RESPONSES + /** - * Handle LIST % command. If any of the mailboxes has children, check these as well - * - * @param {Function} callback Callback function to run on completion - * @param {String} status If "OK" then the command succeeded + * Handle untagged CAPABILITY response, store params to _capabilities array + * + * @param {Array} list Params for "* CAPABILITY" as an array */ -IMAPClient.prototype._handlerTaggedListRoot = function(callback, status){ - var command = "LIST", mailboxes, i, len; - - if(this._capabilities.indexOf("XLIST")){ - command = "XLIST"; - } - - if(status == "OK"){ - // check if child boxes available - mailboxes = Object.keys(this._mailboxList); - for(i=0, len = mailboxes.length; i1){ - if(mailboxList[nameParts[0]] && mailboxList[nameParts[0]].childNodes){ - mailboxList = mailboxList[nameParts[0]].childNodes; - name = nameParts.slice(1).join(delimiter); - }else{ - return; - } - }else{ - name = fullname; - } - - mailbox = { - name: this._mailboxRoot + fullname - }; - + path = (list.shift() || "").substr(this._rootPath.length), + name = delimiter?path.split(delimiter).pop():path, + mailbox = new Mailbox({ + client: this, + path: path, + name: this._convertFromUTF7(name), + delimiter: delimiter + }); + if(tags.indexOf("\\HasChildren")>=0){ - mailbox.childNodes = {}; - } - - if(tags.indexOf("\\Inbox")>=0){ - mailbox.name = "INBOX"; - name = "INBOX"; - mailbox.inbox = true; - this._inboxName = "INBOX"; - } - - if(tags.indexOf("\\Sent")>=0){ - mailbox.sent = true; - this._outgoingName = this._outgoingName || fullname; // prefer previous + mailbox.hasChildren = true; } - - if(tags.indexOf("\\Noselect")>=0){ - mailbox.disabled = true; + + if(name == "INBOX"){ + mailbox.type="Inbox"; } - - mailboxList[name] = mailbox; + + this._mailboxList.push(mailbox); }; /** @@ -930,48 +1125,55 @@ IMAPClient.prototype._handlerUntaggedList = function(list){ IMAPClient.prototype._handlerUntaggedIdle = function(){ this._idleWait = false; this.idling = true; + if(this._shouldCheckOnIdle){ + this._shouldCheckOnIdle = false; + this._checkNewMail(); + } this._processCommandQueue(); }; -/** - * Handle search responses, not yet implemented - * TODO: andle search responses - */ -IMAPClient.prototype._handlerUntaggedSearch = function(list){ - //console.log(list); -}; - /** * Handle untagged FETCH responses, these have data about individual messages. - * + * * @param {Array} list Params about a message */ IMAPClient.prototype._handlerUntaggedFetch = function(list){ - var envelope = list[1] || [], + var envelope = (list || [])[3] || [], + envelopeData = this._formatEnvelope(envelope), nextUID = Number(this._selectedMailbox.UIDNext) || 0, - currentUID = Number(envelope[1]) || 0, - envelopeData = this._formatEnvelope((list || [])[3]); - + currentUID = Number(envelopeData.UID) || 0; + if(!nextUID || nextUID <= currentUID){ - this._selectedMailbox.UIDNext = currentUID+1; + this._selectedMailbox.UIDNext = currentUID + 1; } - + if(this._collectMailList){ this._mailList.push(envelopeData); } - + // emit as new message - if(this._checkForNewMail){ + if(nextUID && nextUID <= currentUID && this._checkForNewMail){ this.emit("new", envelopeData); } }; /** - * Timeout function for idle mode - if sufficient time has passed, break the - * idle and run NOOP. After this, re-enter IDLE + * Handle untagged SERACH responses, this is a list of seq or uid values + * + * @param {Array} list Params about a message */ -IMAPClient.prototype._idleTimeout = function(){ - this._send("NOOP", this.idle.bind(this)); +IMAPClient.prototype._handlerUntaggedSearch = function(list){ + if(this._collectMailList){ + this._mailList = this._mailList.concat(list.map(Number)); + } +}; + +/** + * Timeout function for idle mode - if sufficient time has passed, break the + * idle and run NOOP. After this, re-enter IDLE + */ +IMAPClient.prototype._idleTimeout = function(){ + this._send("NOOP", this.idle.bind(this)); }; // STATE RELATED HANDLERS @@ -984,7 +1186,51 @@ IMAPClient.prototype._postCapability = function(){ if(this._currentState == this.states.PREAUTH){ this._updatedCapabilities = false; - if(this._capabilities.indexOf("AUTH=XOAUTH")>=0 && this.options.auth.XOAuthToken){ + if(this._capabilities.indexOf("AUTH=XOAUTH2")>=0 && this._xoauth2){ + this._xoauth2.getToken((function(err, token){ + var error; + + if(err){ + if(typeof err != "object"){ + error = new Error(err.toString()); + }else{ + error = err; + } + error.errorType = "AuthenticationError"; + error.errorLog = this._log.slice(0, this._logLength); + this.emit("error", error); + return; + } + this._send("AUTHENTICATE XOAUTH2 " + token, + this._handlerTaggedLogin.bind(this), (function(){ + this._xoauth2UntaggedResponse = false; + this._literalHandler = (function(message){ + + this._xoauth2UntaggedResponse = true; + + message = (Array.isArray(message) && message[0] || message || "").toString().trim(); + var data; + try{ + data = JSON.parse(new Buffer(message, "base64").toString("utf-8")); + }catch(E){ + data = { + status: 500, + error: E.message + }; + } + + if(['400', '401'].indexOf((data.status || "").toString().trim())>=0){ + this._xoauth2RetryCount = (this._xoauth2RetryCount || 0) + 1; + } + + this._connection.write("\r\n"); + if(this.debug){ + console.log("CLIENT:"); + } + }).bind(this); + }).bind(this)); + }).bind(this)); + }else if(this._capabilities.indexOf("AUTH=XOAUTH")>=0 && this.options.auth.XOAuthToken){ if(typeof this.options.auth.XOAuthToken == "object"){ this._send("AUTHENTICATE XOAUTH " + this.options.auth.XOAuthToken.generate(), this._handlerTaggedLogin.bind(this)); @@ -1004,21 +1250,20 @@ IMAPClient.prototype._postCapability = function(){ }; /** - * Run when user is successfully entered AUTH state. If NAMESPACE capability - * is detected, run it, otherwise fetch the mailbox list. + * Run when user is successfully entered AUTH state */ IMAPClient.prototype._postAuth = function(){ - if(this._capabilities.indexOf("NAMESPACE")>=0){ - this._send("NAMESPACE", this._handlerTaggedNamespace.bind(this)); + if(this._capabilities.indexOf("CONDSTORE")>=0){ + this._send("ENABLE CONDSTORE", this._handlerTaggedCondstore.bind(this)); }else{ - this.fetchMailboxList(this._postReady.bind(this)); + this._handlerTaggedCondstore("OK"); } }; /** * Run it when all the required jobs for setting up an authorized connection * are completed. Emit 'connect' event. - * + * * @param {Object} err Error object, if an error appeared */ IMAPClient.prototype._postReady = function(err){ @@ -1029,69 +1274,11 @@ IMAPClient.prototype._postReady = function(err){ } }; -/** - * Run after LIST command is completed. Sort the mailbox array and return it - * with callback - * - * @param {Function} callback Callback function to run after LIST data is gathered - */ -IMAPClient.prototype._postFetchList = function(callback){ - var keys = Object.keys(this._mailboxList), - i, len, - sortedList = {}; - - if(!this._outgoingName){ - for(i=0, len = keys.length; i 0){ // ignore the first char + return label.split(this._mailboxDelimiter).map(function(localLabel){ + var localType; + if((localType = detectMailboxType(localLabel)) != "Normal"){ + messageTypes.push(localType); + return localLabel; + }else{ + return localLabel; + } + }); + } + + // Convert name to canonized version + if((type = detectMailboxType(label)) != "Normal"){ + // Add flag indicator + messageTypes.push(type); + return type; + } + + // Convert tags to names + if(label.charAt(0)=="\\" && label != "\\Important"){ + label = label.substr(1); + messageTypes.push(label); + } + + return label; + }).bind(this)); + }else{ + message.folders = (this._selectedMailbox.path || "").split(this._mailboxDelimiter) || []; + if(message.folders.length > 1){ + message.folders = [message.folders]; + }else if(message.folders.length){ + message.folders = [message.folders[0]]; + } + + if(message.folders.length){ + [].concat(message.folders[0]).forEach((function(localLabel){ + var type; + if((type = detectMailboxType(localLabel)) != "Normal"){ + // Add flag indicator + messageTypes.push(type); + } + }).bind(this)); + } + } + + // remove duplicates + if(message.folders){ + var folderList = []; + for(i=0, len=message.folders.length; i=0 && messageTypes[0] || "Normal"; + if(message.type == "Normal" && message.flags && message.flags.indexOf("\\Flagged")>=0){ + message.type = "Starred"; + } + if(dataObject.ENVELOPE){ message.date = new Date(dataObject.ENVELOPE[0] || Date.now()); - + message.title = (dataObject.ENVELOPE[1] || "").toString(). - replace(/\=\?[^?]+\?[QqBb]\?[^?]+\?=/g, + replace(/\=\?[^?]+\?[QqBb]\?[^?]+\?=/g, function(mimeWord){ return mimelib.decodeMimeWord(mimeWord); }); + if(dataObject.ENVELOPE[2] && dataObject.ENVELOPE[2].length){ message.from = dataObject.ENVELOPE[2].map(this._formatEnvelopeAddress); if(message.from.length == 1){ message.from = message.from[0]; } } - + if(dataObject.ENVELOPE[5] && dataObject.ENVELOPE[5].length){ message.to = dataObject.ENVELOPE[5].map(this._formatEnvelopeAddress); } if(dataObject.ENVELOPE[6] && dataObject.ENVELOPE[6].length){ message.cc = dataObject.ENVELOPE[6].map(this._formatEnvelopeAddress); } - message.messageId = (dataObject.ENVELOPE[9] || "").toString(); + + if(dataObject.ENVELOPE[8] && dataObject.ENVELOPE[8].length){ + message.inReplyTo = (dataObject.ENVELOPE[8] || "").toString().replace(/\s/g,""); + } + + message.messageId = (dataObject.ENVELOPE[9] || "").toString().toString().replace(/\s/g,""); } - - return message; + return message; }; /** * Formats an IMAP ENVELOPE address in simpler {name, address} format - * + * * @param {Array} address IMAP ENVELOPE address array [name, smtp route, user, domain] * @return {Object} simple {name, address} format */ IMAPClient.prototype._formatEnvelopeAddress = function(address){ var name = address[0], email = (address[2] || "") + "@" + (address[3] || ""); - + if(email == "@"){ email = ""; } - + return { - name: (name || email).replace(/\=\?[^?]+\?[QqBb]\?[^?]+\?=/g, + name: (name || email).replace(/\=\?[^?]+\?[QqBb]\?[^?]+\?=/g, function(mimeWord){ return mimelib.decodeMimeWord(mimeWord); }), @@ -1184,6 +1505,155 @@ IMAPClient.prototype._formatEnvelopeAddress = function(address){ }; }; +/** + * Parses bodystructure of an IMAP message according to http://tools.ietf.org/html/rfc3501#section-7.4.2 + * parsed bodystructures will look something like this + * { + * 'type': 'multipart/mixed', + * 1: { + * 'type': 'multipart/alternative', + * 1: { + * 'type': 'text/plain', + * params: { + * 'charset': 'utf-8' + * }, + * encoding: '7bit', + * size: 50, + * lines: 10, + * }, + * 2: { + * 'type': 'text/html', + * params: { + * 'charset': 'utf-8' + * }, + * encoding: '7bit', + * size: 158, + * } + * }, + * 2: { + * 'type': 'application/octet-stream', + * params: { + * 'name': 'foobar.md' + * }, + * encoding: 'base64', + * size: 286, + * disposition: [{ + * 'attachment': { + * 'filename': 'foobar.md' + * } + * }] + * } + * } + * + * @param {Array} bs Array containing the raw bodystructure array + * @return {Object} Parsed bodystructure, see comment below + */ +IMAPClient.prototype._parseBodystructure = function(bs, parentBodypart) { + var self = this; + + if (typeof bs === 'string') { + // type information for multipart/alternative or multipart/mixed + return 'multipart/' + bs.toLowerCase(); + } + + if (!Array.isArray(bs)) { + // if it is not the information on which type of multipart/* + // we've got here, or an array containing valuable information, + // it is just imap noise + return; + } + + if (!Array.isArray(bs[0]) && typeof bs[0] === 'string' && bs.length >= 10) { + // we've got a single part, usually a text/plain, text/html or attachment part + var dispositionIndex = 8, + currentPart = {}, type, subtype; + + currentPart.part = parentBodypart || '1'; + type = bs[0].toLowerCase(); + subtype = bs[1].toLowerCase(); + currentPart.type = type + '/' + subtype; + currentPart.parameters = {}; + if (bs[2]) { + // the parameters are a key/value list + var parametersIndex = 0; + while(parametersIndex < bs[2].length) { + currentPart.parameters[bs[2][parametersIndex].toLowerCase()] = bs[2][parametersIndex + 1].toLowerCase(); + parametersIndex += 2; + } + } + currentPart.encoding = bs[5].toLowerCase(); + currentPart.size = parseInt(bs[6], 10); + + if (type === 'message' && subtype === 'rfc822') { + // parsing of envelope and body structure information for message/rfc882 mails is not supported, + // because there are IMAP servers which violate rfc 3501 for message/rfc882, for example gmail. + return currentPart; + } + + if (type === 'text') { + // text/* body parts have an additional field for the body size in lines in its content transfer encoding. + currentPart.lines = parseInt(bs[7], 10); + dispositionIndex = 9; + } + + if (bs[dispositionIndex]) { + currentPart.disposition = []; + if (Array.isArray(bs[dispositionIndex][0])) { + bs[dispositionIndex].forEach(function(rawAttachment){ + if (!rawAttachment) { + return; + } + + currentPart.disposition.push(parseAttachment(rawAttachment)); + }); + } else { + currentPart.disposition.push(parseAttachment(bs[dispositionIndex])); + } + } + + return currentPart; + } + + if (Array.isArray(bs[0])) { + // we have got a multipart/* message + var bodypartsCounter = 1, parsedBodystructure = {}, + parsedPart, partIdentifier; + + bs.forEach(function(rawPart) { + partIdentifier = (parentBodypart ? (parentBodypart + '.') : '') + bodypartsCounter; + parsedPart = self._parseBodystructure(rawPart, partIdentifier); + if (typeof parsedPart === 'string') { + parsedBodystructure.type = parsedPart; + } else if (typeof parsedPart === 'object') { + parsedBodystructure[bodypartsCounter] = parsedPart; + bodypartsCounter++; + } + }); + + return parsedBodystructure; + } + + // helper function to parse attachments + function parseAttachment(rawAttachment) { + var parsedAttachment = {}; + + parsedAttachment.type = rawAttachment[0].toLowerCase(); + if (rawAttachment[1]) { + // attachment filename, not present in inline attachments + parsedAttachment[rawAttachment[1][0].toLowerCase()] = rawAttachment[1][1]; + } + + return parsedAttachment; + } +}; + +/** + * Convert from IMAP UTF7 to UTF-8 - useful for mailbox names + */ +IMAPClient.prototype._convertFromUTF7 = function(str){ + return utf7.decode((str || "").toString()); +}; + /** * Check for new mail, since the last known UID */ @@ -1191,76 +1661,71 @@ IMAPClient.prototype._checkNewMail = function(){ if(isNaN(this._selectedMailbox.UIDNext)){ return; } - - this._send("UID FETCH "+this._selectedMailbox.UIDNext+":* (FLAGS ENVELOPE)", (function(){ + + this._send("UID FETCH " + this._selectedMailbox.UIDNext + ":* (UID BODYSTRUCTURE FLAGS ENVELOPE INTERNALDATE" + + (this._capabilities.indexOf("X-GM-EXT-1") >= 0 ? " X-GM-LABELS X-GM-THRID" : "") + + (this._capabilities.indexOf("CONDSTORE") >= 0 ? " MODSEQ" : "") + + ")", (function(){ this._checkForNewMail = false; - }).bind(this), + }).bind(this), (function(){ - this._checkForNewMail = true; + this._checkForNewMail = true; }).bind(this)); }; - // PUBLIC API /** - * Fetches a structured list of mailboxes - * + * Lists root mailboxes + * * @param {Function} callback Callback function to run with the mailbox list */ -IMAPClient.prototype.fetchMailboxList = function(callback){ - var command = "LIST"; - if(this._capabilities.indexOf("XLIST")>=0){ - command = "XLIST"; - } - - this._mailboxList = {}; - this._send(command+" "+this._escapeString(this._mailboxRoot)+" %", this._handlerTaggedListRoot.bind(this, callback)); -}; - -/** - * Returns the cached mailbox list - * - * @return {Array} Mailbox list - */ -IMAPClient.prototype.getMailboxList = function(){ - return this._mailboxList; +IMAPClient.prototype.listMailboxes = function(path, all, callback){ + this._rootMailbox.listChildren(path, all, callback); }; /** * Opens a selected mailbox. This is needed before you can open any message. - * - * @param {String} mailboxName Mailbox name with full path, ie "INBOX/Sent Items" + * + * @param {String} path Mailbox full path, ie "INBOX/Sent Items" * @param {Object} [options] Optional options object * @param {Boolean} [options.readOnly] If set to true, open the mailbox in read-only mode (seen/unseen flags won't be touched) - * @param {Function} callback Callback function to run when the mailbox is opened + * @param {Function} callback Callback function to run when the mailbox is opened */ -IMAPClient.prototype.openMailbox = function(mailboxName, options, callback){ +IMAPClient.prototype.openMailbox = function(path, options, callback){ var command = "SELECT"; - + if(typeof options == "function" && !callback){ callback = options; options = undefined; } - + options = options || {}; - + if(options.readOnly){ command = "EXAMINE"; } - - mailboxName = mailboxName || this._inboxName || "INBOX"; - + + if(typeof path == "object"){ + path = path.path; + } + + if(!path){ + return callback(new Error("Invalid or missing mailbox path provided")); + } + this._selectedMailbox = { - name: mailboxName + path: path }; - - this._send(command + " " + this._escapeString(mailboxName), this._handlerTaggedSelect.bind(this, callback)); + + this._send(command + " " + this._escapeString(path)+( + this._condstoreEnabled?" (CONDSTORE)":"" + ), this._handlerTaggedSelect.bind(this, callback)); }; /** * Returns the current mailbox data object - * + * * @return {Object} Information about currently selected mailbox */ IMAPClient.prototype.getCurrentMailbox = function(){ @@ -1270,38 +1735,50 @@ IMAPClient.prototype.getCurrentMailbox = function(){ /** * Lists message envelopes for selected range. Negative numbers can be used to * count from the end of the list (most recent messages). - * + * * @param {Number} from List from position (0 based) * @param {Number} limit How many messages to fetch, defaults to all from selected position - * @param {Function} callback Callback function to run with the listed envelopes + * @param {String} [extendedOptions] Additional string to add to the FETCH query + * @param {Function} callback Callback function to run with the listed envelopes */ -IMAPClient.prototype.listMessages = function(from, limit, callback){ +IMAPClient.prototype.listMessages = function(from, limit, extendedOptions, callback){ var to; - + from = Number(from) || 0; - + + if(typeof extendedOptions == "function" && !callback){ + callback = extendedOptions; + extendedOptions = undefined; + } + if(typeof limit == "function" && !callback){ - callback = limit; + callback = limit; limit = undefined; } - + + extendedOptions = extendedOptions || ""; limit = Number(limit) || 0; - + if(this._currentState != this.states.SELECTED){ if(typeof callback == "function"){ callback(new Error("No mailbox selected")); } return; } - + + // Nothing to retrieve + if(!this._selectedMailbox.count){ + return callback(null, []); + } + if(from < 0){ from = this._selectedMailbox.count + from; } - + if(from < 0){ from = 0; } - + if(limit){ to = from + limit; }else{ @@ -1309,16 +1786,23 @@ IMAPClient.prototype.listMessages = function(from, limit, callback){ } from++; - + this._collectMailList = true; this._mailList = []; - this._send("FETCH "+from+":"+to+" (UID FLAGS ENVELOPE)", (function(status){ + + this._send( + "FETCH " + from + ":" + to + + " (UID BODYSTRUCTURE FLAGS ENVELOPE INTERNALDATE" + + (this._capabilities.indexOf("X-GM-EXT-1")>=0?" X-GM-LABELS X-GM-THRID":"") + + (this._capabilities.indexOf("CONDSTORE") >= 0 ? " MODSEQ" : "") + + ")" + + (extendedOptions ? " "+extendedOptions : ""), (function(status){ this._collectMailList = false; - + if(typeof callback != "function"){ return; } - + if(status == "OK"){ callback(null, this._mailList); }else{ @@ -1328,131 +1812,389 @@ IMAPClient.prototype.listMessages = function(from, limit, callback){ }; /** - * Updates flags for selected message - * - * @param {Number} uid Message identifier - * @param {Array} flags Flags to set for a message - * @param {String} [updateType=""] If empty, replace flags; + add flag; - remove flag - * @param {Function} callback Callback function to run, returns an array of flags + * Lists message envelopes for selected range. Similar to listMessages but uses UID values + * + * @param {Number} from First UID value + * @param {Number} to Last UID value or "*" + * @param {String} [extendedOptions] Additional string to add to the FETCH query + * @param {Function} callback Callback function to run with the listed envelopes */ -IMAPClient.prototype.updateFlags = function(uid, flags, updateType, callback){ - uid = Number(uid) || 0; - flags = flags || []; - - if(!callback && typeof updateType == "function"){ - callback = updateType; - updateType = undefined; - } - - updateType = (updateType || "").toString().trim(); - - if(!uid){ - if(typeof callback == "function"){ - callback(new Error("Invalid UID value")); - } - return; +IMAPClient.prototype.listMessagesByUID = function(from, to, extendedOptions, callback){ + var to; + + from = Number(from) || 0; + + if(typeof extendedOptions == "function" && !callback){ + callback = extendedOptions; + extendedOptions = undefined; } - - if(!Array.isArray(flags)){ - if(typeof callback == "function"){ - callback(new Error("Invalid flags value")); - } - return; + + if(typeof to == "function" && !callback){ + callback = to; + to = undefined; } - + + extendedOptions = extendedOptions || ""; + to = Number(to) || "*"; + if(this._currentState != this.states.SELECTED){ if(typeof callback == "function"){ callback(new Error("No mailbox selected")); } return; } - - this._send("UID STORE "+uid+":"+uid+" "+updateType+"FLAGS ("+ - flags.join(" ") - +")", (function(status){ + + // Nothing to retrieve + if(!this._selectedMailbox.count){ + return callback(null, []); + } + + this._collectMailList = true; + this._mailList = []; + + this._send( + "UID FETCH " + from + ":" + to + + " (UID BODYSTRUCTURE FLAGS ENVELOPE INTERNALDATE" + + (this._capabilities.indexOf("X-GM-EXT-1")>=0?" X-GM-LABELS X-GM-THRID":"") + + (this._capabilities.indexOf("CONDSTORE") >= 0 ? " MODSEQ" : "") + + ")" + + (extendedOptions ? " "+extendedOptions : ""), (function(status){ this._collectMailList = false; - + if(typeof callback != "function"){ return; } - - if(typeof callback == "function"){ - if(status == "OK"){ - if(!this._mailList.length){ - callback(null, true); - }else{ - callback(null, this._mailList[0].flags || []); - } - }else{ - callback(new Error("Error fetching message data")); - } + + if(status == "OK"){ + callback(null, this._mailList); + }else{ + callback(new Error("Error fetching list")); } - - }).bind(this), - (function(){ - this._collectMailList = true; - this._mailList = []; }).bind(this)); -} +}; /** - * Add flags for selected message - * - * @param {Number} uid Message identifier - * @param {Array} flags Flags to set for a message - * @param {Function} callback Callback function to run, returns an array of flags + * Lists flags for selected range. Negative numbers can be used to + * count from the end of the list (most recent messages). + * + * @param {Number} from List from position (0 based) + * @param {Number} limit How many messages to fetch, defaults to all from selected position + * @param {Function} callback Callback function to run with the listed envelopes */ -IMAPClient.prototype.addFlags = function(uid, flags, callback){ - if(typeof flags == "string"){ - flags = [flags]; - } - this.updateFlags(uid, flags, "+", callback); -} +IMAPClient.prototype.listFlags = function(from, limit, callback){ + var to; -/** - * Removes flags for selected message - * - * @param {Number} uid Message identifier - * @param {Array} flags Flags to remove from a message - * @param {Function} callback Callback function to run, returns an array of flags - */ -IMAPClient.prototype.removeFlags = function(uid, flags, callback){ - if(typeof flags == "string"){ - flags = [flags]; + from = Number(from) || 0; + + if(typeof limit == "function" && !callback){ + callback = limit; + limit = undefined; } - this.updateFlags(uid, flags, "-", callback); -} -/** - * Fetches envelope object for selected message - * - * @param {Number} uid Message identifier + limit = Number(limit) || 0; + + if(this._currentState != this.states.SELECTED){ + if(typeof callback == "function"){ + callback(new Error("No mailbox selected")); + } + return; + } + + if(from < 0){ + from = this._selectedMailbox.count + from; + } + + if(from < 0){ + from = 0; + } + + if(limit){ + to = from + limit; + }else{ + to = "*"; + } + + from++; + + this._collectMailList = true; + this._mailList = []; + this._send("FETCH " + from + ":" + to + " (UID FLAGS)", (function(status){ + this._collectMailList = false; + + if(typeof callback != "function"){ + return; + } + + if(status == "OK"){ + callback(null, this._mailList); + }else{ + callback(new Error("Error fetching list")); + } + }).bind(this)); +}; + +/** + * Updates flags for selected message + * + * @param {String} uid Message identifier + * @param {Array} flags Flags to set for a message + * @param {String} [updateType=""] If empty, replace flags; + add flag; - remove flag + * @param {Function} callback Callback function to run, returns an array of flags + */ +IMAPClient.prototype.updateFlags = function(uid, flags, updateType, callback){ + flags = [].concat(flags || []); + + if(!callback && typeof updateType == "function"){ + callback = updateType; + updateType = undefined; + } + + updateType = (updateType || "").toString().trim(); + + if(!uid){ + if(typeof callback == "function"){ + callback(new Error("Invalid UID value")); + } + return; + } + + if(!Array.isArray(flags)){ + if(typeof callback == "function"){ + callback(new Error("Invalid flags value")); + } + return; + } + + if(this._currentState != this.states.SELECTED){ + if(typeof callback == "function"){ + callback(new Error("No mailbox selected")); + } + return; + } + + this._send("UID STORE "+uid+" "+updateType+"FLAGS (" + flags.join(" ") + ")", + (function(status){ + this._collectMailList = false; + + if(typeof callback != "function"){ + return; + } + + if(typeof callback == "function"){ + if(status == "OK"){ + if(!this._mailList.length){ + callback(null, true); + }else{ + callback(null, this._mailList[0].flags || []); + } + }else{ + callback(new Error("Error setting flags")); + } + } + + }).bind(this), + (function(){ + this._collectMailList = true; + this._mailList = []; + }).bind(this)); +}; + +/** + * Add flags for selected message + * + * @param {String} uid Message identifier + * @param {Array} flags Flags to set for a message + * @param {Function} callback Callback function to run, returns an array of flags + */ +IMAPClient.prototype.addFlags = function(uid, flags, callback){ + flags = [].concat(flags || []); + this.updateFlags(uid, flags, "+", callback); +}; + +/** + * Removes flags for selected message + * + * @param {String} uid Message identifier + * @param {Array} flags Flags to remove from a message + * @param {Function} callback Callback function to run, returns an array of flags + */ +IMAPClient.prototype.removeFlags = function(uid, flags, callback){ + flags = [].concat(flags || []); + this.updateFlags(uid, flags, "-", callback); +}; + +/** + * Updates labels for selected message + * + * @param {String} uid Message identifier + * @param {Array} labels Labels to set for a message + * @param {String} [updateType=""] If empty, replace labels; + add label; - remove label + * @param {Function} callback Callback function to run, returns an array of labels + */ +IMAPClient.prototype.updateLabels = function(uid, labels, updateType, callback){ + labels = [].concat(labels || []); + + if(!callback && typeof updateType == "function"){ + callback = updateType; + updateType = undefined; + } + + updateType = (updateType || "").toString().trim(); + + if(!uid){ + if(typeof callback == "function"){ + callback(new Error("Invalid UID value")); + } + return; + } + + if(!Array.isArray(labels)){ + if(typeof callback == "function"){ + callback(new Error("Invalid labels value")); + } + return; + } + + if(this._currentState != this.states.SELECTED){ + if(typeof callback == "function"){ + callback(new Error("No mailbox selected")); + } + return; + } + + this._send("UID STORE "+uid+" "+updateType+"X-GM-LABELS (" + labels.join(" ") + ")", + (function(status){ + this._collectMailList = false; + + if(typeof callback != "function"){ + return; + } + + if(typeof callback == "function"){ + if(status == "OK"){ + if(!this._mailList.length){ + callback(null, true); + }else{ + callback(null, this._mailList[0].labels || []); + } + }else{ + callback(new Error("Error setting labels")); + } + } + + }).bind(this), + (function(){ + this._collectMailList = true; + this._mailList = []; + }).bind(this)); +}; + +/** + * Add labels for selected message + * + * @param {String} uid Message identifier + * @param {Array} labels Labels to set for a message + * @param {Function} callback Callback function to run, returns an array of labels + */ +IMAPClient.prototype.addLabels = function(uid, labels, callback){ + labels = [].concat(labels || []); + this.updateLabels(uid, labels, "+", callback); +}; + +/** + * Removes labels for selected message + * + * @param {String} uid Message identifier + * @param {Array} labels Labels to remove from a message + * @param {Function} callback Callback function to run, returns an array of labels + */ +IMAPClient.prototype.removeLabels = function(uid, labels, callback){ + labels = [].concat(labels || []); + this.updateLabels(uid, labels, "-", callback); +}; + +/** + * Fetches flags for selected message + * + * @param {Number} uid Message identifier + * @param {Function} callback Callback function to run with the flags array + */ +IMAPClient.prototype.fetchFlags = function(uid, callback){ + uid = Number(uid) || 0; + + if(!uid){ + if(typeof callback == "function"){ + callback(new Error("Invalid UID value")); + } + return; + } + + if(this._currentState != this.states.SELECTED){ + if(typeof callback == "function"){ + callback(new Error("No mailbox selected")); + } + return; + } + + this._send("UID FETCH " + uid + " (UID FLAGS)", (function(status){ + this._collectMailList = false; + + if(typeof callback != "function"){ + return; + } + + if(typeof callback == "function"){ + if(status == "OK"){ + if(!this._mailList.length){ + callback(null, null); + }else{ + callback(null, this._mailList[0].flags || []); + } + }else{ + callback(new Error("Error fetching message flags")); + } + } + + }).bind(this), + (function(){ + this._collectMailList = true; + this._mailList = []; + }).bind(this)); +}; + +/** + * Fetches envelope object for selected message + * + * @param {Number} uid Message identifier * @param {Function} callback Callback function to run with the envelope object */ IMAPClient.prototype.fetchData = function(uid, callback){ uid = Number(uid) || 0; - + if(!uid){ if(typeof callback == "function"){ callback(new Error("Invalid UID value")); } return; } - + if(this._currentState != this.states.SELECTED){ if(typeof callback == "function"){ callback(new Error("No mailbox selected")); } return; } - - this._send("UID FETCH "+uid+":"+uid+" (FLAGS ENVELOPE)", (function(status){ + + this._send("UID FETCH " + uid + " (UID FLAGS ENVELOPE" + + (this._capabilities.indexOf("X-GM-EXT-1")>=0?" X-GM-LABELS X-GM-THRID":"") + + (this._capabilities.indexOf("CONDSTORE") >= 0 ? " MODSEQ" : "") + + ")", (function(status){ this._collectMailList = false; - + if(typeof callback != "function"){ return; } - + if(typeof callback == "function"){ if(status == "OK"){ if(!this._mailList.length){ @@ -1464,7 +2206,7 @@ IMAPClient.prototype.fetchData = function(uid, callback){ callback(new Error("Error fetching message data")); } } - + }).bind(this), (function(){ this._collectMailList = true; @@ -1474,40 +2216,41 @@ IMAPClient.prototype.fetchData = function(uid, callback){ /** * Creates a Readable Stream for a selected message. - * + * * @param {Number} uid Message identifier */ IMAPClient.prototype.createMessageStream = function(uid){ var stream = new Stream(); - + uid = Number(uid) || 0; - + if(!uid){ process.nextTick(this.emit.bind(this, new Error("Invalid UID value"))); return; } - + if(this._currentState != this.states.SELECTED){ process.nextTick(this.emit.bind(this, new Error("No inbox selected"))); return; } - - this._send("UID FETCH "+uid+":"+uid+" BODY[]", (function(status){ + + this._send("UID FETCH " + uid + " BODY.PEEK[]", (function(status){ this._collectMailList = false; this._literalStreaming = false; - - - + if(!this._mailList.length){ if(status == "OK"){ - stream.emit("error", new Error("Selected message not found")); + stream.emit("error", new Error("Selected message not found: "+uid+"; "+this.port+"; "+this.host+"; "+JSON.stringify(this._selectedMailbox))); }else{ - stream.emit("error", new Error("Error fetching message")); + stream.emit("error", new Error("Error fetching message: "+uid+"; "+this.port+"; "+this.host+"; "+JSON.stringify(this._selectedMailbox))); } } - + + this._messageStream.emit("end"); + this._messageStream.removeAllListeners(); + this._messageStream = null; - + }).bind(this), (function(){ this._collectMailList = true; @@ -1515,21 +2258,270 @@ IMAPClient.prototype.createMessageStream = function(uid){ this._mailList = []; this._messageStream = stream; }).bind(this)); - + return stream; }; +/** + * Copy message from the active mailbox to the end of destination mailbox + * + * @param {Number} uid Message identifier + * @param {String} destination Destination folder to copy the message to + * @param {Function} callback Callback function to run after the copy succeeded or failed + */ +IMAPClient.prototype.copyMessage = function(uid, destination, callback){ + uid = Number(uid) || 0; + + if(!uid){ + if(typeof callback == "function"){ + callback(new Error("Invalid UID value")); + } + return; + } + + if(this._currentState != this.states.SELECTED){ + if(typeof callback == "function"){ + callback(new Error("No mailbox selected")); + } + return; + } + + this._send("UID COPY " + uid + " " + this._escapeString(destination), (function(status){ + if(status != "OK"){ + return callback(new Error("Error copying message")); + } + return callback(null, true); + }).bind(this)); +}; + +/** + * Delete message from the active mailbox + * + * @param {Number} uid Message identifier + * @param {Function} callback Callback function to run after the removal succeeded or failed + */ +IMAPClient.prototype.deleteMessage = function(uid, callback){ + uid = Number(uid) || 0; + + if(!uid){ + if(typeof callback == "function"){ + callback(new Error("Invalid UID value")); + } + return; + } + + this.addFlags(uid, "\\Deleted", (function(error){ + if(error){ + return callback(error); + } + + this._send("EXPUNGE", (function(status){ + if(status != "OK"){ + return callback(new Error("Error removing message")); + } + return callback(null, true); + }).bind(this)); + + }).bind(this)); +}; + +/** + * Move message from the active mailbox to the end of destination mailbox + * + * @param {Number} uid Message identifier + * @param {String} destination Destination folder to move the message to + * @param {Function} callback Callback function to run after the move succeeded or failed + */ +IMAPClient.prototype.moveMessage = function(uid, destination, callback){ + this.copyMessage(uid, destination, (function(error){ + if(error){ + return callback(error); + } + this.deleteMessage(uid, function(error){ + // we don't really care if the removal succeeded or not at this point + return callback(null, !error); + }); + }).bind(this)); +}; + +/** + * Upload a message to the mailbox + * + * This totally sucks but as the length of the message need to be known + * beforehand, it is probably a good idea to include it in whole - easier + * to implement and gives as total byte count + */ +IMAPClient.prototype.storeMessage = function(message, flags, callback){ + if(typeof flags == "function" && !callback){ + callback = flags; + flags = undefined; + } + + message = message || ""; + if(typeof message == "string"){ + message = new Buffer(message, "utf-8"); + } + + flags = [].concat(flags || []); + this._send("APPEND " + this._escapeString(this._selectedMailbox.path) + (flags.length ? " (" + flags.join(" ")+")":"") + " {" + message.length+"}", (function(status, data){ + this._literalHandler = null; + if(status == "OK"){ + this._shouldCheckOnIdle = true; + + // supports APPENDUID + if(data && data[0] && data[0].params && data[0].params[0] == "APPENDUID"){ + return callback(null, { + UIDValidity: data[0].params[1] || "", + UID: data[0].params[2] || "" + }); + } + + // Guess the values from mailbox data. Not sure if it really works :S + return callback(null, { + UIDValidity: this._selectedMailbox.UIDValidity, + UID: this._selectedMailbox.UIDNext + }); + }else{ + return callback(new Error("Error saving message to mailbox")); + } + }).bind(this), (function(){ + this._literalHandler = (function(){ + this._connection.write(message); + this._connection.write("\r\n"); + }).bind(this); + }).bind(this)); +}; + +/** + * + */ +IMAPClient.prototype.getMailbox = function(path, callback){ + this._rootMailbox.listChildren(path, function(error, mailboxes){ + if(error){ + callback(error); + }else if(mailboxes && mailboxes.length){ + callback(null, mailboxes[0]); + }else{ + callback(null, null); + } + }); +}; + +/** + * Create a new mailbox + */ +IMAPClient.prototype.createMailbox = function(path, callback){ + this._rootMailbox.createChild(path, callback); +}; + +/** + * Delete a mailbox + */ +IMAPClient.prototype.deleteMailbox = function(path, callback){ + this._rootMailbox.deleteChild(path, callback); +}; + /** * Enter IDLE mode */ IMAPClient.prototype.idle = function(){ - this._send("IDLE", (function(){ - this.idling = false; - this._idleEnd = false; - }).bind(this), (function(){ - this._idleWait = true; - this._idleEnd = false; - this._idleTimer = setTimeout(this._idleTimeout.bind(this), this.IDLE_TIMEOUT); + if(this._capabilities.indexOf("IDLE")>=0){ + this._send("IDLE", (function(){ + this.idling = false; + this._idleEnd = false; + }).bind(this), (function(){ + this._idleWait = true; + this._idleEnd = false; + this._idleTimer = setTimeout(this._idleTimeout.bind(this), this.IDLE_TIMEOUT); + }).bind(this)); + }else{ + if(this.debug){ + console.log("WARNING: Server does not support IDLE, fallback to NOOP"); + } + this._idleTimer = setTimeout((function(){ + this._send("NOOP", (function(){ + this.nooping = false; + }).bind(this), (function(){ + this.nooping = true; + }).bind(this)); + }).bind(this), this.IDLE_TIMEOUT); + } +}; + +/** + * Lists seq or uid values for a search. Query is an object where keys are query terms and + * values are params. Use arrays for multiple terms or true for just the key. + * + * connection.search({new: true, header: ["subject", "test"]}, function(err, list)) + * + * @param {Object} Search query + * @param {Boolean} [isUID] If true perform an UID search + * @param {Function} callback Callback function to run with the listed envelopes + */ +IMAPClient.prototype.search = function(query, isUid, callback){ + if(!callback && typeof isUid == "function"){ + callback = isUid; + isUid = undefined; + } + + var queryType = isUid ? "UID SEARCH" : "SEARCH", + self = this, + + buildTerm = function(query){ + return Object.keys(query).map(function(key){ + var term = key.toUpperCase(), + params = [], + escapeDate = function(date){ + return self._escapeString(date.toUTCString().replace(/^\w+, (\d+) (\w+) (\d+).*/, "$1-$2-$3")); + }, + escapeParam = function(param){ + var list = []; + if(typeof param == "number"){ + list.push(String(param)); + }else if(typeof param == "string"){ + list.push(self._escapeString(param)); + }else if(Object.prototype.toString.call(param) == "[object Date]"){ + list.push(escapeDate(param)); + }else if(Array.isArray(param)){ + param.map(escapeParam).forEach(function(p){ + if(typeof p == "string"){ + list.push(p); + } + }); + }else if(typeof param == "object"){ + return buildTerm(param); + } + return list.join(" "); + }; + + [].concat(query[key] || []).forEach(function(param){ + var param = escapeParam(param); + if(param){ + params.push(param); + } + }); + + return term + (params.length ? " " + params.join(" ") : ""); + }).join(" "); + }, + + queryTerm = buildTerm(query); + + this._collectMailList = true; + this._mailList = []; + + this._send(queryType + (queryTerm ? " " + queryTerm : ""), (function(status){ + this._collectMailList = false; + + if(typeof callback != "function"){ + return; + } + + if(status == "OK"){ + callback(null, this._mailList.sort(function(a, b){return a-b;})); + }else{ + callback(new Error("Error searching messages")); + } }).bind(this)); }; @@ -1537,9 +2529,35 @@ IMAPClient.prototype.idle = function(){ * Closes the socket to the server * // FIXME - should LOGOUT first! */ -IMAPClient.prototype.close = function(){ +IMAPClient.prototype._close = function(){ + if(!this._connection){ + return; + } + + clearTimeout(this._shouldIdleTimer); + clearTimeout(this._idleTimer); + clearTimeout(this._greetingTimeout); + var socket = this._connection.socket || this._connection; + if(socket && !socket.destroyed){ socket.destroy(); } -}; \ No newline at end of file + + if(this.debug){ + console.log("Connection to server closed"); + } + + this._connection = false; + this._commandQueue = []; + this.emit("close"); + + this.removeAllListeners(); +}; + +// Calls LOGOUT +IMAPClient.prototype.close = function(){ + this._send("LOGOUT", (function (){ + this._close(); + }).bind(this)); +}; diff --git a/lib/lineparser.js b/lib/lineparser.js index b8141a6..ee6f964 100644 --- a/lib/lineparser.js +++ b/lib/lineparser.js @@ -1,3 +1,5 @@ +"use strict"; + /** * @fileOverview Provides a parser for IMAP line based commands * @author Andris Reinman @@ -5,20 +7,20 @@ var Stream = require("stream").Stream, utillib = require("util"); - + // expose to the world module.exports = IMAPLineParser; /** * Creates a reusable parser for parsing. It is a writable stream for piping * data directly in. - * + * * @constructor */ function IMAPLineParser(){ Stream.call(this); this.writable = true; - + this._init(); } utillib.inherits(IMAPLineParser, Stream); @@ -46,8 +48,8 @@ IMAPLineParser.prototype.types = { // PUBLIC METHODS /** - * Appends a chunk for parsing - * + * Appends a chunk for parsing + * * @param {Buffer|String} chunk Data to be appended to the parse string * @return {Boolean} Always returns true */ @@ -62,34 +64,34 @@ IMAPLineParser.prototype.write = function(chunk){ * If a literal occurs ({123}\r\n) do not parse it, since the length is known. * Just add it separately and it will included as the node value instead of * length property. - * + * * @param {Buffer|String} chunk Data to be appended to the literal string value */ IMAPLineParser.prototype.writeLiteral = function(chunk){ if(!this.currentNode.value){ this.currentNode.value = ""; } - + if(this.currentNode.type != this.types.LITERAL){ //this.currentNode.literal = this.currentNode.value; this.currentNode.value = ""; //this.currentNode.type = this.types.LITERAL; } - + this.currentNode.value += (chunk || "").toString("binary"); }; /** - * Finishes current parsing and reesets internal variables. Emits 'line' event + * Finishes current parsing and reesets internal variables. Emits 'line' event * with the parsed data - * + * * @param {Buffer|String} chunk Data to be appended to the parse string */ IMAPLineParser.prototype.end = function(chunk){ if(chunk && chunk.length){ this.write(chunk); } - + if(this.currentNode.value){ if(this._state == this.states.ATOM || this._state==this.states.QUOTED){ if(this._state == this.states.ATOM && this.currentNode.value == "NIL"){ @@ -98,7 +100,8 @@ IMAPLineParser.prototype.end = function(chunk){ this._branch.childNodes.push(this.currentNode); } } - + + process.nextTick(this.emit.bind(this, "log", this._currentLine)); process.nextTick(this.emit.bind(this, "line", this.finalize())); this._init(); }; @@ -106,7 +109,7 @@ IMAPLineParser.prototype.end = function(chunk){ /** * Generates a structured object with the data currently known. Useful if you * need to check parse status in the middle of the process - * + * * @return {Array} Parsed data */ IMAPLineParser.prototype.finalize = function(){ @@ -121,25 +124,25 @@ IMAPLineParser.prototype.finalize = function(){ * Resets all internal variables and creates a new parse tree */ IMAPLineParser.prototype._init = function(){ - + /** * Current state the parser is in * @private */ this._state = this.states.DEFAULT; - + /** * Which quote symbol is used for current quoted string * @private */ this._quoteMark = ''; - + /** * Is the current character escaped by \ * @private */ this._escapedChar = false; - + /** * Parse tree to hold the parsed data structure * @private @@ -147,19 +150,19 @@ IMAPLineParser.prototype._init = function(){ this._parseTree = { childNodes: [] }; - + /** * Active branch, by default it's the tree itselt * @private */ this._branch = this._parseTree; - + /** * Hold the original line data * @private */ this._currentLine = ""; - + /** * Starting node * @private @@ -175,20 +178,20 @@ IMAPLineParser.prototype._init = function(){ * Parses the data currently known, continues from the previous state. * This is a token based parser. Special characters are space, backslash, * quotes, (), [] and <>. After every character the parseTree is updated. - * + * * @param {String} line Data to be parsed */ IMAPLineParser.prototype._parseLine = function(line){ var i=0, curchar; - + while(i < line.length){ - + curchar = line.charAt(i); // Check all characters one by one switch(curchar){ - + // Handle whitespace case " ": case "\t": @@ -202,19 +205,25 @@ IMAPLineParser.prototype._parseLine = function(line){ this._createNode(); } break; - + // Backspace is for escaping in quoted strings case '\\': if(this._escapedChar || this._state == this.states.ATOM){ this.currentNode.value += curchar; }else if(this._state == this.states.QUOTED){ - this._escapedChar = true; + if( line.charAt(i+1) === this._quoteMark || this._state == this.states.QUOTED){ + this._escapedChar = true; + // This is a modified copy the default case + }else if(this._state == this.states.ATOM){ + this.currentNode.value += curchar; + this.currentNode.value += line.charAt(++i); + } }else if(this._state == this.states.DEFAULT){ this._state = this.states.ATOM; this._createNode(curchar); } break; - + // Handle quotes, remember the quote type to allow other unescaped quotes case '"': case "'": @@ -228,14 +237,22 @@ IMAPLineParser.prototype._parseLine = function(line){ this._addToBranch(); this._state = this.states.DEFAULT; this._createNode(); - }else if(this._state == this.states.ATOM){ - this._addToBranch(); - this._quoteMark = curchar; - this._state = this.states.QUOTED; - this._createNode(); + }else if(this._state == this.states.ATOM ){ + + if( i === 0 || [ ' ', '\t', '"', "'", '[', '(', '<' ].indexOf( line.charAt(i-1) ) > -1 ){ + this._addToBranch(); + this._quoteMark = curchar; + this._state = this.states.QUOTED; + this._createNode(); + + // This is a modified copy the default case + }else if(this._state == this.states.ATOM || this._state == this.states.QUOTED){ + this.currentNode.value += curchar; + } + } break; - + // Handle different group types case "[": case "(": @@ -248,7 +265,7 @@ IMAPLineParser.prototype._parseLine = function(line){ if(this._state == this.states.ATOM){ this._addToBranch(); } - + // () gets a separate node, [] uses last node as parent if(this._state == this.states.ATOM && curchar == "["){ this._branch = this._branch.lastNode || this._parseTree; @@ -270,9 +287,9 @@ IMAPLineParser.prototype._parseLine = function(line){ this.currentNode.type = this.types.PARAMS; break; } - + this._addToBranch(); - + this._branch = this.currentNode || this._parseTree; if(!this._branch.childNodes){ this._branch.childNodes = []; @@ -280,9 +297,9 @@ IMAPLineParser.prototype._parseLine = function(line){ } this._state = this.states.DEFAULT; - + this._createNode(); - + break; case "]": case ")": @@ -291,21 +308,21 @@ IMAPLineParser.prototype._parseLine = function(line){ this.currentNode.value += curchar; break; } - + if(this._state == this.states.ATOM){ this._addToBranch(); } - + this._state = this.states.DEFAULT; this._branch = this._branch.parentNode || this._branch; if(!this._branch.childNodes){ this._branch.childNodes = []; } - + this._createNode(); break; - + // Add to existing string or create a new atom default: if(this._state == this.states.ATOM || this._state == this.states.QUOTED){ @@ -315,15 +332,15 @@ IMAPLineParser.prototype._parseLine = function(line){ this._createNode(curchar); } } - + // cancel escape if it didn't happen if(this._escapedChar && curchar != "\\"){ this._escapedChar = false; } - + i++; } - + }; /** @@ -339,24 +356,24 @@ IMAPLineParser.prototype._addToBranch = function(){ /** * Creates a new empty node - * + * * @param {String} [defaultValue] If specified will be used as node.value value */ IMAPLineParser.prototype._createNode = function(defaultValue){ this.lastNode = this.currentNode; - + this.currentNode = {}; - + if(defaultValue !== false){ this.currentNode.value = defaultValue || ""; } - + this.currentNode.parentNode = this._branch; }; /** * Recursive function to walk the parseTree and generate structured output object - * + * * @param {Array} branch Current branch to check * @param {Array} local Output object node to append the data to */ @@ -365,19 +382,19 @@ IMAPLineParser.prototype._nodeWalker = function(branch, local){ for(i=0, len = branch.length; i=0){ + command = "LIST"; + suffix = " RETURN (SPECIAL-USE)"; + }else if(this.client._capabilities.indexOf("XLIST")>=0){ + command = "XLIST"; + } + + this.client._send(command+" "+this.client._escapeString(this.client._rootPath) + " " + path + suffix, + (function(){ + this.listSubscribed(path, this.client._mailboxList, callback); + }).bind(this), + (function(){ + this.client._mailboxList = []; + }).bind(this)); + +}; + +/** + * Fetches subscribed mailboxes + * + * @param {String} path Parent mailbox + * @param {Array} xinfo Results from XLIST or LIST + * @param {Function} callback Callback function to run with the mailbox list + */ +Mailbox.prototype.listSubscribed = function(path, xinfo, callback){ + if(!callback && typeof xinfo == "function"){ + callback = xinfo; + xinfo = undefined; + } + + xinfo = xinfo || []; + + this.client._send("LSUB "+this.client._escapeString(this.client._rootPath)+" "+path, + (function(status){ + if(!this.client._mailboxList.length){ + this.client._mailboxList = [].concat(xinfo); + } + this.client._handlerTaggedLsub(xinfo, callback, status); + }).bind(this), + (function(){ + this.client._mailboxList = []; + }).bind(this)); +}; + +/** + * Creates a new mailbox and subscribes to it + * + * @param {String} name Name of the mailbox + * @param {Function} callback Callback function to run with the created mailbox object + */ +Mailbox.prototype.createChild = function(name, callback){ + var path = (this.path ? this.path + this.delimiter + name:name); + this.client._send("CREATE "+this.client._escapeString(path), (function(status){ + if(status == "OK"){ + this.client._send("SUBSCRIBE "+this.client._escapeString(path), (function(){ + if(typeof callback == "function"){ + callback(null, new Mailbox({ + client: this.client, + path: path, + name: name, + delimiter: this.delimiter, + tags: [] + })); + } + }).bind(this)); + }else{ + callback(new Error("Creating mailbox failed")); + } + }).bind(this)); +}; + +/** + * Deletes a mailbox + * + * @param {String} name Name of the mailbox + * @param {Function} callback Callback function to run with the status of the operation + */ +Mailbox.prototype.deleteChild = function(name, callback){ + var path = (this.path ? this.path + this.delimiter + name:name); + this.client._send("DELETE "+this.client._escapeString(path), (function(status){ + if(status == "OK"){ + callback(null, status); + }else{ + callback(new Error("Deleting mailbox failed")); + } + }).bind(this)); +}; + +/** + * Returns mailbox type detected by the name of the mailbox + * + * @param {String} mailboxName Mailbox name + * @return {String} Mailbox type + */ +function detectMailboxType(mailboxName){ + mailboxName = (mailboxName || "").toString().trim().toLowerCase(); + + if(mailboxNames.sent.indexOf(mailboxName)>=0){ + return "Sent"; + } + + if(mailboxNames.trash.indexOf(mailboxName)>=0){ + return "Trash"; + } + + if(mailboxNames.junk.indexOf(mailboxName)>=0){ + return "Junk"; + } + + if(mailboxNames.drafts.indexOf(mailboxName)>=0){ + return "Drafts"; + } + + return "Normal"; +} diff --git a/lib/names.json b/lib/names.json new file mode 100644 index 0000000..4610a17 --- /dev/null +++ b/lib/names.json @@ -0,0 +1,6 @@ +{ + "sent": ["aika", "bidaliak", "bidalita", "dihantar", "e rometsweng", "e tindami", "elküldött", "elküldöttek", "enviadas", "enviadas", "enviados", "enviats", "envoyés", "ethunyelweyo", "expediate", "ezipuru", "gesendete", "gestuur", "gönderilmiş öğeler", "göndərilənlər", "iberilen", "inviati", "išsiųstieji", "kuthunyelwe", "lasa", "lähetetyt", "messages envoyés", "naipadala", "nalefa", "napadala", "nosūtītās ziņas", "odeslané", "padala", "poslane", "poslano", "poslano", "poslané", "poslato", "saadetud", "saadetud kirjad", "sendt", "sendt", "sent", "sent items", "sent messages", "sända poster", "sänt", "terkirim", "ti fi ranṣẹ", "të dërguara", "verzonden", "vilivyotumwa", "wysłane", "đã gửi", "σταλθέντα", "жиберилген", "жіберілгендер", "изпратени", "илгээсэн", "ирсол шуд", "испратено", "надіслані", "отправленные", "пасланыя", "юборилган", "ուղարկված", "נשלחו", "פריטים שנשלחו", "المرسلة", "بھیجے گئے", "سوزمژہ", "لېګل شوی", "موارد ارسال شده", "पाठविले", "पाठविलेले", "प्रेषित", "भेजा गया", "প্রেরিত", "প্রেরিত", "প্ৰেৰিত", "ਭੇਜੇ", "મોકલેલા", "ପଠାଗଲା", "அனுப்பியவை", "పంపించబడింది", "ಕಳುಹಿಸಲಾದ", "അയച്ചു", "යැවු පණිවුඩ", "ส่งแล้ว", "გაგზავნილი", "የተላኩ", "បាន​ផ្ញើ", "寄件備份", "寄件備份", "已发信息", "送信済みメール", "발신 메시지", "보낸 편지함"], + "trash": ["articole șterse", "bin", "borttagna objekt", "deleted", "deleted items", "deleted messages", "elementi eliminati", "elementos borrados", "elementos eliminados", "gelöschte objekte", "item dipadam", "itens apagados", "itens excluídos", "mục đã xóa", "odstraněné položky", "pesan terhapus", "poistetut", "praht", "silinmiş öğeler", "slettede beskeder", "slettede elementer", "trash", "törölt elemek", "usunięte wiadomości", "verwijderde items", "vymazané správy", "éléments supprimés", "видалені", "жойылғандар", "удаленные", "פריטים שנמחקו", "العناصر المحذوفة", "موارد حذف شده", "รายการที่ลบ", "已删除邮件", "已刪除項目", "已刪除項目"], + "junk": ["bulk mail", "correo no deseado", "courrier indésirable", "istenmeyen", "istenmeyen e-posta", "junk", "levélszemét", "nevyžiadaná pošta", "nevyžádaná pošta", "no deseado", "posta indesiderata", "pourriel", "roskaposti", "skräppost", "spam", "spam", "spamowanie", "søppelpost", "thư rác", "спам", "דואר זבל", "الرسائل العشوائية", "هرزنامه", "สแปม", "‎垃圾郵件", "垃圾邮件", "垃圾電郵"], + "drafts": ["ba brouillon", "borrador", "borrador", "borradores", "bozze", "brouillons", "bản thảo", "ciorne", "concepten", "draf", "drafts", "drög", "entwürfe", "esborranys", "garalamalar", "ihe edeturu", "iidrafti", "izinhlaka", "juodraščiai", "kladd", "kladder", "koncepty", "koncepty", "konsep", "konsepte", "kopie robocze", "layihələr", "luonnokset", "melnraksti", "meralo", "mesazhe të padërguara", "mga draft", "mustandid", "nacrti", "nacrti", "osnutki", "piszkozatok", "rascunhos", "rasimu", "skice", "taslaklar", "tsararrun saƙonni", "utkast", "vakiraoka", "vázlatok", "zirriborroak", "àwọn àkọpamọ́", "πρόχειρα", "жобалар", "нацрти", "нооргууд", "сиёҳнавис", "хомаки хатлар", "чарнавікі", "чернетки", "чернови", "черновики", "черновиктер", "սևագրեր", "טיוטות", "مسودات", "مسودات", "موسودې", "پیش نویسها", "ڈرافٹ/", "ड्राफ़्ट", "प्रारूप", "খসড়া", "খসড়া", "ড্ৰাফ্ট", "ਡ੍ਰਾਫਟ", "ડ્રાફ્ટસ", "ଡ୍ରାଫ୍ଟ", "வரைவுகள்", "చిత్తు ప్రతులు", "ಕರಡುಗಳು", "കരടുകള്‍", "කෙටුම් පත්", "ฉบับร่าง", "მონახაზები", "ረቂቆች", "សារព្រាង", "下書き", "草稿", "草稿", "草稿", "임시 보관함"] +} \ No newline at end of file diff --git a/lib/starttls.js b/lib/starttls.js index 836f0f6..9c0ffe8 100644 --- a/lib/starttls.js +++ b/lib/starttls.js @@ -1,3 +1,5 @@ +"use strict"; + // SOURCE: https://gist.github.com/848444 // Target API: @@ -24,14 +26,14 @@ module.exports.starttls = starttls; /** *

Upgrades a socket to a secure TLS connection

- * + * * @memberOf starttls * @param {Object} socket Plaintext socket to be upgraded * @param {Function} callback Callback function to be run after upgrade */ function starttls(socket, callback) { var sslcontext, pair, cleartext; - + socket.removeAllListeners("data"); sslcontext = require('crypto').createCredentials(); pair = require('tls').createSecurePair(sslcontext, false); @@ -56,16 +58,16 @@ function starttls(socket, callback) { function forwardEvents(events, emitterSource, emitterDestination) { var map = [], name, handler; - + for(var i = 0, len = events.length; i < len; i++) { name = events[i]; handler = forwardEvent.bind(emitterDestination, name); - + map.push(name); emitterSource.on(name, handler); } - + return map; } @@ -84,9 +86,9 @@ function pipe(pair, socket) { socket.pipe(pair.encrypted); pair.fd = socket.fd; - + var cleartext = pair.cleartext; - + cleartext.socket = socket; cleartext.encrypted = pair.encrypted; cleartext.authorized = false; @@ -98,7 +100,7 @@ function pipe(pair, socket) { } var map = forwardEvents(["timeout", "end", "close", "drain", "error"], socket, cleartext); - + function onclose() { socket.removeListener('error', onerror); socket.removeListener('close', onclose); @@ -109,4 +111,4 @@ function pipe(pair, socket) { socket.on('close', onclose); return cleartext; -} \ No newline at end of file +} diff --git a/lib/xoauth.js b/lib/xoauth.js index 4ba735f..e177ae6 100644 --- a/lib/xoauth.js +++ b/lib/xoauth.js @@ -1,3 +1,5 @@ +"use strict"; + // this module is inspired by xoauth.py // http://code.google.com/p/google-mail-xoauth-tools/ @@ -11,12 +13,13 @@ module.exports.XOAuthGenerator = XOAuthGenerator; /** * Create a XOAUTH login token generator - * + * * @constructor * @memberOf xoauth * @param {Object} options * @param {String} [options.consumerKey="anonymous"] OAuth consumer key * @param {String} [options.consumerSecret="anonymous"] OAuth consumer secret + * @param {String} [options.requestorId] 2 legged OAuth requestor ID * @param {String} [options.nonce] Nonce value to be used for OAuth * @param {Number} [options.timestamp] Unix timestamp value to be used for OAuth * @param {String} options.user Username @@ -31,7 +34,7 @@ function XOAuthGenerator(options){ /** * Generate a XOAuth login token - * + * * @param {Function} [callback] Callback function to run when the access token is genertaed * @return {String|undefined} If callback is not set, return the token value, otherwise run callback instead */ @@ -53,8 +56,8 @@ function hmacSha1(str, key){ function initOAuthParams(options){ return { - oauth_consumer_key: options.consumerKey || "anonymous", - oauth_nonce: options.nonce || "" + Date.now() + Math.round(Math.random()*1000000), + oauth_consumer_key: options.consumerKey || "anonymous", + oauth_nonce: options.nonce || "" + Date.now() + Math.round(Math.random()*1000000), oauth_signature_method: "HMAC-SHA1", oauth_version: "1.0", oauth_timestamp: options.timestamp || "" + Math.round(Date.now()/1000) @@ -65,35 +68,42 @@ function generateOAuthBaseStr(method, requestUrl, params){ var reqArr = [method, requestUrl].concat(Object.keys(params).sort().map(function(key){ return key + "=" + encodeURIComponent(params[key]); }).join("&")); - return escapeAndJoin(reqArr); } function generateXOAuthStr(options, callback){ options = options || {}; - + var params = initOAuthParams(options), - requestUrl = options.requestUrl || "https://mail.google.com/mail/b/" + (options.user || "") + "/imap/", + requestUrl = options.requestUrl || "https://mail.google.com/mail/b/" + (options.user || "") + "/imap/", baseStr, signatureKey, paramsStr, returnStr; - - if(options.token){ + + if(options.token && !options.requestorId){ params.oauth_token = options.token; } - - baseStr = generateOAuthBaseStr(options.method || "GET", requestUrl, params); - - signatureKey = escapeAndJoin([options.consumerSecret || "anonymous", options.tokenSecret]); + + baseStr = generateOAuthBaseStr(options.method || "GET", requestUrl, params); + + if(options.requestorId){ + baseStr += encodeURIComponent("&xoauth_requestor_id=" + encodeURIComponent(options.requestorId)); + } + + signatureKey = escapeAndJoin([options.consumerSecret || "anonymous", options.tokenSecret || ""]); + params.oauth_signature = hmacSha1(baseStr, signatureKey); paramsStr = Object.keys(params).sort().map(function(key){ return key+"=\""+encodeURIComponent(params[key])+"\""; }).join(","); - - returnStr = [options.method || "GET", requestUrl, paramsStr].join(" "); - + + // Liidab kokku üheks pikaks stringiks kujul "METHOD URL BODY" + // 2-legged variandi puhul lisab BODY parameetritele otsa ka requestor_id väärtuse + returnStr = [options.method || "GET", requestUrl + + (options.requestorId ? "?xoauth_requestor_id=" + encodeURIComponent(options.requestorId) : ""), paramsStr].join(" "); + if(typeof callback == "function"){ callback(null, new Buffer(returnStr, "utf-8").toString("base64")); }else{ return new Buffer(returnStr, "utf-8").toString("base64"); } -} \ No newline at end of file +} diff --git a/package.json b/package.json index b78766a..f87164a 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,30 @@ { - "name": "inbox", - "version": "0.1.3", - "author" : "Andris Reinman", - "maintainers":[ - { - "name":"andris", - "email":"andris@node.ee" - } - ], - - "main": "lib/client.js", - - "dependencies": { - "mimelib": "*" - }, - - "devDependencies": { - "nodeunit": "*" - }, - - "scripts":{ - "test": "nodeunit test" + "name": "inbox", + "version": "1.1.55", + "author": "Andris Reinman", + "maintainers": [ + { + "name": "andris", + "email": "andris@node.ee" } -} \ No newline at end of file + ], + "main": "lib/client.js", + "dependencies": { + "mimelib": "*", + "utf7": "~1.0.0", + "xoauth2": "*" + }, + "devDependencies": { + "nodeunit": "*", + "hoodiecrow": "~1.1.19" + }, + "scripts": { + "test": "nodeunit test" + }, + "homepage": "https://github.com/andris9/inbox", + "repository": { + "type": "git", + "url": "https://github.com/andris9/inbox.git" + }, + "license": "MIT" +} diff --git a/test/condstore.js b/test/condstore.js new file mode 100644 index 0000000..75d2437 --- /dev/null +++ b/test/condstore.js @@ -0,0 +1,68 @@ +"use strict"; + +var inbox = require(".."), + hoodiecrow = require("hoodiecrow"); + +var IMAP_PORT = 1143; + +module.exports["Condstore"] = { + setUp: function(next){ + this.server = hoodiecrow({ + plugins: ["ENABLE", "CONDSTORE"], + storage: { + "INBOX":{ + messages: [ + {raw: "Subject: hello 1\r\n\r\nWorld 1!"}, + {raw: "Subject: hello 2\r\n\r\nWorld 2!", flags: ["\\Seen"]}, + {raw: "Subject: hello 3\r\n\r\nWorld 3!"} + ] + }, + "": { + "separator": "/", + "folders": { + "TRASH": {}, + "SENT": {} + } + } + }, + debug: false + }); + + this.server.listen(IMAP_PORT, (function(){ + this.client = inbox.createConnection(IMAP_PORT, "localhost", { + auth:{ + user: "testuser", + pass: "testpass" + }, + debug: false + }); + this.client.connect(); + this.client.on("connect", next); + }).bind(this)); + }, + + tearDown: function(next){ + this.client.close(); + this.client.on("close", (function(){ + this.server.close(next); + }).bind(this)); + }, + + "Fetch messages since last modseq change": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + var modseq = mailbox.highestModSeq; + test.equal(modseq, 3); + this.client.addFlags(2, ["Test"], (function(err, flags){ + test.ifError(err); + this.client.listMessages(0, 0, "(CHANGEDSINCE " + modseq + ")", function(err, messages){ + test.ifError(err); + test.equal(messages.length, 1); + test.equal(messages[0].UID, 2); + test.equal(messages[0].modSeq, 4); + test.done(); + }); + }).bind(this)); + }).bind(this)); + } +}; + diff --git a/test/inbox.js b/test/inbox.js new file mode 100644 index 0000000..dc1cab1 --- /dev/null +++ b/test/inbox.js @@ -0,0 +1,412 @@ +"use strict"; + +var inbox = require(".."), + hoodiecrow = require("hoodiecrow"); + +var IMAP_PORT = 1143; + +module.exports["Inbox tests"] = { + setUp: function(next){ + this.server = hoodiecrow({ + plugins: ["IDLE"], + storage: { + "INBOX":{ + messages: [ + {raw: "Subject: hello 1\r\n\r\nWorld 1!", internaldate: "14-Sep-2013 21:22:28 -0300"}, + {raw: "Subject: hello 2\r\n\r\nWorld 2!", flags: ["\\Seen"]}, + {raw: "Subject: hello 3\r\n\r\nWorld 3!"}, + {raw: "From: sender name \r\n"+ + "To: Receiver name \r\n"+ + "Subject: hello 4\r\n"+ + "Message-Id: \r\n"+ + "Date: Fri, 13 Sep 2013 15:01:00 +0300\r\n"+ + "\r\n"+ + "World 4!"}, + {raw: "Subject: hello 5\r\n\r\nWorld 5!"}, + {raw: "Subject: hello 6\r\n\r\nWorld 6!"}, + {raw: "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\nMIME-Version: 1.0\r\n\r\nwow. very mail. such bodystructure."}, + {raw: "Content-Type: multipart/alternative;\r\n boundary=\"=_BOUNDARY_BOUNDARY_BOUNDARY_\";\r\n charset=\"UTF-8\"\r\nMIME-Version: 1.0\r\nSender: \"FOOBAR\" \r\n\r\nThis is a multi-part message in MIME format\r\n\r\n--=_BOUNDARY_BOUNDARY_BOUNDARY_\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nFOOFOOFOOFOO\r\n\r\n\r\n--=_BOUNDARY_BOUNDARY_BOUNDARY_\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n\r\n\r\n \r\n \r\n STUFF\r\n \r\n \r\n

stuff

\r\n \r\n\r\n\r\n--=_BOUNDARY_BOUNDARY_BOUNDARY_--"} + ] + }, + "": { + "separator": "/", + "folders": { + "TRASH": {}, + "SENT": {}, + "Unsubscribed": { + subscribed: false + } + } + } + } + }); + + this.server.listen(IMAP_PORT, (function(){ + this.client = inbox.createConnection(IMAP_PORT, "localhost", { + auth:{ + user: "testuser", + pass: "testpass" + }, + debug: false + }); + this.client.connect(); + this.client.on("connect", next); + }).bind(this)); + }, + + tearDown: function(next){ + this.client.close(); + this.client.on("close", (function(){ + this.server.close(next); + }).bind(this)); + }, + + "List mailboxes": function(test){ + this.client.listMailboxes(function(err, mailboxes){ + test.ifError(err); + test.equal(mailboxes.length, 3); + test.equal(mailboxes[0].path, "INBOX"); + test.equal(mailboxes[1].path, "TRASH"); + test.equal(mailboxes[2].name, "SENT"); + test.done(); + }); + }, + + "Fetch mailbox": function(test){ + this.client.getMailbox("SENT", function(err, mailbox){ + test.ifError(err); + test.equal(Object.keys(mailbox).length, 4); + test.equal(mailbox.type, "Sent"); + test.equal(mailbox.delimiter, "/"); + test.done(); + }); + }, + + "Open mailbox": function(test){ + this.client.openMailbox("INBOX", function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.count, 8); + test.equal(mailbox.UIDValidity, "1"); + test.equal(mailbox.UIDNext, "9"); + test.done(); + }); + }, + + "Try to open invalid mailbox": function(test){ + this.client.openMailbox(undefined, function(err, mailbox){ + test.ok(err); + test.ok(!mailbox); + test.done(); + }); + }, + + "Try to open missing mailbox": function(test){ + this.client.openMailbox("NON-EXISTENT", function(err, mailbox){ + test.ok(err); + test.ok(!mailbox); + test.done(); + }); + }, + + "List messages": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.listMessages(-100, function(err, messages){ + test.ifError(err); + test.equal(messages.length, 8); + for(var i = 0; i < messages.length; i++) { + test.equal(messages[i].UIDValidity, 1); + test.equal(messages[i].UID, i+1); + } + test.equal(messages[3].from.address, "sender@example.com"); + test.deepEqual(messages[6].bodystructure, { part: '1', + type: 'text/plain', + parameters: { charset: 'utf-8' }, + encoding: 'quoted-printable', + size: 35, + lines: 1 + }); + test.deepEqual(messages[7].bodystructure, { + '1': { + part: '1', + type: 'text/plain', + parameters: {}, + encoding: 'quoted-printable', + size: 16, + lines: 3 + }, + '2': { + part: '2', + type: 'text/html', + parameters: {}, + encoding: 'quoted-printable', + size: 248, + lines: 12 + }, + type: 'multipart/alternative' + }); + test.done(); + }); + }).bind(this)); + }, + + "List messages by UID": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.listMessagesByUID(2, 4, function(err, messages){ + test.ifError(err); + test.equal(messages.length, 3); + for(var i = 0; i < messages.length; i++) { + test.equal(messages[i].UIDValidity, 1); + test.equal(messages[i].UID, i + 2); + } + test.equal(messages[2].from.address, "sender@example.com"); + test.done(); + }); + }).bind(this)); + }, + + "List flags": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.listFlags(-100, function(err, messages){ + test.ifError(err); + test.equal(messages.length, 8); + for(var i = 0; i < messages.length; i++) { + test.equal(messages[i].flags.length, i === 1 ? 1 : 0); + } + test.done(); + }); + + }).bind(this)); + }, + + "Fetch message details": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.fetchData(4, function(err, message){ + test.ifError(err); + test.equal(Object.keys(message).length, 11); + test.equal(message.title, "hello 4"); + test.equal(message.from.address, "sender@example.com"); + test.equal(message.to[0].name, "Receiver name"); + test.done(); + }); + }).bind(this)); + }, + + "Fetch message contents": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + var chunks = [], + chunklength = 0, + messageStream = this.client.createMessageStream(1); + messageStream.on("data", function(chunk){ + chunks.push(chunk); + chunklength += chunk.length; + }); + messageStream.on("end", function(){ + test.equal(Buffer.concat(chunks, chunklength).toString(), "Subject: hello 1\r\n\r\nWorld 1!"); + test.done(); + }); + + }).bind(this)); + }, + + "Stream should not be ended prematurely": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + + var messageStream = this.client.createMessageStream(1); + messageStream.on("data", function(){}); + messageStream.on("end", (function(){ + this.client.listMessagesByUID(2, 2, function(err, messages){ + test.ifError(err); + + test.equal(messages[0].UID, 2); + test.done(); + }); + }).bind(this)); + }).bind(this)); + }, + + "Fetch message flags": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.fetchFlags(2, function(err, flags) { + test.ifError(err); + test.equal(flags.length, 1); + test.equal(flags[0], "\\Seen"); + test.done(); + }); + + }).bind(this)); + }, + + "Add message flag": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.addFlags(2, ["Test"], function(err, flags){ + test.ifError(err); + test.equal(flags.length, 2); + test.equal(flags[0], "\\Seen"); + test.equal(flags[1], "Test"); + test.done(); + }); + }).bind(this)); + }, + + "Remove message flag": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.removeFlags(2, ["\\Seen"], function(err, flags) { + test.ifError(err); + test.equal(flags.length, 0); + test.done(); + }); + }).bind(this)); + }, + + + "Store message": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.count, 8); + this.client.storeMessage("Subject: hello 7\r\n\r\nWorld 7!", ["\\Seen"], (function(err, params){ + test.ifError(err); + test.equal(params.UID, mailbox.UIDNext); + this.client.openMailbox("INBOX", function(err, mailbox){ + test.equal(mailbox.count, 9); + test.done(); + }); + }).bind(this)); + }).bind(this)); + }, + + "Copy message": function(test){ + this.client.openMailbox("INBOX", (function(err){ + test.ifError(err); + this.client.copyMessage(3, "TRASH", (function(err){ + test.ifError(err); + this.client.openMailbox("TRASH", function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.count, 1); + test.equal(mailbox.UIDNext, 2); + test.done(); + }); + }).bind(this)); + }).bind(this)); + }, + + "Delete message": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.count, 8); + this.client.deleteMessage(6, (function(err){ + test.ifError(err); + this.client.openMailbox("INBOX", function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.count, 7); + test.done(); + }); + }).bind(this)); + }).bind(this)); + }, + + "New message": function(test){ + this.client.on("new", function(message){ + test.ok(message); + test.done(); + }); + + this.client.openMailbox("INBOX", (function(err){ + this.client.storeMessage("Subject: hello 8\r\n\r\nWorld 8!", function(){}); + }).bind(this)); + }, + + "Create mailbox": function(test){ + var self = this; + this.client.createMailbox("NEW-MAILBOX", function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.path, "NEW-MAILBOX"); + test.equal(mailbox.type, "Normal"); + test.equal(mailbox.delimiter, "/"); + self.client.openMailbox("NEW-MAILBOX", function(err, mailbox){ + test.ifError(err); + test.equal(mailbox.count, 0); + test.equal(mailbox.UIDValidity, "1"); + test.equal(mailbox.UIDNext, "1"); + test.done(); + }); + }); + }, + + "Delete mailbox": function(test){ + var self = this; + this.client.createMailbox("NEW-MAILBOX", function(err, mailbox){ + self.client.deleteMailbox("NEW-MAILBOX", function(err, status){ + test.ifError(err); + test.equal(status, "OK"); + self.client.openMailbox("NEW-MAILBOX", function(err, mailbox){ + test.ok(err); + test.ok(!mailbox); + test.done(); + }); + }); + }); + } +}; + +module.exports["Empty LSUB"] = { + setUp: function(next){ + this.server = hoodiecrow({ + storage: { + "INBOX":{ + subscribed: false + }, + "": { + "separator": "/", + "folders": { + "TRASH": { + subscribed: false + }, + "SENT": { + subscribed: false + } + } + } + }, + debug: false + }); + + this.server.listen(IMAP_PORT, (function(){ + this.client = inbox.createConnection(IMAP_PORT, "localhost", { + auth:{ + user: "testuser", + pass: "testpass" + }, + debug: false + }); + this.client.connect(); + this.client.on("connect", next); + }).bind(this)); + }, + + tearDown: function(next){ + this.client.close(); + this.client.on("close", (function(){ + this.server.close(next); + }).bind(this)); + }, + + "List mailboxes with empty LSUB": function(test){ + this.client.listMailboxes(function(err, mailboxes){ + test.ifError(err); + test.equal(mailboxes.length, 3); + test.equal(mailboxes[0].path, "INBOX"); + test.equal(mailboxes[1].path, "TRASH"); + test.equal(mailboxes[2].name, "SENT"); + test.done(); + }); + } +}; diff --git a/test/lineparser.js b/test/lineparser.js index 87a5da6..a752775 100644 --- a/test/lineparser.js +++ b/test/lineparser.js @@ -1,205 +1,243 @@ -var testCase = require('nodeunit').testCase, - IMAPLineParser = require("../lib/lineparser"); - +var IMAPLineParser = require("../lib/lineparser"); exports["Type tests"] = { - + "Single atom": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1"]); - test.done(); - }); - - lp.end("TAG1"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1"]); + test.done(); + }); + + lp.end("TAG1"); }, - + "Multiple atoms": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", "UID", "FETCH"]); - test.done(); - }); - - lp.end("TAG1 UID FETCH"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", "UID", "FETCH"]); + test.done(); + }); + + lp.end("TAG1 UID FETCH"); }, - + "Single quoted": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1"]); - test.done(); - }); - - lp.end("\"TAG1\""); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1"]); + test.done(); + }); + + lp.end("\"TAG1\""); }, - + "Multiword quoted": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1 UID FETCH"]); - test.done(); - }); - - lp.end("\"TAG1 UID FETCH\""); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1 UID FETCH"]); + test.done(); + }); + + lp.end("\"TAG1 UID FETCH\""); }, - + "Atom + quoted": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", "UID FETCH"]); - test.done(); - }); - - lp.end("TAG1 \"UID FETCH\""); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", "UID FETCH"]); + test.done(); + }); + + lp.end("TAG1 \"UID FETCH\""); }, - + "Single literal": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", "ABC DEF\r\nGHI JKL", "TAG2"]); - test.done(); - }); - - lp.write("TAG1 {123}"); - lp.writeLiteral("ABC DEF\r\nGHI JKL"); - lp.end("\"TAG2\""); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", "ABC DEF\r\nGHI JKL", "TAG2"]); + test.done(); + }); + + lp.write("TAG1 {123}"); + lp.writeLiteral("ABC DEF\r\nGHI JKL"); + lp.end("\"TAG2\""); }, - + "NIL value": function(test){ var lp = new IMAPLineParser(); - + test.expect(1); - + lp.on("line", function(data){ test.deepEqual(data, ["TAG1", null]); test.done(); }); - + lp.end("TAG1 NIL"); }, - + "NIL string": function(test){ var lp = new IMAPLineParser(); - + test.expect(1); - + lp.on("line", function(data){ test.deepEqual(data, ["TAG1", "NIL"]); test.done(); }); - + lp.end("TAG1 \"NIL\""); } } exports["Structure tests"] = { - "Single group": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", "FETCH", ["NAME", "HEADER", "BODY"]]); - test.done(); - }); - - lp.end("TAG1 FETCH (NAME HEADER BODY)"); + "Single group": function(test){ + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", "FETCH", ["NAME", "HEADER", "BODY"]]); + test.done(); + }); + + lp.end("TAG1 FETCH (NAME HEADER BODY)"); }, - + "Nested group": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", "FETCH", ["NAME", "HEADER", "BODY", ["CHARSET", "UTF-8"]]]); - test.done(); - }); - - lp.end("TAG1 FETCH (NAME HEADER BODY (CHARSET \"UTF-8\"))"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", "FETCH", ["NAME", "HEADER", "BODY", ["CHARSET", "UTF-8"]]]); + test.done(); + }); + + lp.end("TAG1 FETCH (NAME HEADER BODY (CHARSET \"UTF-8\"))"); }, - + "Single params": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", {value:"BODY", params: ["DATE", "TEXT"]}]); - test.done(); - }); - - lp.end("TAG1 BODY[DATE TEXT]"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", {value:"BODY", params: ["DATE", "TEXT"]}]); + test.done(); + }); + + lp.end("TAG1 BODY[DATE TEXT]"); }, - + "Partial data": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", {value:"BODY", partial: [122, 456]}]); - test.done(); - }); - - lp.end("TAG1 BODY[]<122.456>"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", {value:"BODY", partial: [122, 456]}]); + test.done(); + }); + + lp.end("TAG1 BODY[]<122.456>"); }, - + "Mixed params and partial": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", {value:"BODY", params: ["HEADER", "FOOTER"], partial: [122, 456]}]); - test.done(); - }); - - lp.end("TAG1 BODY[HEADER FOOTER]<122.456>"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", {value:"BODY", params: ["HEADER", "FOOTER"], partial: [122, 456]}]); + test.done(); + }); + + lp.end("TAG1 BODY[HEADER FOOTER]<122.456>"); }, - + "Nested params and groups": function(test){ - var lp = new IMAPLineParser(); - - test.expect(1); - - lp.on("line", function(data){ - test.deepEqual(data, ["TAG1", {value:"BODY", params: ["DATE", "FLAGS", ["\\Seen", "\\Deleted"]]}]); - test.done(); - }); - - lp.end("TAG1 BODY[DATE FLAGS (\\Seen \\Deleted)]"); + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", {value:"BODY", params: ["DATE", "FLAGS", ["\\Seen", "\\Deleted"]]}]); + test.done(); + }); + + lp.end("TAG1 BODY[DATE FLAGS (\\Seen \\Deleted)]"); }, - + "Bound and unbound params": function(test){ var lp = new IMAPLineParser(); - + test.expect(1); - + lp.on("line", function(data){ test.deepEqual(data, ["TAG1", {params: ["ALERT"]}, {value: "BODY", params:["TEXT", "HEADER"]}]); test.done(); }); - + lp.end("TAG1 [ALERT] BODY[TEXT HEADER]"); + }, + + "Escaped list": function(test){ + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("line", function(data){ + test.deepEqual(data, ["TAG1", 'abc"', [ 'def' ]]); + test.done(); + }); + + lp.end("TAG1 \"abc\\\"\" (\"def\")"); + }, + + "Escaped label": function(test){ + var input = 'X-GM-LABELS ("\\\\Draft")'; + var lp = new IMAPLineParser(); + lp.on("line", function(data){ + test.deepEqual(data, [ 'X-GM-LABELS', [ '\\Draft' ] ]); + test.done(); + }); + + lp.end(input); + } +} + +exports["Logging tests"] = { + "Simple log": function(test){ + var lp = new IMAPLineParser(); + + test.expect(1); + + lp.on("log", function(data){ + test.equal(data, "TAG1 FETCH (NAME HEADER BODY)"); + test.done(); + }); + + lp.write("TAG1 ") + lp.end("FETCH (NAME HEADER BODY)"); } -} \ No newline at end of file +}; diff --git a/test/search.js b/test/search.js new file mode 100644 index 0000000..f9423c6 --- /dev/null +++ b/test/search.js @@ -0,0 +1,146 @@ +"use strict"; + +var inbox = require(".."), + hoodiecrow = require("hoodiecrow"); + +var IMAP_PORT = 1143; + +module.exports = { + setUp: function(next){ + this.server = hoodiecrow({ + storage: { + "INBOX":{ + messages: [ + {raw: "Subject: hello 1\r\n\r\nWorld 1!", uid: 45, internaldate: new Date("2009-1-1")}, + {raw: "Subject: hello 2\r\n\r\nWorld 2!", flags: ["test", "\\Seen"], uid: 48}, + {raw: "Subject: hello 3\r\n\r\nWorld 3!", flags: ["test"], uid: 49}, + {raw: "Subject: test\r\n\r\nWorld 1!", flags: ["\\Seen"], uid: 50, internaldate: new Date("2009-1-1")}, + ] + } + }, + debug: false + }); + this.server.listen(IMAP_PORT, (function(){ + this.client = inbox.createConnection(IMAP_PORT, "localhost", { + auth:{ + user: "testuser", + pass: "testpass" + }, + debug: false + }); + this.client.connect(); + this.client.on("connect", next); + }).bind(this)); + }, + + tearDown: function(next){ + this.client.close(); + this.client.on("close", (function(){ + this.server.close(next); + }).bind(this)); + }, + + "Boolean search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + unseen: true + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [1, 3]); + test.done(); + }); + }).bind(this)); + }, + + "String search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + keyword: "test" + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [2, 3]); + test.done(); + }); + }).bind(this)); + }, + + "String UID search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + keyword: "test" + }, true, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [48, 49]); + test.done(); + }); + }).bind(this)); + }, + + "Array search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + header: ["subject", "hello"] + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [1, 2, 3]); + test.done(); + }); + }).bind(this)); + }, + + "Date search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + sentsince: new Date("2010-01-01") + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [2, 3]); + test.done(); + }); + }).bind(this)); + }, + + "AND search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + senton: new Date("2009-01-01"), + unseen: true + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [1]); + test.done(); + }); + }).bind(this)); + }, + + "OR search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + or: { + senton: new Date("2009-01-01"), + unseen: true + } + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages.sort(function(a,b){return a - b}), [1, 3, 4]); + test.done(); + }); + }).bind(this)); + }, + + "NOT search": function(test){ + this.client.openMailbox("INBOX", (function(err, mailbox){ + this.client.search({ + unseen: true, + not: { + senton: new Date("2009-01-01") + } + }, function(err, messages){ + test.ifError(err); + test.deepEqual(messages, [3]); + test.done(); + }); + }).bind(this)); + } +}; + diff --git a/tools/clientplayground.js b/tools/clientplayground.js index a20c614..34fe6a7 100644 --- a/tools/clientplayground.js +++ b/tools/clientplayground.js @@ -1,6 +1,6 @@ var inbox = require(".."), util = require("util"); - + var client = inbox.createConnection(false, "imap.gmail.com", { secureConnection: true, auth:{ @@ -13,32 +13,73 @@ var client = inbox.createConnection(false, "imap.gmail.com", { client.connect(); client.on("connect", function(){ - console.log(client.getMailboxList()); client.openMailbox("INBOX", function(error, mailbox){ if(error) throw error; - + // List newest 10 messages client.listMessages(-10, function(err, messages){ messages.forEach(function(message){ console.log(message.UID+": "+message.title); }); }); - + + /* client.fetchData(52, function(err, message){ - console.log(message); + console.log(message); }); - - var stream = client.createMessageStream(52); - client.createMessageStream(52).pipe(process.stdout, {end: false}); - + + //var stream = client.createMessageStream(52); + //client.createMessageStream(52).pipe(process.stdout, {end: false}); + + client.updateFlags(52, ["\\Answered", "\\Flagged"], "+", console.log) + client.removeFlags(52, ["\\Answered", "\\Flagged"], console.log) + client.addFlags(52, ["\\Flagged"], console.log) + */ + + function walkMailboxes(name, level, node){ + level = level || 0; + (node.listChildren || node.listMailboxes).call(node, function(err, list){ + if(err){return;} + console.log("> "+name); + for(var i=0; i