├── .gitignore ├── .jshintrc ├── .travis.yml ├── Gruntfile.js ├── LICENSE.txt ├── README.md ├── package.json ├── src └── imap-client.js └── test ├── background.js ├── integration-test.js ├── integration.html ├── integration.js ├── local-integration-test.js ├── manifest.json ├── unit-test.js ├── unit.html └── unit.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | test/lib 3 | node_modules 4 | npm-debug.log 5 | lib 6 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent": 4, 3 | "strict": true, 4 | "globalstrict": true, 5 | "node": true, 6 | "browser": true, 7 | "camelcase": false, 8 | "nonew": true, 9 | "curly": true, 10 | "eqeqeq": true, 11 | "immed": true, 12 | "newcap": true, 13 | "regexp": true, 14 | "evil": true, 15 | "eqnull": true, 16 | "expr": true, 17 | "trailing": true, 18 | "undef": true, 19 | "unused": true, 20 | 21 | "predef": [ 22 | "Promise", 23 | "define", 24 | "importScripts", 25 | "self", 26 | "setImmediate", 27 | "mocha", 28 | "chrome", 29 | "mock", 30 | "mockFunction", 31 | "when", 32 | "anything", 33 | "beforeEach", 34 | "afterEach", 35 | "before", 36 | "after", 37 | "console", 38 | "process", 39 | "describe", 40 | "it", 41 | "ES6Promise" 42 | ], 43 | 44 | "globals": { 45 | } 46 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - 0.12 5 | before_script: 6 | - npm install -g grunt-cli 7 | notifications: 8 | email: 9 | - build@whiteout.io -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 'use strict'; 3 | 4 | // Project configuration. 5 | grunt.initConfig({ 6 | jshint: { 7 | all: ['*.js', 'src/*.js', 'test/*.js'], 8 | options: { 9 | jshintrc: '.jshintrc' 10 | } 11 | }, 12 | 13 | connect: { 14 | dev: { 15 | options: { 16 | port: 10000, 17 | base: '.', 18 | keepalive: true 19 | } 20 | } 21 | }, 22 | 23 | mocha_phantomjs: { 24 | all: { 25 | options: { 26 | reporter: 'spec' 27 | }, 28 | src: ['test/unit.html'] 29 | } 30 | }, 31 | 32 | mochaTest: { 33 | tonline: { 34 | options: { 35 | reporter: 'spec' 36 | }, 37 | src: ['test/integration-test.js'] 38 | }, 39 | local: { 40 | options: { 41 | reporter: 'spec' 42 | }, 43 | src: ['test/local-integration-test.js'] 44 | }, 45 | unit: { 46 | options: { 47 | reporter: 'spec' 48 | }, 49 | src: ['test/unit-test.js'] 50 | } 51 | }, 52 | 53 | watch: { 54 | js: { 55 | files: ['src/*.js', 'test/*.js', 'test/*.html'], 56 | tasks: ['deps'] 57 | } 58 | }, 59 | 60 | copy: { 61 | npm: { 62 | expand: true, 63 | flatten: true, 64 | cwd: 'node_modules/', 65 | src: [ 66 | 'mocha/mocha.js', 67 | 'mocha/mocha.css', 68 | 'chai/chai.js', 69 | 'axe-logger/axe.js', 70 | 'sinon/pkg/sinon.js', 71 | 'requirejs/require.js', 72 | 'browserbox/node_modules/tcp-socket/src/*.js', 73 | 'browserbox/node_modules/tcp-socket/node_modules/node-forge/js/forge.min.js', 74 | 'browserbox/src/*.js', 75 | 'browserbox/node_modules/wo-addressparser/src/*.js', 76 | 'browserbox/node_modules/wo-utf7/src/*.js', 77 | 'browserbox/node_modules/wo-imap-handler/src/*.js', 78 | 'browserbox/node_modules/mimefuncs/src/*.js', 79 | 'browserbox/node_modules/mimefuncs/node_modules/wo-stringencoding/dist/stringencoding.js', 80 | 'es6-promise/dist/es6-promise.js' 81 | ], 82 | dest: 'test/lib/' 83 | }, 84 | app: { 85 | expand: true, 86 | flatten: true, 87 | cwd: 'src/', 88 | src: [ 89 | '*.js', 90 | ], 91 | dest: 'test/lib/' 92 | } 93 | }, 94 | 95 | clean: ['test/lib/**/*'] 96 | }); 97 | 98 | // Load the plugin(s) 99 | grunt.loadNpmTasks('grunt-mocha-test'); 100 | grunt.loadNpmTasks('grunt-mocha-phantomjs'); 101 | grunt.loadNpmTasks('grunt-contrib-jshint'); 102 | grunt.loadNpmTasks('grunt-contrib-connect'); 103 | grunt.loadNpmTasks('grunt-contrib-watch'); 104 | grunt.loadNpmTasks('grunt-contrib-copy'); 105 | grunt.loadNpmTasks('grunt-contrib-clean'); 106 | 107 | // Default task(s). 108 | grunt.registerTask('deps', ['clean', 'copy']); 109 | grunt.registerTask('dev', ['deps', 'connect:dev']); 110 | grunt.registerTask('default', ['jshint', 'deps', 'mochaTest:unit', 'mocha_phantomjs', 'mochaTest:local', 'mochaTest:tonline']); 111 | }; -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Whiteout Networks GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imap-client 2 | 3 | Repos under this github org are not longer maintained. Please use the new [emailjs](http://emailjs.org/) repos. 4 | 5 | High-level UMD module wrapper for [browserbox](https://github.com/whiteout-io/browserbox). This module encapsulates the most commonly used IMAP commands. 6 | 7 | Needs ES6 Promises, [supply polyfills where necessary](https://github.com/jakearchibald/es6-promise). 8 | 9 | ## API 10 | 11 | ### Constructor 12 | 13 | ``` 14 | var ImapClient = require(‘imap-client’); 15 | var imap = new ImapClient({ 16 | port: 993, // the port to connect to 17 | host: ’imap.example.com’, // the host to connect to 18 | secure: true/false, // use SSL? 19 | ignoreTLS: true/false, // if true, do not call STARTTLS before authentication even if the host advertises support for it 20 | requireTLS: true/false, // if true, always use STARTTLS before authentication even if the host does not advertise it. If STARTTLS fails, do not try to authenticate the user 21 | auth.user: ’john.q@example.com’, // username of the user (also applies to oauth2) 22 | auth.pass: ‘examplepassword’, // password for the user 23 | auth.xoauth2: ‘EXAMPLEOAUTH2TOKEN’, // OAuth2 access token to be used instead of password 24 | ca: ‘PEM ENCODED CERT’, // (optional, used only in conjunction with the TCPSocket shim) if you use TLS with forge, pin a PEM-encoded certificate as a string. Please refer to the [tcp-socket documentation](https://github.com/whiteout-io/tcp-socket) for more information! 25 | maxUpdateSize: 20 // (optional) the maximum number of messages you want to receive in one update from the server 26 | }); 27 | ``` 28 | 29 | ### #login() and #logout() 30 | 31 | Log in to an IMAP Session. No-op if already logged in. 32 | 33 | ``` 34 | imap.login().then(function() { 35 | // yay, we’re logged in 36 | }) 37 | 38 | imap.logout().then(function() { 39 | // yay, we’re logged out 40 | }); 41 | ``` 42 | 43 | ### #listenForChanges() and #stopListeningForChanges() 44 | 45 | Set up a connection dedicated to listening for changes published by the IMAP server on one specific inbox. 46 | 47 | ``` 48 | imap.listenForChanges({ 49 | path: ‘mailboxpath’ 50 | }).then(function() { 51 | // the audience is listening 52 | ... 53 | }) 54 | 55 | imap.stopListeningForChanges().then(function() { 56 | // we’re not listening anymore 57 | }) 58 | ``` 59 | 60 | ### #listWellKnownFolders() 61 | 62 | Lists folders, grouped to folders that are in the following categories: Inbox, Drafts, All, Flagged, Sent, Trash, Junk, Archive, Other. 63 | 64 | ### #createFolder(path) 65 | 66 | Creates a folder... 67 | 68 | ``` 69 | imap.createFolder({ 70 | path: ['foo', 'bar'] 71 | }).then(function(path) { 72 | // folder created 73 | console.log('created folder: ' + path); 74 | }) 75 | ``` 76 | 77 | ### #search(options, callback) 78 | 79 | Returns the uids of messages containing the search terms in the options. 80 | 81 | ``` 82 | imap.search({ 83 | answered: true, 84 | unread: true, 85 | header: ['X-Foobar', '123qweasdzxc'] 86 | }).then(function(uids) { 87 | console.log(‘uids: ‘ + uids.join(‘, ‘)) 88 | }); 89 | ``` 90 | 91 | ### #listMessages(options, callback) 92 | 93 | Lists messages in the mailbox based on their UID. 94 | 95 | ``` 96 | imap.listMessages({ 97 | path: ‘path’, the folder's path 98 | firstUid: 15, (optional) the uid of the first messagem defaults to 1 99 | lastUid: 30 (optional) the uid of the last message, defaults to ‘*’ 100 | }).then(function(messages) {}) 101 | ``` 102 | 103 | Messages have the following attributes: 104 | 105 | * uid: The UID in the mailbox 106 | * id: The Mesage-ID header (without "<>") 107 | * inReplyTo: The Message-ID that this message is a reply to 108 | * references: The Message-IDs that this message references 109 | * from, replyTo, to, cc, bcc: The Sender/Receivers 110 | * modseq: The MODSEQ number of this message (as a string – javascript numbers do not tolerate 64 bit uints) 111 | * subject: The message's subject 112 | * sentDate: The date the message was sent 113 | * unread: The unread flag 114 | * answered: The answered flag 115 | * bodyParts: Array of message parts, simplified version of a MIME tree. Used by #getBodyParts 116 | 117 | ### #getBodyParts() 118 | 119 | Fetches parts of a message from the imap server 120 | 121 | ``` 122 | imap.getBodyParts({ 123 | path: 'foo/bar', 124 | uid: someMessage.uid, 125 | bodyParts: someMessage.bodyParts 126 | }).then(function() { 127 | // all done, bodyparts can now be fed to the mailreader 128 | }) 129 | ``` 130 | 131 | ### #updateFlags(options, callback) 132 | 133 | Marks a message as un-/read or un-/answered. 134 | 135 | ``` 136 | imap.updateFlags({ 137 | path: 'foo/bar', 138 | uid: someMessage.uid, 139 | unread: true/false/undefined, // (optional) Marks the message as un-/read, no action if omitted 140 | answered: true/false/undefined // (optional) Marks the message as answered, no action if omitted 141 | }).then(function( 142 | // all done 143 | }); 144 | ``` 145 | 146 | ### #moveMessage(options, callback) 147 | 148 | Moves a message from mailbox A to mailbox B. 149 | 150 | ``` 151 | imap.moveMessage({ 152 | path: 'foo/bar', // the origin folder 153 | uid: someMessage.uid, // the message's uid 154 | destination: 'bla/bli' // the destination folder 155 | }).then(function( 156 | // all done 157 | }); 158 | ``` 159 | 160 | ### uploadMessage(options, callback) 161 | 162 | Uploads a message to a folder 163 | 164 | ``` 165 | imap.uploadMessage({ 166 | path: 'foo/bar', // the target folder 167 | message: '...' // RFC-2822 compliant string 168 | }).then(function( 169 | // all done 170 | }); 171 | ``` 172 | 173 | ### #deleteMessage(options, callback) 174 | 175 | Deletes a message from a folder 176 | 177 | ``` 178 | imap.deleteMessage({ 179 | path: 'foo/bar', // the folder from which to delete the message 180 | uid: someMessage.uid, // the message's uid 181 | }).then(function( 182 | // all done 183 | }); 184 | ``` 185 | 186 | ### #onSyncUpdate 187 | 188 | If there are updates available for an IMAP folder, you will receive the changed UIDs in the `#onSyncUpdate` callback. The IMAP client invokes the callback if there are new/changes messages after a mailbox has been selected and on IMAP expunge/exists/fetch updates have been pushed from the server. 189 | If this handler is not set, you will not receive updates from IMAP. 190 | 191 | ``` 192 | var SYNC_TYPE_NEW = 'new'; 193 | var SYNC_TYPE_DELETED = 'deleted'; 194 | var SYNC_TYPE_MSGS = 'messages'; 195 | 196 | imap.onSyncUpdate = function(options) { 197 | var updatedMesages = options.list, 198 | updatesMailbox = options.path 199 | 200 | if (options.type === SYNC_TYPE_NEW) { 201 | // new messages available on imap 202 | // updatedMesages is an array of the newly available UIDs 203 | } else if (options.type === SYNC_TYPE_DELETED) { 204 | // messages have been deleted 205 | // updatedMesages is an array of the deleted UIDs 206 | } else if (options.type === SYNC_TYPE_MSGS) { 207 | // NB! several possible reasons why this could be called. 208 | // updatedMesages is an array of objects 209 | // if an object in the array has uid value and flags array, it had a possible flag update 210 | } 211 | }; 212 | ``` 213 | 214 | ## Getting started 215 | 216 | Run the following commands to get started: 217 | 218 | npm install && grunt 219 | 220 | ## License 221 | 222 | ``` 223 | The MIT License (MIT) 224 | 225 | Copyright (c) 2014 Whiteout Networks GmbH 226 | 227 | Permission is hereby granted, free of charge, to any person obtaining a copy of 228 | this software and associated documentation files (the "Software"), to deal in 229 | the Software without restriction, including without limitation the rights to 230 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 231 | the Software, and to permit persons to whom the Software is furnished to do so, 232 | subject to the following conditions: 233 | 234 | The above copyright notice and this permission notice shall be included in all 235 | copies or substantial portions of the Software. 236 | 237 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 238 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 239 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 240 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 241 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 242 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 243 | ``` 244 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imap-client", 3 | "version": "0.14.3", 4 | "scripts": { 5 | "pretest": "dir=$(pwd) && cd node_modules/browserbox/node_modules/tcp-socket/node_modules/node-forge/ && npm install && npm run minify && cd $dir", 6 | "test": "grunt" 7 | }, 8 | "main": "src/imap-client.js", 9 | "dependencies": { 10 | "browserbox": "~0.9.0", 11 | "axe-logger": "~0.0.2" 12 | }, 13 | "devDependencies": { 14 | "es6-promise": "^2.0.1", 15 | "chai": "~1.9.2", 16 | "grunt": "~0.4.1", 17 | "grunt-mocha-phantomjs": "~0.7.0", 18 | "grunt-contrib-connect": "~0.6.0", 19 | "grunt-contrib-jshint": "~0.8.0", 20 | "grunt-contrib-copy": "^0.5.0", 21 | "grunt-contrib-clean": "^0.5.0", 22 | "grunt-contrib-watch": "^0.6.1", 23 | "phantomjs": "~1.9.7-1", 24 | "sinon": "1.7.3", 25 | "requirejs": "2.1.x", 26 | "hoodiecrow": "1.1.x", 27 | "mocha": "^1.18.2", 28 | "grunt-mocha-test": "^0.10.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/imap-client.js: -------------------------------------------------------------------------------- 1 | (function(factory) { 2 | 'use strict'; 3 | 4 | if (typeof define === 'function' && define.amd) { 5 | define(['browserbox', 'axe'], factory); 6 | } else if (typeof exports === 'object') { 7 | module.exports = factory(require('browserbox'), require('axe-logger')); 8 | } 9 | })(function(BrowserBox, axe) { 10 | 'use strict'; 11 | 12 | var DEBUG_TAG = 'imap-client'; 13 | 14 | /** 15 | * Create an instance of ImapClient. 16 | * @param {Number} options.port Port is the port to the server (defaults to 143 on non-secure and to 993 on secure connection). 17 | * @param {String} options.host Hostname of the server. 18 | * @param {Boolean} options.secure Indicates if the connection is using TLS or not 19 | * @param {String} options.auth.user Username for login 20 | * @param {String} options.auth.pass Password for login 21 | * @param {String} options.auth.xoauth2 xoauth2 token for login 22 | * @param {Array} options.ca Array of PEM-encoded certificates that should be pinned. 23 | * @param {Number} options.maxUpdateSize (optional) The maximum number of messages to receive in an onSyncUpdate of type "new". 0 = all messages. Defaults to 0. 24 | */ 25 | var ImapClient = function(options, browserbox) { 26 | var self = this; 27 | 28 | /* 29 | * Holds the login state. 30 | */ 31 | self._loggedIn = false; 32 | self._listenerLoggedIn = false; 33 | 34 | /* 35 | * Instance of our imap library 36 | * (only relevant in unit test environment) 37 | */ 38 | if (browserbox) { 39 | self._client = self._listeningClient = browserbox; 40 | } else { 41 | var credentials = { 42 | useSecureTransport: options.secure, 43 | ignoreTLS: options.ignoreTLS, 44 | requireTLS: options.requireTLS, 45 | auth: options.auth, 46 | ca: options.ca, 47 | tlsWorkerPath: options.tlsWorkerPath, 48 | enableCompression: true, // enable compression by default 49 | compressionWorkerPath: options.compressionWorkerPath 50 | }; 51 | self._client = new BrowserBox(options.host, options.port, credentials); 52 | self._listeningClient = new BrowserBox(options.host, options.port, credentials); 53 | } 54 | 55 | /* 56 | * Calls the upper layer if the TLS certificate has to be updated 57 | */ 58 | self._client.oncert = self._listeningClient.oncert = function(certificate) { 59 | self.onCert(certificate); 60 | }; 61 | 62 | /** 63 | * Cache object with the following structure: 64 | * 65 | * { 66 | * "INBOX": { 67 | * exists: 5, 68 | * uidNext: 6, 69 | * uidlist: [1, 2, 3, 4, 5], 70 | * highestModseq: "555" 71 | * } 72 | * } 73 | * 74 | * @type {Object} 75 | */ 76 | self.mailboxCache = {}; 77 | 78 | self._registerEventHandlers(self._client); 79 | self._registerEventHandlers(self._listeningClient); 80 | }; 81 | 82 | /** 83 | * Register the event handlers for the respective imap client 84 | */ 85 | ImapClient.prototype._registerEventHandlers = function(client) { 86 | client.onselectmailbox = this._onSelectMailbox.bind(this, client); 87 | client.onupdate = this._onUpdate.bind(this, client); 88 | client.onclose = this._onClose.bind(this, client); 89 | client.onerror = this._onError.bind(this, client); 90 | }; 91 | 92 | /** 93 | * Informs the upper layer if the main IMAP connection errors and cleans up. 94 | * If the listening IMAP connection fails, it only logs the error. 95 | */ 96 | ImapClient.prototype._onError = function(client, err) { 97 | var msg = 'IMAP connection encountered an error! ' + err; 98 | 99 | if (client === this._client) { 100 | this._loggedIn = false; 101 | client.close(); 102 | axe.error(DEBUG_TAG, new Error(msg)); 103 | this.onError(new Error(msg)); // report the error 104 | } else if (client === this._listeningClient) { 105 | this._listenerLoggedIn = false; 106 | client.close(); 107 | axe.warn(DEBUG_TAG, new Error('Listening ' + msg)); 108 | } 109 | }; 110 | 111 | /** 112 | * Informs the upper layer if the main IMAP connection has been unexpectedly closed remotely. 113 | * If the listening IMAP connection is closed unexpectedly, it only logs the error 114 | */ 115 | ImapClient.prototype._onClose = function(client) { 116 | var msg = 'IMAP connection closed unexpectedly!'; 117 | 118 | if (client === this._client && this._loggedIn) { 119 | this._loggedIn = false; 120 | axe.error(DEBUG_TAG, new Error(msg)); 121 | this.onError(new Error(msg)); // report the error 122 | } else if (client === this._listeningClient && this._listenerLoggedIn) { 123 | this._listenerLoggedIn = false; 124 | axe.warn(DEBUG_TAG, new Error('Listening ' + msg)); 125 | } 126 | }; 127 | 128 | /** 129 | * Executed whenever 'onselectmailbox' event is emitted in BrowserBox 130 | * 131 | * @param {Object} client Listening client object 132 | * @param {String} path Path to currently opened mailbox 133 | * @param {Object} mailbox Information object for the opened mailbox 134 | */ 135 | ImapClient.prototype._onSelectMailbox = function(client, path, mailbox) { 136 | var self = this, 137 | cached; 138 | 139 | // If both clients are currently listening the same mailbox, ignore data from listeningClient 140 | if (client === self._listeningClient && self._listeningClient.selectedMailbox === self._client.selectedMailbox) { 141 | return; 142 | } 143 | 144 | if (!self.onSyncUpdate) { 145 | return; 146 | } 147 | 148 | axe.debug(DEBUG_TAG, 'selected mailbox ' + path); 149 | 150 | // populate the cahce object for current path 151 | if (!self.mailboxCache[path]) { 152 | axe.debug(DEBUG_TAG, 'populating cache object for mailbox ' + path); 153 | self.mailboxCache[path] = { 154 | exists: 0, 155 | uidNext: 0, 156 | uidlist: [] 157 | }; 158 | } 159 | 160 | cached = self.mailboxCache[path]; 161 | 162 | // if exists count does not match, there might be new messages 163 | // if exists count matches but uidNext is different, then something has been deleted and something added 164 | if (cached.exists !== mailbox.exists || cached.uidNext !== mailbox.uidNext) { 165 | axe.debug(DEBUG_TAG, 'possible updates available in ' + path + '. exists: ' + mailbox.exists + ', uidNext: ' + mailbox.uidNext); 166 | 167 | var firstUpdate = cached.exists === 0; 168 | 169 | cached.exists = mailbox.exists; 170 | cached.uidNext = mailbox.uidNext; 171 | 172 | // list all uid values in the selected mailbox 173 | self.search({ 174 | path: path, 175 | client: client 176 | }).then(function(imapUidList) { 177 | // normalize the uidlist 178 | cached.uidlist = cached.uidlist || []; 179 | 180 | // determine deleted uids 181 | var deltaDeleted = cached.uidlist.filter(function(i) { 182 | return imapUidList.indexOf(i) < 0; 183 | }); 184 | 185 | // notify about deleted messages 186 | if (deltaDeleted.length) { 187 | axe.debug(DEBUG_TAG, 'onSyncUpdate for deleted uids in ' + path + ': ' + deltaDeleted); 188 | self.onSyncUpdate({ 189 | type: 'deleted', 190 | path: path, 191 | list: deltaDeleted 192 | }); 193 | } 194 | 195 | // determine new uids 196 | var deltaNew = imapUidList.filter(function(i) { 197 | return cached.uidlist.indexOf(i) < 0; 198 | }).sort(sortNumericallyDescending); 199 | 200 | // notify about new messages 201 | if (deltaNew.length) { 202 | axe.debug(DEBUG_TAG, 'new uids in ' + path + ': ' + deltaNew); 203 | self.onSyncUpdate({ 204 | type: 'new', 205 | path: path, 206 | list: deltaNew 207 | }); 208 | } 209 | 210 | // update mailbox info 211 | cached.uidlist = imapUidList; 212 | 213 | if (!firstUpdate) { 214 | axe.debug(DEBUG_TAG, 'no changes in message count in ' + path + '. exists: ' + mailbox.exists + ', uidNext: ' + mailbox.uidNext); 215 | self._checkModseq({ 216 | highestModseq: mailbox.highestModseq, 217 | client: client 218 | }).catch(function(error) { 219 | axe.error(DEBUG_TAG, 'error checking modseq: ' + error + '\n' + error.stack); 220 | }); 221 | } 222 | }); 223 | } else { 224 | // check for changed flags 225 | axe.debug(DEBUG_TAG, 'no changes in message count in ' + path + '. exists: ' + mailbox.exists + ', uidNext: ' + mailbox.uidNext); 226 | self._checkModseq({ 227 | highestModseq: mailbox.highestModseq, 228 | client: client 229 | }).catch(function(error) { 230 | axe.error(DEBUG_TAG, 'error checking modseq: ' + error + '\n' + error.stack); 231 | }); 232 | } 233 | }; 234 | 235 | ImapClient.prototype._onUpdate = function(client, type, value) { 236 | var self = this, 237 | path = client.selectedMailbox, 238 | cached = self.mailboxCache[path]; 239 | 240 | if (!self.onSyncUpdate) { 241 | return; 242 | } 243 | 244 | // If both clients are currently listening the same mailbox, ignore data from listeningClient 245 | if (client === self._listeningClient && self._listeningClient.selectedMailbox === self._client.selectedMailbox) { 246 | return; 247 | } 248 | 249 | if (!cached) { 250 | return; 251 | } 252 | 253 | if (type === 'expunge') { 254 | axe.debug(DEBUG_TAG, 'expunge notice received for ' + path + ' with sequence number: ' + value); 255 | // a message has been deleted 256 | // input format: "* EXPUNGE 123" where 123 is the sequence number of the deleted message 257 | 258 | var deletedUid = cached.uidlist[value - 1]; 259 | // reorder the uidlist by removing deleted item 260 | cached.uidlist.splice(value - 1, 1); 261 | 262 | if (deletedUid) { 263 | axe.debug(DEBUG_TAG, 'deleted uid in ' + path + ': ' + deletedUid); 264 | self.onSyncUpdate({ 265 | type: 'deleted', 266 | path: path, 267 | list: [deletedUid] 268 | }); 269 | } 270 | } else if (type === 'exists') { 271 | axe.debug(DEBUG_TAG, 'exists notice received for ' + path + ', checking for updates'); 272 | 273 | // there might be new messages (or something was deleted) as the message count in the mailbox has changed 274 | // input format: "* EXISTS 123" where 123 is the count of messages in the mailbox 275 | cached.exists = value; 276 | self.search({ 277 | path: path, 278 | // search for messages with higher UID than last known uidNext 279 | uid: cached.uidNext + ':*', 280 | client: client 281 | }).then(function(imapUidList) { 282 | // if we do not find anything or the returned item was already known then return 283 | // if there was no new messages then we get back a single element array where the element 284 | // is the message with the highest UID value ('*' -> highest UID) 285 | // ie. if the largest UID in the mailbox is 100 and we search for 123:* then the query is 286 | // translated to 100:123 as '*' is 100 and this matches the element 100 that we already know about 287 | if (!imapUidList.length || (imapUidList.length === 1 && cached.uidlist.indexOf(imapUidList[0]) >= 0)) { 288 | return; 289 | } 290 | 291 | imapUidList.sort(sortNumericallyDescending); 292 | axe.debug(DEBUG_TAG, 'new uids in ' + path + ': ' + imapUidList); 293 | // update cahced uid list 294 | cached.uidlist = cached.uidlist.concat(imapUidList); 295 | // predict the next UID, might not be the actual value set by the server 296 | cached.uidNext = cached.uidlist[cached.uidlist.length - 1] + 1; 297 | 298 | // notify about new messages 299 | axe.debug(DEBUG_TAG, 'new uids in ' + path + ': ' + imapUidList); 300 | self.onSyncUpdate({ 301 | type: 'new', 302 | path: path, 303 | list: imapUidList 304 | }); 305 | }).catch(function(error) { 306 | axe.error(DEBUG_TAG, 'error handling exists notice: ' + error + '\n' + error.stack); 307 | }); 308 | } else if (type === 'fetch') { 309 | axe.debug(DEBUG_TAG, 'fetch notice received for ' + path); 310 | 311 | // probably some flag updates. A message or messages have been altered in some way 312 | // and the server sends an unsolicited FETCH response 313 | // input format: "* 123 FETCH (FLAGS (\Seen))" 314 | // UID is probably not listed, only the sequence number 315 | self.onSyncUpdate({ 316 | type: 'messages', 317 | path: path, 318 | // listed message object does not contain uid by default 319 | list: [value].map(function(message) { 320 | if (!message.uid && cached.uidlist) { 321 | message.uid = cached.uidlist[message['#'] - 1]; 322 | } 323 | return message; 324 | }) 325 | }); 326 | } 327 | }; 328 | 329 | /** 330 | * Lists messages with the last check 331 | * 332 | * @param {String} options.highestModseq MODSEQ value 333 | * 334 | * @return {Promise} 335 | */ 336 | ImapClient.prototype._checkModseq = function(options) { 337 | var self = this, 338 | highestModseq = options.highestModseq, 339 | client = options.client || self._client, 340 | path = client.selectedMailbox; 341 | 342 | // do nothing if we do not have highestModseq value. it should be at least 1. if it is 343 | // undefined then the server does not support CONDSTORE extension. 344 | // Yahoo supports a custom MODSEQ related extension called XYMHIGHESTMODSEQ which 345 | // returns HIGHESTMODSEQ value when doing SELECT but does not allow to use the CHANGEDSINCE modifier 346 | // or query the message MODSEQ value. Returned HIGHESTMODSEQ also happens to be a 64 bit number that 347 | // is larger than Number.MAX_SAFE_INTEGER so it can't be used as a numeric value. To fix errors 348 | // with Yahoo we double check if the CONDSTORE is listed as a capability or not as checking just 349 | // the highestModseq value would give us a false positive. 350 | if (!client.hasCapability('CONDSTORE') || !highestModseq || !path) { 351 | axe.info(DEBUG_TAG, 'can not check MODSEQ, server does not support CONDSTORE extension'); 352 | return new Promise(function(resolve) { 353 | resolve([]); 354 | }); 355 | } 356 | 357 | var cached = self.mailboxCache[path]; 358 | 359 | // only do this when we actually do have a last know change number 360 | if (!(cached && cached.highestModseq && cached.highestModseq !== highestModseq)) { 361 | return new Promise(function(resolve) { 362 | resolve([]); 363 | }); 364 | } 365 | 366 | var msgs = cached.uidlist.slice(-100); 367 | var firstUid = (msgs.shift() || '1'); 368 | var lastUid = (msgs.pop() || '*'); 369 | 370 | axe.debug(DEBUG_TAG, 'listing changes since MODSEQ ' + highestModseq + ' for ' + path); 371 | return client.listMessages(firstUid + ':' + lastUid, ['uid', 'flags', 'modseq'], { 372 | byUid: true, 373 | changedSince: cached.highestModseq 374 | }).then(function(messages) { 375 | cached.highestModseq = highestModseq; 376 | 377 | if (!messages || !messages.length) { 378 | return []; 379 | } 380 | 381 | axe.debug(DEBUG_TAG, 'changes since MODSEQ ' + highestModseq + ' for ' + path + ' available!'); 382 | self.onSyncUpdate({ 383 | type: 'messages', 384 | path: path, 385 | list: messages 386 | }); 387 | 388 | return messages; 389 | }).catch(function(error) { 390 | axe.error(DEBUG_TAG, 'error handling exists notice: ' + error + '\n' + error.stack); 391 | }); 392 | }; 393 | 394 | /** 395 | * Synchronization handler 396 | * 397 | * type 'new' returns an array of UID values that are new messages 398 | * type 'deleted' returns an array of UID values that are deleted 399 | * type 'messages' returns an array of message objects that are somehow updated 400 | * 401 | * @param {Object} options Notification options 402 | * @param {String} options.type Type of the update 403 | * @param {Array} options.list List of uids/messages 404 | * @param {String} options.path Selected mailbox 405 | */ 406 | ImapClient.prototype.onSyncUpdate = false; 407 | 408 | /** 409 | * Log in to an IMAP Session. No-op if already logged in. 410 | * 411 | * @return {Prmomise} 412 | */ 413 | ImapClient.prototype.login = function() { 414 | var self = this; 415 | 416 | return new Promise(function(resolve, reject) { 417 | if (self._loggedIn) { 418 | axe.debug(DEBUG_TAG, 'refusing login while already logged in!'); 419 | return resolve(); 420 | } 421 | var authenticating = true; 422 | 423 | self._client.onauth = function() { 424 | axe.debug(DEBUG_TAG, 'login completed, ready to roll!'); 425 | self._loggedIn = true; 426 | authenticating = false; 427 | return resolve(); 428 | }; 429 | 430 | self._client.onerror = function(error) { 431 | if (!self._loggedIn && authenticating) { 432 | axe.debug(DEBUG_TAG, 'login failed! ' + error); 433 | authenticating = false; 434 | return reject(error); 435 | } 436 | }; 437 | 438 | self._client.connect(); 439 | }); 440 | }; 441 | 442 | /** 443 | * Log out of the current IMAP session 444 | * 445 | * @return {Promise} 446 | */ 447 | ImapClient.prototype.logout = function() { 448 | var self = this; 449 | 450 | return new Promise(function(resolve) { 451 | if (!self._loggedIn) { 452 | axe.debug(DEBUG_TAG, 'refusing logout while already logged out!'); 453 | return resolve(); 454 | } 455 | 456 | self._client.onclose = function() { 457 | axe.debug(DEBUG_TAG, 'logout completed, kthxbye!'); 458 | resolve(); 459 | }; 460 | 461 | self._loggedIn = false; 462 | self._client.close(); 463 | }); 464 | }; 465 | 466 | /** 467 | * Starts dedicated listener for updates on a specific IMAP folder, calls back when a change occurrs, 468 | * or includes information in case of an error 469 | 470 | * @param {String} options.path The path to the folder to subscribe to 471 | * 472 | * @return {Promise} 473 | */ 474 | ImapClient.prototype.listenForChanges = function(options) { 475 | var self = this; 476 | 477 | return new Promise(function(resolve, reject) { 478 | if (self._listenerLoggedIn) { 479 | axe.debug(DEBUG_TAG, 'refusing login listener while already logged in!'); 480 | return resolve(); 481 | } 482 | 483 | self._listeningClient.onauth = function() { 484 | axe.debug(DEBUG_TAG, 'listener login completed, ready to roll!'); 485 | self._listenerLoggedIn = true; 486 | axe.debug(DEBUG_TAG, 'listening for changes in ' + options.path); 487 | self._listeningClient.selectMailbox(options.path).then(resolve).catch(reject); 488 | }; 489 | self._listeningClient.connect(); 490 | }); 491 | }; 492 | 493 | /** 494 | * Stops dedicated listener for updates 495 | * 496 | * @return {Promise} 497 | */ 498 | ImapClient.prototype.stopListeningForChanges = function() { 499 | var self = this; 500 | 501 | return new Promise(function(resolve) { 502 | if (!self._listenerLoggedIn) { 503 | axe.debug(DEBUG_TAG, 'refusing logout listener already logged out!'); 504 | return resolve(); 505 | } 506 | 507 | self._listeningClient.onclose = function() { 508 | axe.debug(DEBUG_TAG, 'logout completed, kthxbye!'); 509 | resolve(); 510 | }; 511 | 512 | self._listenerLoggedIn = false; 513 | self._listeningClient.close(); 514 | }); 515 | }; 516 | 517 | ImapClient.prototype.selectMailbox = function(options) { 518 | axe.debug(DEBUG_TAG, 'selecting mailbox ' + options.path); 519 | return this._client.selectMailbox(options.path); 520 | }; 521 | 522 | /** 523 | * Provides the well known folders: Drafts, Sent, Inbox, Trash, Flagged, etc. No-op if not logged in. 524 | * Since there may actually be multiple sent folders (e.g. one is default, others were created by Thunderbird, 525 | * Outlook, another accidentally matched the naming), we return the well known folders as an array to avoid false positives. 526 | * 527 | * @return {Promise} Array of folders 528 | */ 529 | ImapClient.prototype.listWellKnownFolders = function() { 530 | var self = this; 531 | 532 | var wellKnownFolders = { 533 | Inbox: [], 534 | Drafts: [], 535 | All: [], 536 | Flagged: [], 537 | Sent: [], 538 | Trash: [], 539 | Junk: [], 540 | Archive: [], 541 | Other: [] 542 | }; 543 | 544 | axe.debug(DEBUG_TAG, 'listing folders'); 545 | 546 | return self._checkOnline().then(function() { 547 | return self._client.listMailboxes(); 548 | }).then(function(mailbox) { 549 | axe.debug(DEBUG_TAG, 'folder list received!'); 550 | walkMailbox(mailbox); 551 | return wellKnownFolders; 552 | 553 | }).catch(function(error) { 554 | axe.error(DEBUG_TAG, 'error listing folders: ' + error + '\n' + error.stack); 555 | throw error; 556 | }); 557 | 558 | function walkMailbox(mailbox) { 559 | if (mailbox.path && (mailbox.flags || []).indexOf("\\Noselect") === -1) { 560 | // only list mailboxes here that have a path and are selectable 561 | axe.debug(DEBUG_TAG, 'name: ' + mailbox.name + ', path: ' + mailbox.path + (mailbox.flags ? (', flags: ' + mailbox.flags) : '') + (mailbox.specialUse ? (', special use: ' + mailbox.specialUse) : '')); 562 | 563 | var folder = { 564 | name: mailbox.name || mailbox.path, 565 | path: mailbox.path 566 | }; 567 | 568 | if (folder.name.toUpperCase() === 'INBOX') { 569 | folder.type = 'Inbox'; 570 | self._delimiter = mailbox.delimiter; 571 | wellKnownFolders.Inbox.push(folder); 572 | } else if (mailbox.specialUse === '\\Drafts') { 573 | folder.type = 'Drafts'; 574 | wellKnownFolders.Drafts.push(folder); 575 | } else if (mailbox.specialUse === '\\All') { 576 | folder.type = 'All'; 577 | wellKnownFolders.All.push(folder); 578 | } else if (mailbox.specialUse === '\\Flagged') { 579 | folder.type = 'Flagged'; 580 | wellKnownFolders.Flagged.push(folder); 581 | } else if (mailbox.specialUse === '\\Sent') { 582 | folder.type = 'Sent'; 583 | wellKnownFolders.Sent.push(folder); 584 | } else if (mailbox.specialUse === '\\Trash') { 585 | folder.type = 'Trash'; 586 | wellKnownFolders.Trash.push(folder); 587 | } else if (mailbox.specialUse === '\\Junk') { 588 | folder.type = 'Junk'; 589 | wellKnownFolders.Junk.push(folder); 590 | } else if (mailbox.specialUse === '\\Archive') { 591 | folder.type = 'Archive'; 592 | wellKnownFolders.Archive.push(folder); 593 | } else { 594 | folder.type = 'Other'; 595 | wellKnownFolders.Other.push(folder); 596 | } 597 | } 598 | 599 | if (mailbox.children) { 600 | // walk the child mailboxes recursively 601 | mailbox.children.forEach(walkMailbox); 602 | } 603 | } 604 | }; 605 | 606 | /** 607 | * Creates a folder with the provided path under the personal namespace 608 | * 609 | * @param {String or Array} options.path 610 | * The folder's path. If path is a hierarchy as an array (e.g. ['foo', 'bar', 'baz'] to create foo/bar/bar), 611 | * will create a hierarchy with all intermediate folders if needed. 612 | * @returns {Promise} Fully qualified path of the folder just created 613 | */ 614 | ImapClient.prototype.createFolder = function(options) { 615 | var self = this, 616 | path = options.path, 617 | fullPath; 618 | 619 | if (!Array.isArray(path)) { 620 | path = [path]; 621 | } 622 | 623 | return self._checkOnline().then(function() { 624 | // spare the check 625 | if (typeof self._delimiter !== 'undefined' && typeof self._prefix !== 'undefined') { 626 | return; 627 | } 628 | 629 | // try to get the namespace prefix and delimiter 630 | return self._client.listNamespaces().then(function(namespaces) { 631 | if (namespaces && namespaces.personal && namespaces.personal[0]) { 632 | // personal namespace is available 633 | self._delimiter = namespaces.personal[0].delimiter; 634 | self._prefix = namespaces.personal[0].prefix.split(self._delimiter).shift(); 635 | return; 636 | } 637 | 638 | // no namespaces, falling back to empty prefix 639 | self._prefix = ""; 640 | 641 | // if we already have the delimiter, there's no need to retrieve the lengthy folder list 642 | if (self._delimiter) { 643 | return; 644 | } 645 | 646 | // find the delimiter by listing the folders 647 | return self._client.listMailboxes().then(function(response) { 648 | findDelimiter(response); 649 | }); 650 | }); 651 | 652 | }).then(function() { 653 | if (!self._delimiter) { 654 | throw new Error('Could not determine delimiter for mailbox hierarchy'); 655 | } 656 | 657 | if (self._prefix) { 658 | path.unshift(self._prefix); 659 | } 660 | 661 | fullPath = path.join(self._delimiter); 662 | 663 | // create path [prefix/]foo/bar/baz 664 | return self._client.createMailbox(fullPath); 665 | 666 | }).then(function() { 667 | return fullPath; 668 | 669 | }).catch(function(error) { 670 | axe.error(DEBUG_TAG, 'error creating folder ' + options.path + ': ' + error + '\n' + error.stack); 671 | throw error; 672 | }); 673 | 674 | // Helper function to find the hierarchy delimiter from a client.listMailboxes() response 675 | function findDelimiter(mailbox) { 676 | if ((mailbox.path || '').toUpperCase() === 'INBOX') { 677 | // found the INBOX, use its hierarchy delimiter, we're done. 678 | self._delimiter = mailbox.delimiter; 679 | return; 680 | } 681 | 682 | if (mailbox.children) { 683 | // walk the child mailboxes recursively 684 | mailbox.children.forEach(findDelimiter); 685 | } 686 | } 687 | }; 688 | 689 | /** 690 | * Returns the uids of messages containing the search terms in the options 691 | * @param {String} options.path The folder's path 692 | * @param {Boolean} options.answered (optional) Mails with or without the \Answered flag set. 693 | * @param {Boolean} options.unread (optional) Mails with or without the \Seen flag set. 694 | * @param {Array} options.header (optional) Query an arbitrary header, e.g. ['Subject', 'Foobar'], or ['X-Foo', 'bar'] 695 | * 696 | * @returns {Promise} Array of uids for messages matching the search terms 697 | */ 698 | ImapClient.prototype.search = function(options) { 699 | var self = this, 700 | client = options.client || self._client; 701 | 702 | var query = {}, 703 | queryOptions = { 704 | byUid: true, 705 | precheck: self._ensurePath(options.path, client) 706 | }; 707 | 708 | // initial request to AND the following properties 709 | query.all = true; 710 | 711 | if (options.unread === true) { 712 | query.unseen = true; 713 | } else if (options.unread === false) { 714 | query.seen = true; 715 | } 716 | 717 | if (options.answered === true) { 718 | query.answered = true; 719 | } else if (options.answered === false) { 720 | query.unanswered = true; 721 | } 722 | 723 | if (options.header) { 724 | query.header = options.header; 725 | } 726 | 727 | if (options.uid) { 728 | query.uid = options.uid; 729 | } 730 | 731 | axe.debug(DEBUG_TAG, 'searching in ' + options.path + ' for ' + Object.keys(query).join(',')); 732 | return self._checkOnline().then(function() { 733 | return client.search(query, queryOptions); 734 | }).then(function(uids) { 735 | axe.debug(DEBUG_TAG, 'searched in ' + options.path + ' for ' + Object.keys(query).join(',') + ': ' + uids); 736 | return uids; 737 | }).catch(function(error) { 738 | axe.error(DEBUG_TAG, 'error searching ' + options.path + ': ' + error + '\n' + error.stack); 739 | throw error; 740 | }); 741 | }; 742 | 743 | /** 744 | * List messages in an IMAP folder based on their uid 745 | * @param {String} options.path The folder's path 746 | * @param {Number} options.firstUid (optional) If you want to fetch a range, this is the uid of the first message. if omitted, defaults to 1 747 | * @param {Number} options.lastUid (optional) The uid of the last message. if omitted, defaults to * 748 | * @param {Array} options.uids (optional) If used, fetched individual uids 749 | * 750 | * @returns {Promise} Array of messages with their respective envelope data. 751 | */ 752 | ImapClient.prototype.listMessages = function(options) { 753 | var self = this; 754 | 755 | var query = ['uid', 'bodystructure', 'flags', 'envelope', 'body.peek[header.fields (references)]'], 756 | queryOptions = { 757 | byUid: true, 758 | precheck: self._ensurePath(options.path) 759 | }, 760 | interval; 761 | 762 | if (options.uids) { 763 | interval = options.uids.join(','); 764 | } else { 765 | interval = (options.firstUid || 1) + ':' + (options.lastUid || '*'); 766 | } 767 | 768 | // only if client has CONDSTORE capability 769 | if (this._client.hasCapability('CONDSTORE')) { 770 | query.push('modseq'); 771 | } 772 | 773 | axe.debug(DEBUG_TAG, 'listing messages in ' + options.path + ' for interval ' + interval); 774 | return self._checkOnline().then(function() { 775 | return self._client.listMessages(interval, query, queryOptions); 776 | }).then(function(messages) { 777 | // a message without uid will be ignored as malformed 778 | messages = messages.filter(function(message) { 779 | return !!message.uid; 780 | }); 781 | 782 | var cleansedMessages = []; 783 | messages.forEach(function(message) { 784 | // construct a cleansed message object 785 | 786 | var references = (message['body[header.fields (references)]'] || '').replace(/^references:\s*/i, '').trim(); 787 | 788 | var cleansed = { 789 | uid: message.uid, 790 | id: (message.envelope['message-id'] || '').replace(/[<>]/g, ''), 791 | from: message.envelope.from || [], 792 | replyTo: message.envelope['reply-to'] || [], 793 | to: message.envelope.to || [], 794 | cc: message.envelope.cc || [], 795 | bcc: message.envelope.bcc || [], 796 | modseq: message.modseq || '0', 797 | subject: message.envelope.subject || '(no subject)', 798 | inReplyTo: (message.envelope['in-reply-to'] || '').replace(/[<>]/g, ''), 799 | references: references ? references.split(/\s+/).map(function(reference) { 800 | return reference.replace(/[<>]/g, ''); 801 | }) : [], 802 | sentDate: message.envelope.date ? new Date(message.envelope.date) : new Date(), 803 | unread: (message.flags || []).indexOf('\\Seen') === -1, 804 | flagged: (message.flags || []).indexOf('\\Flagged') > -1, 805 | answered: (message.flags || []).indexOf('\\Answered') > -1, 806 | bodyParts: [] 807 | }; 808 | 809 | walkMimeTree((message.bodystructure || {}), cleansed); 810 | cleansed.encrypted = cleansed.bodyParts.filter(function(bodyPart) { 811 | return bodyPart.type === 'encrypted'; 812 | }).length > 0; 813 | cleansed.signed = cleansed.bodyParts.filter(function(bodyPart) { 814 | return bodyPart.type === 'signed'; 815 | }).length > 0; 816 | 817 | axe.debug(DEBUG_TAG, 'listing message: [uid: ' + cleansed.uid + '][encrypted: ' + cleansed.encrypted + '][signed: ' + cleansed.signed + ']'); 818 | cleansedMessages.push(cleansed); 819 | }); 820 | 821 | return cleansedMessages; 822 | }).catch(function(error) { 823 | axe.error(DEBUG_TAG, 'error listing messages in ' + options.path + ': ' + error + '\n' + error.stack); 824 | throw error; 825 | }); 826 | }; 827 | 828 | /** 829 | * Fetches parts of a message from the imap server 830 | * @param {String} options.path The folder's path 831 | * @param {Number} options.uid The uid of the message 832 | * @param {Array} options.bodyParts Parts of a message, as returned by #listMessages 833 | 834 | * @returns {Promise} Body parts that have been received from the server 835 | */ 836 | ImapClient.prototype.getBodyParts = function(options) { 837 | var self = this, 838 | query = [], 839 | queryOptions = { 840 | byUid: true, 841 | precheck: self._ensurePath(options.path) 842 | }, 843 | interval = options.uid + ':' + options.uid, 844 | bodyParts = options.bodyParts || []; 845 | 846 | // formulate a query for each text part. for part 2.1 to be parsed, we need 2.1.MIME and 2.1 847 | bodyParts.forEach(function(bodyPart) { 848 | if (typeof bodyPart.partNumber === 'undefined') { 849 | return; 850 | } 851 | 852 | if (bodyPart.partNumber === '') { 853 | query.push('body.peek[]'); 854 | } else { 855 | query.push('body.peek[' + bodyPart.partNumber + '.mime]'); 856 | query.push('body.peek[' + bodyPart.partNumber + ']'); 857 | } 858 | }); 859 | 860 | if (query.length === 0) { 861 | return new Promise(function(resolve) { 862 | resolve(bodyParts); 863 | }); 864 | } 865 | 866 | axe.debug(DEBUG_TAG, 'retrieving body parts for uid ' + options.uid + ' in folder ' + options.path + ': ' + query); 867 | return self._checkOnline().then(function() { 868 | return self._client.listMessages(interval, query, queryOptions); 869 | }).then(function(messages) { 870 | axe.debug(DEBUG_TAG, 'successfully retrieved body parts for uid ' + options.uid + ' in folder ' + options.path + ': ' + query); 871 | 872 | var message = messages[0]; 873 | if (!message) { 874 | // message has been deleted while waiting for the command to return 875 | return bodyParts; 876 | } 877 | 878 | bodyParts.forEach(function(bodyPart) { 879 | if (typeof bodyPart.partNumber === 'undefined') { 880 | return; 881 | } 882 | 883 | if (bodyPart.partNumber === '') { 884 | bodyPart.raw = message['body[]']; 885 | } else { 886 | bodyPart.raw = message['body[' + bodyPart.partNumber + '.mime]'] + message['body[' + bodyPart.partNumber + ']']; 887 | } 888 | 889 | delete bodyPart.partNumber; 890 | }); 891 | 892 | return bodyParts; 893 | }).catch(function(error) { 894 | axe.error(DEBUG_TAG, 'error fetching body parts for uid ' + options.uid + ' in folder ' + options.path + ': ' + error + '\n' + error.stack); 895 | throw error; 896 | }); 897 | }; 898 | 899 | /** 900 | * Update IMAP flags for a message with a given UID 901 | * @param {String} options.path The folder's path 902 | * @param {Number} options.uid The uid of the message 903 | * @param {Boolean} options.unread (optional) Marks the message as unread 904 | * @param {Boolean} options.answered (optional) Marks the message as answered 905 | * @param {Boolean} options.flagged (optional) Marks the message as answered 906 | * 907 | * @returns {Promise} 908 | */ 909 | ImapClient.prototype.updateFlags = function(options) { 910 | var self = this, 911 | interval = options.uid + ':' + options.uid, 912 | queryOptions = { 913 | byUid: true, 914 | precheck: self._ensurePath(options.path) 915 | }, 916 | queryAdd, 917 | queryRemove, 918 | remove = [], 919 | add = [], 920 | READ_FLAG = '\\Seen', 921 | FLAGGED_FLAG = '\\Flagged', 922 | ANSWERED_FLAG = '\\Answered'; 923 | 924 | if (options.unread === true) { 925 | remove.push(READ_FLAG); 926 | } else if (options.unread === false) { 927 | add.push(READ_FLAG); 928 | } 929 | 930 | if (options.flagged === true) { 931 | add.push(FLAGGED_FLAG); 932 | } else if (options.flagged === false) { 933 | remove.push(FLAGGED_FLAG); 934 | } 935 | 936 | if (options.answered === true) { 937 | add.push(ANSWERED_FLAG); 938 | } else if (options.answered === false) { 939 | remove.push(ANSWERED_FLAG); 940 | } 941 | 942 | if (add.length === 0 && remove.length === 0) { 943 | return new Promise(function() { 944 | throw new Error('Can not update flags, cause: Not logged in!'); 945 | }); 946 | } 947 | 948 | queryAdd = { 949 | add: add 950 | }; 951 | queryRemove = { 952 | remove: remove 953 | }; 954 | 955 | axe.debug(DEBUG_TAG, 'updating flags for uid ' + options.uid + ' in folder ' + options.path + ': ' + (remove.length > 0 ? (' removing ' + remove) : '') + (add.length > 0 ? (' adding ' + add) : '')); 956 | return self._checkOnline().then(function() { 957 | return new Promise(function(resolve) { 958 | if (add.length > 0) { 959 | resolve(self._client.setFlags(interval, queryAdd, queryOptions)); 960 | } else { 961 | resolve(); 962 | } 963 | }); 964 | }).then(function() { 965 | if (remove.length > 0) { 966 | return self._client.setFlags(interval, queryRemove, queryOptions); 967 | } 968 | }).then(function() { 969 | axe.debug(DEBUG_TAG, 'successfully updated flags for uid ' + options.uid + ' in folder ' + options.path + ': added ' + add + ' and removed ' + remove); 970 | }).catch(function(error) { 971 | axe.error(DEBUG_TAG, 'error updating flags for uid ' + options.uid + ' in folder ' + options.path + ' : ' + error + '\n' + error.stack); 972 | throw error; 973 | }); 974 | }; 975 | 976 | /** 977 | * Move a message to a destination folder 978 | * @param {String} options.path The origin path where the message resides 979 | * @param {Number} options.uid The uid of the message 980 | * @param {String} options.destination The destination folder 981 | * 982 | * @returns {Promise} 983 | */ 984 | ImapClient.prototype.moveMessage = function(options) { 985 | var self = this, 986 | interval = options.uid + ':' + options.uid, 987 | queryOptions = { 988 | byUid: true, 989 | precheck: self._ensurePath(options.path) 990 | }; 991 | 992 | axe.debug(DEBUG_TAG, 'moving uid ' + options.uid + ' from ' + options.path + ' to ' + options.destination); 993 | return self._checkOnline().then(function() { 994 | return self._client.moveMessages(interval, options.destination, queryOptions); 995 | }).then(function() { 996 | axe.debug(DEBUG_TAG, 'successfully moved uid ' + options.uid + ' from ' + options.path + ' to ' + options.destination); 997 | }).catch(function(error) { 998 | axe.error(DEBUG_TAG, 'error moving uid ' + options.uid + ' from ' + options.path + ' to ' + options.destination + ' : ' + error + '\n' + error.stack); 999 | throw error; 1000 | }); 1001 | }; 1002 | 1003 | /** 1004 | * Move a message to a folder 1005 | * @param {String} options.path The path the message should be uploaded to 1006 | * @param {String} options.message A RFC-2822 compliant message 1007 | * 1008 | * @returns {Promise} 1009 | */ 1010 | ImapClient.prototype.uploadMessage = function(options) { 1011 | var self = this; 1012 | 1013 | axe.debug(DEBUG_TAG, 'uploading a message of ' + options.message.length + ' bytes to ' + options.path); 1014 | return self._checkOnline().then(function() { 1015 | return self._client.upload(options.path, options.message); 1016 | }).then(function() { 1017 | axe.debug(DEBUG_TAG, 'successfully uploaded message to ' + options.path); 1018 | }).catch(function(error) { 1019 | axe.error(DEBUG_TAG, 'error uploading <' + options.message.length + '> bytes to ' + options.path + ' : ' + error + '\n' + error.stack); 1020 | throw error; 1021 | }); 1022 | }; 1023 | 1024 | /** 1025 | * Purges a message from a folder 1026 | * @param {String} options.path The origin path where the message resides 1027 | * @param {Number} options.uid The uid of the message 1028 | * 1029 | * @returns {Promise} 1030 | */ 1031 | ImapClient.prototype.deleteMessage = function(options) { 1032 | var self = this, 1033 | interval = options.uid + ':' + options.uid, 1034 | queryOptions = { 1035 | byUid: true, 1036 | precheck: self._ensurePath(options.path) 1037 | }; 1038 | 1039 | axe.debug(DEBUG_TAG, 'deleting uid ' + options.uid + ' from ' + options.path); 1040 | return self._checkOnline().then(function() { 1041 | return self._client.deleteMessages(interval, queryOptions); 1042 | }).then(function() { 1043 | axe.debug(DEBUG_TAG, 'successfully deleted uid ' + options.uid + ' from ' + options.path); 1044 | }).catch(function(error) { 1045 | axe.error(DEBUG_TAG, 'error deleting uid ' + options.uid + ' from ' + options.path + ' : ' + error + '\n' + error.stack); 1046 | throw error; 1047 | }); 1048 | }; 1049 | 1050 | // 1051 | // Helper methods 1052 | // 1053 | 1054 | /** 1055 | * Makes sure that the respective instance of browserbox is in the correct mailbox to run the command 1056 | * 1057 | * @param {String} path The mailbox path 1058 | */ 1059 | ImapClient.prototype._ensurePath = function(path, client) { 1060 | var self = this; 1061 | client = client || self._client; 1062 | 1063 | return function(ctx, next) { 1064 | if (client.selectedMailbox === path) { 1065 | return next(); 1066 | } 1067 | 1068 | axe.debug(DEBUG_TAG, 'selecting mailbox ' + path); 1069 | client.selectMailbox(path, { 1070 | ctx: ctx 1071 | }, next); 1072 | }; 1073 | }; 1074 | 1075 | ImapClient.prototype._checkOnline = function() { 1076 | var self = this; 1077 | 1078 | return new Promise(function(resolve) { 1079 | if (!self._loggedIn) { 1080 | throw new Error('Not logged in!'); 1081 | } 1082 | 1083 | resolve(); 1084 | }); 1085 | }; 1086 | 1087 | /* 1088 | * Mime Tree Handling 1089 | * ================== 1090 | * 1091 | * matchEncrypted, matchSigned, ... are matchers that are called on each node of the mimde tree 1092 | * when it is being traversed in a DFS. if one of the matchers returns true, it indicates that it 1093 | * matched respective mime node, hence there is no need to look any further down in the tree. 1094 | * 1095 | */ 1096 | 1097 | var mimeTreeMatchers = [matchEncrypted, matchSigned, matchAttachment, matchText, matchHtml]; 1098 | 1099 | /** 1100 | * Helper function that walks the MIME tree in a dfs and calls the handlers 1101 | * @param {Object} mimeNode The initial MIME node whose subtree should be traversed 1102 | * @param {Object} message The initial root MIME node whose subtree should be traversed 1103 | */ 1104 | function walkMimeTree(mimeNode, message) { 1105 | var i = mimeTreeMatchers.length; 1106 | while (i--) { 1107 | if (mimeTreeMatchers[i](mimeNode, message)) { 1108 | return; 1109 | } 1110 | } 1111 | 1112 | if (mimeNode.childNodes) { 1113 | mimeNode.childNodes.forEach(function(childNode) { 1114 | walkMimeTree(childNode, message); 1115 | }); 1116 | } 1117 | } 1118 | 1119 | /** 1120 | * Matches encrypted PGP/MIME nodes 1121 | * 1122 | * multipart/encrypted 1123 | * | 1124 | * |-- application/pgp-encrypted 1125 | * |-- application/octet-stream <-- ciphertext 1126 | */ 1127 | function matchEncrypted(node, message) { 1128 | var isEncrypted = /^multipart\/encrypted/i.test(node.type) && node.childNodes && node.childNodes[1]; 1129 | if (!isEncrypted) { 1130 | return false; 1131 | } 1132 | 1133 | message.bodyParts.push({ 1134 | type: 'encrypted', 1135 | partNumber: node.part || '', 1136 | }); 1137 | return true; 1138 | } 1139 | 1140 | /** 1141 | * Matches signed PGP/MIME nodes 1142 | * 1143 | * multipart/signed 1144 | * | 1145 | * |-- *** (signed mime sub-tree) 1146 | * |-- application/pgp-signature 1147 | */ 1148 | function matchSigned(node, message) { 1149 | var c = node.childNodes; 1150 | 1151 | var isSigned = /^multipart\/signed/i.test(node.type) && c && c[0] && c[1] && /^application\/pgp-signature/i.test(c[1].type); 1152 | if (!isSigned) { 1153 | return false; 1154 | } 1155 | 1156 | message.bodyParts.push({ 1157 | type: 'signed', 1158 | partNumber: node.part || '', 1159 | }); 1160 | return true; 1161 | } 1162 | 1163 | /** 1164 | * Matches non-attachment text/plain nodes 1165 | */ 1166 | function matchText(node, message) { 1167 | var isText = (/^text\/plain/i.test(node.type) && node.disposition !== 'attachment'); 1168 | if (!isText) { 1169 | return false; 1170 | } 1171 | 1172 | message.bodyParts.push({ 1173 | type: 'text', 1174 | partNumber: node.part || '' 1175 | }); 1176 | return true; 1177 | } 1178 | 1179 | /** 1180 | * Matches non-attachment text/html nodes 1181 | */ 1182 | function matchHtml(node, message) { 1183 | var isHtml = (/^text\/html/i.test(node.type) && node.disposition !== 'attachment'); 1184 | if (!isHtml) { 1185 | return false; 1186 | } 1187 | 1188 | message.bodyParts.push({ 1189 | type: 'html', 1190 | partNumber: node.part || '' 1191 | }); 1192 | return true; 1193 | } 1194 | 1195 | /** 1196 | * Matches non-attachment text/html nodes 1197 | */ 1198 | function matchAttachment(node, message) { 1199 | var isAttachment = (/^text\//i.test(node.type) && node.disposition) || (!/^text\//i.test(node.type) && !/^multipart\//i.test(node.type)); 1200 | if (!isAttachment) { 1201 | return false; 1202 | } 1203 | 1204 | var bodyPart = { 1205 | type: 'attachment', 1206 | partNumber: node.part || '', 1207 | mimeType: node.type || 'application/octet-stream', 1208 | id: node.id ? node.id.replace(/[<>]/g, '') : undefined 1209 | }; 1210 | 1211 | if (node.dispositionParameters && node.dispositionParameters.filename) { 1212 | bodyPart.filename = node.dispositionParameters.filename; 1213 | } else if (node.parameters && node.parameters.name) { 1214 | bodyPart.filename = node.parameters.name; 1215 | } else { 1216 | bodyPart.filename = 'attachment'; 1217 | } 1218 | 1219 | message.bodyParts.push(bodyPart); 1220 | return true; 1221 | } 1222 | 1223 | /** 1224 | * Compares numbers, sorts them ascending 1225 | */ 1226 | function sortNumericallyDescending(a, b) { 1227 | return b - a; 1228 | } 1229 | 1230 | return ImapClient; 1231 | }); -------------------------------------------------------------------------------- /test/background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | chrome.app.runtime.onLaunched.addListener(function() { 4 | chrome.app.window.create('integration.html', { 5 | 'bounds': { 6 | 'width': 1024, 7 | 'height': 650 8 | } 9 | }); 10 | }); -------------------------------------------------------------------------------- /test/integration-test.js: -------------------------------------------------------------------------------- 1 | (function(factory) { 2 | 'use strict'; 3 | 4 | if (typeof define === 'function' && define.amd) { 5 | ES6Promise.polyfill(); // load ES6 Promises polyfill 6 | define(['chai', 'imap-client', 'axe'], factory); 7 | } else if (typeof exports === 'object') { 8 | require('es6-promise').polyfill(); // load ES6 Promises polyfill 9 | module.exports = factory(require('chai'), require('../src/imap-client'), require('axe-logger')); 10 | } 11 | })(function(chai, ImapClient, axe) { 12 | 'use strict'; 13 | 14 | var expect = chai.expect, 15 | loginOptions; 16 | 17 | loginOptions = { 18 | port: 993, 19 | host: 'secureimap.t-online.de', 20 | auth: { 21 | user: 'whiteout.testaccount@t-online.de', 22 | pass: 'HelloSafer' 23 | }, 24 | secure: true, 25 | tlsWorkerPath: 'lib/tcp-socket-tls-worker.js' 26 | }; 27 | 28 | describe('ImapClient t-online integration tests', function() { 29 | this.timeout(50000); 30 | chai.config.includeStack = true; 31 | 32 | // don't log in the tests 33 | axe.removeAppender(axe.defaultAppender); 34 | 35 | var ic; 36 | 37 | beforeEach(function(done) { 38 | ic = new ImapClient(loginOptions); 39 | ic.onSyncUpdate = function() {}; 40 | ic.onCert = function() {}; 41 | ic.onError = function(error) { 42 | console.error(error); 43 | }; 44 | ic.login().then(done); 45 | }); 46 | 47 | 48 | afterEach(function(done) { 49 | ic.logout().then(done); 50 | }); 51 | 52 | it('should list well known folders', function(done) { 53 | ic.listWellKnownFolders().then(function(folders) { 54 | expect(folders).to.exist; 55 | 56 | expect(folders.Inbox).to.be.instanceof(Array); 57 | expect(folders.Inbox[0]).to.exist; 58 | expect(folders.Inbox[0].name).to.exist; 59 | expect(folders.Inbox[0].type).to.exist; 60 | expect(folders.Inbox[0].path).to.exist; 61 | 62 | expect(folders.Drafts).to.be.instanceof(Array); 63 | expect(folders.Drafts).to.not.be.empty; 64 | 65 | expect(folders.Sent).to.be.instanceof(Array); 66 | expect(folders.Sent).to.not.be.empty; 67 | 68 | expect(folders.Trash).to.be.instanceof(Array); 69 | expect(folders.Trash).to.not.be.empty; 70 | 71 | expect(folders.Other).to.be.instanceof(Array); 72 | }).then(done); 73 | }); 74 | 75 | it('should search messages', function(done) { 76 | ic.search({ 77 | path: 'INBOX', 78 | unread: false, 79 | answered: false 80 | }).then(function(uids) { 81 | expect(uids).to.not.be.empty; 82 | }).then(done); 83 | }); 84 | 85 | it('should list messages by uid', function(done) { 86 | ic.listMessages({ 87 | path: 'INBOX', 88 | firstUid: 1 89 | }).then(function(messages) { 90 | expect(messages).to.not.be.empty; 91 | }).then(done); 92 | }); 93 | 94 | it('should create folder hierarchy', function(done) { 95 | ic.createFolder({ 96 | path: ['bar', 'baz'] 97 | }).then(function(fullPath) { 98 | expect(fullPath).to.equal('INBOX.bar.baz'); 99 | return ic.listWellKnownFolders(); 100 | 101 | }).then(function(folders) { 102 | var hasFoo = false; 103 | folders.Other.forEach(function(folder) { 104 | hasFoo = hasFoo || folder.path === 'INBOX.bar.baz'; 105 | }); 106 | 107 | expect(hasFoo).to.be.true; 108 | expect(ic._delimiter).to.exist; 109 | expect(ic._prefix).to.exist; 110 | expect(hasFoo).to.be.true; 111 | }).then(done); 112 | }); 113 | 114 | it('should upload Message', function(done) { 115 | var msg = 'MIME-Version: 1.0\r\nDate: Wed, 9 Jul 2014 15:07:47 +0200\r\nDelivered-To: test@test.com\r\nMessage-ID: \r\nSubject: integration test\r\nFrom: Test Test \r\nTo: Test Test \r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nintegration test', 116 | path = 'INBOX', 117 | msgCount; 118 | 119 | ic.listMessages({ 120 | path: path, 121 | firstUid: 1 122 | }).then(function(messages) { 123 | expect(messages).to.not.be.empty; 124 | msgCount = messages.length; 125 | 126 | return ic.uploadMessage({ 127 | path: path, 128 | message: msg 129 | }); 130 | }).then(function() { 131 | return ic.listMessages({ 132 | path: path, 133 | firstUid: 1 134 | }); 135 | }).then(function(messages) { 136 | expect(messages.length).to.equal(msgCount + 1); 137 | }).then(done); 138 | }); 139 | 140 | it('should get message parts', function(done) { 141 | var msg; 142 | ic.listMessages({ 143 | path: 'INBOX', 144 | firstUid: 1 145 | }).then(function(messages) { 146 | msg = messages.pop(); 147 | return ic.getBodyParts({ 148 | path: 'INBOX', 149 | uid: msg.uid, 150 | bodyParts: msg.bodyParts 151 | }); 152 | }).then(function(bodyParts) { 153 | expect(msg.bodyParts).to.equal(bodyParts); 154 | expect(bodyParts[0].raw).to.not.be.empty; 155 | }).then(done); 156 | }); 157 | }); 158 | }); -------------------------------------------------------------------------------- /test/integration.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JavaScript Integration Tests 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/integration.js: -------------------------------------------------------------------------------- 1 | require.config({ 2 | baseUrl: 'lib', 3 | paths: { 4 | 'test': '..', 5 | 'forge': 'forge.min' 6 | }, 7 | shim: { 8 | forge: { 9 | exports: 'forge' 10 | } 11 | } 12 | 13 | }); 14 | 15 | require([], function() { 16 | 'use strict'; 17 | 18 | mocha.setup('bdd'); 19 | 20 | require(['test/integration-test'], function() { 21 | mocha.run(); 22 | }); 23 | }); -------------------------------------------------------------------------------- /test/local-integration-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('es6-promise').polyfill(); // load ES6 Promises polyfill 4 | 5 | // this test is node-only (hoodiecrow is fired up) 6 | 7 | var chai = require('chai'), 8 | expect = chai.expect, 9 | ImapClient = require('../src/imap-client'), 10 | hoodiecrow = require('hoodiecrow'), 11 | loginOptions = { 12 | port: 12345, 13 | host: 'localhost', 14 | auth: { 15 | user: 'testuser', 16 | pass: 'testpass' 17 | }, 18 | secure: false 19 | }; 20 | 21 | describe('ImapClient local integration tests', function() { 22 | var ic, imap; 23 | 24 | chai.config.includeStack = true; 25 | before(function() { 26 | imap = hoodiecrow({ 27 | storage: { 28 | 'INBOX': { 29 | messages: [{ 30 | raw: 'Message-Id: \r\nX-Foobar: 123qweasdzxc\r\nSubject: hello 1\r\n\r\nWorld 1!' 31 | }, { 32 | raw: 'Message-Id: \r\nSubject: hello 2\r\n\r\nWorld 2!', 33 | flags: ['\\Seen'] 34 | }, { 35 | raw: 'Message-Id: \r\nSubject: hello 3\r\n\r\nWorld 3!' 36 | }, { 37 | raw: 'MIME-Version: 1.0\r\nDate: Tue, 01 Oct 2013 07:08:55 GMT\r\nMessage-Id: <1380611335900.56da46df@Nodemailer>\r\nFrom: alice@example.com\r\nTo: bob@example.com\r\nSubject: Hello\r\nContent-Type: multipart/mixed;\r\n boundary="----Nodemailer-0.5.3-dev-?=_1-1380611336047"\r\n\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello world\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047\r\nContent-Type: text/plain; name="foo.txt"\r\nContent-Disposition: attachment; filename="foo.txt"\r\nContent-Transfer-Encoding: base64\r\n\r\nZm9vZm9vZm9vZm9vZm9v\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047\r\nContent-Type: text/plain; name="bar.txt"\r\nContent-Disposition: attachment; filename="bar.txt"\r\nContent-Transfer-Encoding: base64\r\n\r\nYmFyYmFyYmFyYmFyYmFy\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047--' 38 | }, { 39 | raw: 'Content-Type: multipart/encrypted; boundary="Apple-Mail=_CC38E51A-DB4D-420E-AD14-02653EB88B69"; protocol="application/pgp-encrypted";\r\nSubject: [whiteout] attachment only\r\nFrom: Felix Hammerl \r\nDate: Thu, 16 Jan 2014 14:55:56 +0100\r\nContent-Transfer-Encoding: 7bit\r\nMessage-Id: <3ECDF9DC-895E-4475-B2A9-52AF1F117652@gmail.com>\r\nContent-Description: OpenPGP encrypted message\r\nTo: safewithme.testuser@gmail.com\r\n\r\nThis is an OpenPGP/MIME encrypted message (RFC 2440 and 3156)\r\n--Apple-Mail=_CC38E51A-DB4D-420E-AD14-02653EB88B69\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: application/pgp-encrypted\r\nContent-Description: PGP/MIME Versions Identification\r\n\r\nVersion: 1\r\n\r\n--Apple-Mail=_CC38E51A-DB4D-420E-AD14-02653EB88B69\r\nContent-Transfer-Encoding: 7bit\r\nContent-Disposition: inline;\r\n filename=encrypted.asc\r\nContent-Type: application/octet-stream;\r\n name=encrypted.asc\r\nContent-Description: OpenPGP encrypted message\r\n\r\ninsert pgp here.\r\n\r\n--Apple-Mail=_CC38E51A-DB4D-420E-AD14-02653EB88B69--', 40 | }, { 41 | raw: 'MIME-Version: 1.0\r\nDate: Tue, 01 Oct 2013 07:08:55 GMT\r\nMessage-Id: <1380611335900.56da46df@Nodemailer>\r\nFrom: alice@example.com\r\nTo: bob@example.com\r\nSubject: Hello\r\nContent-Type: multipart/mixed;\r\n boundary="----Nodemailer-0.5.3-dev-?=_1-1380611336047"\r\n\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello world\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047\r\nContent-Type: text/plain; name="foo.txt"\r\nContent-Disposition: attachment; filename="foo.txt"\r\nContent-Transfer-Encoding: base64\r\n\r\nZm9vZm9vZm9vZm9vZm9v\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047\r\nContent-Type: text/plain; name="bar.txt"\r\nContent-Disposition: attachment; filename="bar.txt"\r\nContent-Transfer-Encoding: base64\r\n\r\nYmFyYmFyYmFyYmFyYmFy\r\n------Nodemailer-0.5.3-dev-?=_1-1380611336047--' 42 | }] 43 | }, 44 | '': { 45 | 'separator': '/', 46 | 'folders': { 47 | '[Gmail]': { 48 | 'flags': ['\\Noselect'], 49 | 'folders': { 50 | 'All Mail': { 51 | 'flags': '\\All' 52 | }, 53 | 'Drafts': { 54 | 'flags': '\\Drafts' 55 | }, 56 | 'Important': { 57 | 'flags': '\\Important' 58 | }, 59 | 'Sent Mail': { 60 | 'flags': '\\Sent' 61 | }, 62 | 'Spam': { 63 | 'flags': '\\Junk' 64 | }, 65 | 'Starred': { 66 | 'flags': '\\Flagged' 67 | }, 68 | 'Trash': { 69 | 'flags': '\\Trash' 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | }); 77 | 78 | imap.listen(loginOptions.port); 79 | }); 80 | 81 | after(function(done) { 82 | imap.close(done); 83 | }); 84 | 85 | beforeEach(function(done) { 86 | ic = new ImapClient(loginOptions); 87 | ic.onSyncUpdate = function() {}; 88 | ic.login().then(done); 89 | }); 90 | 91 | afterEach(function(done) { 92 | ic.logout().then(done); 93 | }); 94 | 95 | it('should notify about new messages', function(done) { 96 | var invocations = 0; // counts the message updates 97 | 98 | ic.onSyncUpdate = function(options) { 99 | invocations++; 100 | 101 | expect(options.list.length).to.equal(6); 102 | expect(options.type).to.equal('new'); 103 | done(); 104 | }; 105 | 106 | ic.selectMailbox({ 107 | path: 'INBOX' 108 | }); 109 | }); 110 | 111 | it('should list well known folders', function(done) { 112 | ic.listWellKnownFolders().then(function(folders) { 113 | expect(folders).to.exist; 114 | 115 | expect(folders.Inbox).to.be.instanceof(Array); 116 | expect(folders.Inbox[0]).to.exist; 117 | expect(folders.Inbox[0].name).to.exist; 118 | expect(folders.Inbox[0].type).to.exist; 119 | expect(folders.Inbox[0].path).to.exist; 120 | 121 | expect(folders.Drafts).to.be.instanceof(Array); 122 | expect(folders.Drafts).to.not.be.empty; 123 | 124 | expect(folders.Sent).to.be.instanceof(Array); 125 | expect(folders.Sent).to.not.be.empty; 126 | 127 | expect(folders.Trash).to.be.instanceof(Array); 128 | expect(folders.Trash).to.not.be.empty; 129 | 130 | expect(folders.Other).to.be.instanceof(Array); 131 | expect(folders.Other).to.not.be.empty; 132 | }).then(done); 133 | }); 134 | 135 | it('should search messages', function(done) { 136 | ic.search({ 137 | path: 'INBOX', 138 | unread: false, 139 | answered: false 140 | }).then(function(uids) { 141 | expect(uids).to.not.be.empty; 142 | }).then(done); 143 | }); 144 | 145 | it('should create folder', function(done) { 146 | ic.createFolder({ 147 | path: 'foo' 148 | }).then(function(fullPath) { 149 | expect(fullPath).to.equal('foo'); 150 | return ic.listWellKnownFolders(); 151 | 152 | }).then(function(folders) { 153 | var hasFoo = false; 154 | 155 | folders.Other.forEach(function(folder) { 156 | hasFoo = hasFoo || folder.path === 'foo'; 157 | }); 158 | 159 | expect(hasFoo).to.be.true; 160 | expect(ic._delimiter).to.exist; 161 | expect(ic._prefix).to.exist; 162 | }).then(done); 163 | }); 164 | 165 | it('should create folder hierarchy', function(done) { 166 | ic.createFolder({ 167 | path: ['bar', 'baz'] 168 | }).then(function(fullPath) { 169 | expect(fullPath).to.equal('bar/baz'); 170 | return ic.listWellKnownFolders(); 171 | }).then(function(folders) { 172 | var hasFoo = false; 173 | 174 | folders.Other.forEach(function(folder) { 175 | hasFoo = hasFoo || folder.path === 'bar/baz'; 176 | }); 177 | 178 | expect(hasFoo).to.be.true; 179 | }).then(done); 180 | }); 181 | 182 | it('should search messages for header', function(done) { 183 | ic.search({ 184 | path: 'INBOX', 185 | header: ['X-Foobar', '123qweasdzxc'] 186 | }).then(function(uids) { 187 | expect(uids).to.deep.equal([1]); 188 | }).then(done); 189 | }); 190 | 191 | it('should list messages by uid', function(done) { 192 | ic.listMessages({ 193 | path: 'INBOX', 194 | firstUid: 1, 195 | lastUid: 3 196 | }).then(function(messages) { 197 | expect(messages).to.not.be.empty; 198 | expect(messages.length).to.equal(3); 199 | expect(messages[0].id).to.not.be.empty; 200 | expect(messages[0].bodyParts.length).to.equal(1); 201 | }).then(done); 202 | }); 203 | 204 | it('should list all messages by uid', function(done) { 205 | ic.listMessages({ 206 | path: 'INBOX', 207 | firstUid: 1 208 | }).then(function(messages) { 209 | expect(messages).to.not.be.empty; 210 | expect(messages.length).to.equal(6); 211 | }).then(done); 212 | }); 213 | 214 | it('should get message parts', function(done) { 215 | var msgs; 216 | ic.listMessages({ 217 | path: 'INBOX', 218 | firstUid: 4, 219 | lastUid: 4 220 | }).then(function(messages) { 221 | msgs = messages; 222 | return ic.getBodyParts({ 223 | path: 'INBOX', 224 | uid: messages[0].uid, 225 | bodyParts: messages[0].bodyParts 226 | }); 227 | 228 | }).then(function(bodyParts) { 229 | expect(msgs[0].bodyParts).to.equal(bodyParts); 230 | expect(bodyParts[0].type).to.equal('text'); 231 | expect(bodyParts[0].raw).to.equal('Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello world'); 232 | 233 | }).then(done); 234 | }); 235 | 236 | it('should update flags', function(done) { 237 | ic.updateFlags({ 238 | path: 'INBOX', 239 | uid: 1, 240 | unread: true, 241 | flagged: true, 242 | answered: true 243 | }).then(function() { 244 | done(); 245 | }); 246 | }); 247 | 248 | it('should purge message', function(done) { 249 | ic.listMessages({ 250 | path: 'INBOX', 251 | firstUid: 1 252 | }).then(function(messages) { 253 | expect(messages).to.not.be.empty; 254 | return ic.deleteMessage({ 255 | path: 'INBOX', 256 | uid: 2 257 | }); 258 | 259 | }).then(function() { 260 | return ic.listMessages({ 261 | path: 'INBOX', 262 | firstUid: 1 263 | }); 264 | 265 | }).then(function(messages) { 266 | expect(messages).to.not.be.empty; 267 | messages.forEach(function(message) { 268 | expect(message.uid).to.not.equal(2); 269 | }); 270 | 271 | }).then(done); 272 | }); 273 | 274 | it('should upload Message', function(done) { 275 | var msg = 'MIME-Version: 1.0\r\nDate: Wed, 9 Jul 2014 15:07:47 +0200\r\nDelivered-To: test@test.com\r\nMessage-ID: \r\nSubject: test\r\nFrom: Test Test \r\nTo: Test Test \r\nContent-Type: text/plain; charset=UTF-8\r\n\r\ntest', 276 | path = 'INBOX', 277 | msgCount; 278 | 279 | ic.listMessages({ 280 | path: path, 281 | firstUid: 1 282 | }).then(function(messages) { 283 | expect(messages).to.not.be.empty; 284 | msgCount = messages.length; 285 | 286 | return ic.uploadMessage({ 287 | path: path, 288 | message: msg, 289 | flags: ['\\Seen'] 290 | }); 291 | }).then(function() { 292 | return ic.listMessages({ 293 | path: path, 294 | firstUid: 1 295 | }); 296 | }).then(function(messages) { 297 | expect(messages.length).to.equal(msgCount + 1); 298 | }).then(done); 299 | }); 300 | 301 | it('should move message', function(done) { 302 | var destination = '[Gmail]/Trash'; 303 | 304 | ic.listMessages({ 305 | path: destination, 306 | firstUid: 1 307 | }).then(function(messages) { 308 | expect(messages).to.be.empty; 309 | return ic.listMessages({ 310 | path: 'INBOX', 311 | firstUid: 1 312 | }); 313 | 314 | }).then(function(messages) { 315 | expect(messages).to.not.be.empty; 316 | return ic.moveMessage({ 317 | path: 'INBOX', 318 | uid: messages[0].uid, 319 | destination: destination 320 | }); 321 | 322 | }).then(function() { 323 | return ic.listMessages({ 324 | path: destination, 325 | firstUid: 1 326 | }); 327 | 328 | }).then(function(messages) { 329 | expect(messages).to.not.be.empty; 330 | }).then(done); 331 | }); 332 | 333 | it('should timeout', function(done) { 334 | ic.onError = function(err) { 335 | expect(err).to.exist; 336 | expect(ic._loggedIn).to.be.false; 337 | done(); 338 | }; 339 | 340 | ic._client.client.TIMEOUT_SOCKET_LOWER_BOUND = 20; // fails 20ms after writing to socket 341 | ic._client.client.socket.ondata = function() {}; // browserbox won't be receiving data anymore 342 | 343 | // fire anything at the socket 344 | ic.listMessages({ 345 | path: 'INBOX', 346 | firstUid: 1 347 | }); 348 | }); 349 | 350 | it.skip('should not error for listening client timeout', function(done) { 351 | ic.listenForChanges({ 352 | path: 'INBOX' 353 | }).then(function() { 354 | ic._listeningClient.client.TIMEOUT_SOCKET_LOWER_BOUND = 20; // fails 20ms after dropping into idle/noop 355 | ic._listeningClient.client.socket.ondata = function() {}; // browserbox won't be receiving data anymore 356 | 357 | // the listening client does not cause an error, so we let it fail silently 358 | // in the background and check back after a 1 s delay 359 | setTimeout(function() { 360 | expect(ic._listenerLoggedIn).to.be.false; 361 | done(); 362 | }, 1000); 363 | }); 364 | }); 365 | }); -------------------------------------------------------------------------------- /test/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imap-client integration tests", 3 | "description": "Testing the imap-client in the browser", 4 | "version": "0.0.1", 5 | "manifest_version": 2, 6 | "offline_enabled": true, 7 | "permissions": ["http://*/*", "https://*/*", { 8 | "socket": ["tcp-connect"] 9 | }], 10 | "app": { 11 | "background": { 12 | "scripts": ["background.js"] 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /test/unit-test.js: -------------------------------------------------------------------------------- 1 | (function(factory) { 2 | 'use strict'; 3 | 4 | if (typeof define === 'function' && define.amd) { 5 | ES6Promise.polyfill(); // load ES6 Promises polyfill 6 | define(['chai', 'sinon', 'browserbox', 'axe', 'imap-client'], factory); 7 | } else if (typeof exports === 'object') { 8 | require('es6-promise').polyfill(); // load ES6 Promises polyfill 9 | module.exports = factory(require('chai'), require('sinon'), require('browserbox'), require('axe-logger'), require('../src/imap-client')); 10 | } 11 | })(function(chai, sinon, browserbox, axe, ImapClient) { 12 | 'use strict'; 13 | 14 | // don't log in the tests 15 | axe.removeAppender(axe.defaultAppender); 16 | 17 | describe('ImapClient', function() { 18 | var expect = chai.expect; 19 | chai.config.includeStack = true; 20 | 21 | var imap, bboxMock; 22 | 23 | beforeEach(function() { 24 | bboxMock = sinon.createStubInstance(browserbox); 25 | imap = new ImapClient({}, bboxMock); 26 | 27 | expect(imap._client).to.equal(bboxMock); 28 | 29 | imap._loggedIn = true; 30 | }); 31 | 32 | describe('#login', function() { 33 | it('should login', function(done) { 34 | imap._loggedIn = false; 35 | 36 | imap.login().then(function() { 37 | expect(imap._loggedIn).to.be.true; 38 | expect(bboxMock.connect.calledOnce).to.be.true; 39 | }).then(done); 40 | bboxMock.onauth(); 41 | }); 42 | 43 | it('should reject on login failure', function(done) { 44 | imap._loggedIn = false; 45 | imap.login() 46 | .catch(function() { 47 | expect(imap._loggedIn).to.be.false; 48 | expect(bboxMock.connect.calledOnce).to.be.true; 49 | done(); 50 | }); 51 | bboxMock.onerror('login error'); 52 | }); 53 | 54 | it('should not login when logged in', function(done) { 55 | imap._loggedIn = true; 56 | imap.login().then(done); 57 | }); 58 | }); 59 | 60 | describe('#logout', function() { 61 | it('should logout', function(done) { 62 | imap.logout().then(function() { 63 | expect(bboxMock.close.calledOnce).to.be.true; 64 | expect(imap._loggedIn).to.be.false; 65 | }).then(done); 66 | bboxMock.onclose(); 67 | }); 68 | 69 | it('should not logout when not logged in', function(done) { 70 | imap._loggedIn = false; 71 | imap.logout().then(done); 72 | }); 73 | }); 74 | 75 | describe('#_onError', function() { 76 | it('should report error for main imap connection', function(done) { 77 | imap.onError = function(error) { 78 | expect(error).to.exist; 79 | expect(imap._loggedIn).to.be.false; 80 | expect(bboxMock.close.calledOnce).to.be.true; 81 | 82 | done(); 83 | }; 84 | 85 | bboxMock.onerror(); 86 | }); 87 | 88 | it('should not error for listening imap connection', function() { 89 | imap._loggedIn = false; 90 | imap._listenerLoggedIn = true; 91 | imap._client = {}; // _client !== _listeningClient 92 | 93 | bboxMock.onerror(); 94 | 95 | expect(imap._listenerLoggedIn).to.be.false; 96 | expect(bboxMock.close.calledOnce).to.be.true; 97 | }); 98 | }); 99 | 100 | describe('#_onClose', function() { 101 | it('should error for main imap connection', function(done) { 102 | imap.onError = function(error) { 103 | expect(error).to.exist; 104 | expect(imap._loggedIn).to.be.false; 105 | 106 | done(); 107 | }; 108 | 109 | bboxMock.onclose(); 110 | }); 111 | 112 | it('should not error for listening imap connection', function() { 113 | imap._loggedIn = false; 114 | imap._listenerLoggedIn = true; 115 | imap._client = {}; // _client !== _listeningClient 116 | 117 | bboxMock.onclose(); 118 | 119 | expect(imap._listenerLoggedIn).to.be.false; 120 | }); 121 | 122 | }); 123 | 124 | describe('#selectMailbox', function() { 125 | var path = 'foo'; 126 | 127 | it('should select a different mailbox', function(done) { 128 | bboxMock.selectMailbox.withArgs(path).returns(resolves()); 129 | 130 | imap.selectMailbox({ 131 | path: path 132 | }).then(done); 133 | }); 134 | }); 135 | 136 | describe('#listWellKnownFolders', function() { 137 | it('should list well known folders', function(done) { 138 | // setup fixture 139 | bboxMock.listMailboxes.returns(resolves({ 140 | children: [{ 141 | path: 'INBOX' 142 | }, { 143 | name: 'drafts', 144 | path: 'drafts', 145 | specialUse: '\\Drafts' 146 | }, { 147 | name: 'sent', 148 | path: 'sent', 149 | specialUse: '\\Sent' 150 | }] 151 | })); 152 | 153 | // execute test case 154 | imap.listWellKnownFolders().then(function(folders) { 155 | expect(folders).to.exist; 156 | 157 | expect(folders.Inbox).to.be.instanceof(Array); 158 | expect(folders.Inbox[0]).to.exist; 159 | expect(folders.Inbox[0].name).to.exist; 160 | expect(folders.Inbox[0].type).to.exist; 161 | expect(folders.Inbox[0].path).to.exist; 162 | 163 | expect(folders.Drafts).to.be.instanceof(Array); 164 | expect(folders.Drafts).to.not.be.empty; 165 | 166 | expect(folders.Sent).to.be.instanceof(Array); 167 | expect(folders.Sent).to.not.be.empty; 168 | 169 | expect(folders.Trash).to.be.instanceof(Array); 170 | expect(folders.Trash).to.be.empty; 171 | 172 | expect(folders.Other).to.be.instanceof(Array); 173 | 174 | expect(bboxMock.listMailboxes.calledOnce).to.be.true; 175 | }).then(done); 176 | }); 177 | 178 | it('should not list folders when not logged in', function(done) { 179 | imap._loggedIn = false; 180 | imap.listWellKnownFolders().catch(function() { 181 | done(); 182 | }); 183 | }); 184 | 185 | it('should error while listing folders', function(done) { 186 | // setup fixture 187 | bboxMock.listMailboxes.returns(rejects()); 188 | 189 | // execute test case 190 | imap.listWellKnownFolders().catch(function() { 191 | done(); 192 | }); 193 | }); 194 | }); 195 | 196 | describe('#createFolder', function() { 197 | it('should create folder with namespaces', function(done) { 198 | bboxMock.listNamespaces.returns(resolves({ 199 | "personal": [{ 200 | "prefix": "BLA/", 201 | "delimiter": "/" 202 | }], 203 | "users": false, 204 | "shared": false 205 | })); 206 | bboxMock.createMailbox.withArgs('BLA/foo').returns(resolves()); 207 | 208 | imap.createFolder({ 209 | path: 'foo' 210 | }).then(function(fullPath) { 211 | expect(fullPath).to.equal('BLA/foo'); 212 | expect(bboxMock.listNamespaces.calledOnce).to.be.true; 213 | expect(bboxMock.createMailbox.calledOnce).to.be.true; 214 | expect(imap._delimiter).to.exist; 215 | expect(imap._prefix).to.exist; 216 | done(); 217 | }); 218 | }); 219 | 220 | it('should create folder without namespaces', function(done) { 221 | bboxMock.listNamespaces.returns(resolves()); 222 | bboxMock.listMailboxes.returns(resolves({ 223 | "root": true, 224 | "children": [{ 225 | "name": "INBOX", 226 | "delimiter": "/", 227 | "path": "INBOX" 228 | }] 229 | })); 230 | bboxMock.createMailbox.withArgs('foo').returns(resolves()); 231 | 232 | imap.createFolder({ 233 | path: 'foo' 234 | }).then(function(fullPath) { 235 | expect(fullPath).to.equal('foo'); 236 | expect(bboxMock.listNamespaces.calledOnce).to.be.true; 237 | expect(bboxMock.createMailbox.calledOnce).to.be.true; 238 | expect(imap._delimiter).to.exist; 239 | expect(imap._prefix).to.exist; 240 | done(); 241 | }); 242 | }); 243 | 244 | it('should create folder hierarchy with namespaces', function(done) { 245 | bboxMock.listNamespaces.returns(resolves({ 246 | "personal": [{ 247 | "prefix": "BLA/", 248 | "delimiter": "/" 249 | }], 250 | "users": false, 251 | "shared": false 252 | })); 253 | bboxMock.createMailbox.withArgs('foo/bar').returns(resolves()); 254 | bboxMock.createMailbox.withArgs('foo/baz').returns(resolves()); 255 | 256 | imap.createFolder({ 257 | path: ['foo', 'bar'] 258 | }).then(function(fullPath) { 259 | expect(fullPath).to.equal('BLA/foo/bar'); 260 | 261 | return imap.createFolder({ 262 | path: ['foo', 'baz'] 263 | }); 264 | }).then(function(fullPath) { 265 | expect(fullPath).to.equal('BLA/foo/baz'); 266 | 267 | expect(bboxMock.listNamespaces.calledOnce).to.be.true; 268 | expect(bboxMock.createMailbox.calledTwice).to.be.true; 269 | expect(imap._delimiter).to.exist; 270 | expect(imap._prefix).to.exist; 271 | done(); 272 | }); 273 | }); 274 | 275 | it('should create folder hierarchy without namespaces', function(done) { 276 | bboxMock.listNamespaces.returns(resolves()); 277 | bboxMock.listMailboxes.returns(resolves({ 278 | "root": true, 279 | "children": [{ 280 | "name": "INBOX", 281 | "delimiter": "/", 282 | "path": "INBOX" 283 | }] 284 | })); 285 | bboxMock.createMailbox.withArgs('foo').returns(resolves()); 286 | 287 | imap.createFolder({ 288 | path: ['foo', 'bar'] 289 | }).then(function(fullPath) { 290 | expect(fullPath).to.equal('foo/bar'); 291 | expect(bboxMock.listNamespaces.calledOnce).to.be.true; 292 | expect(bboxMock.createMailbox.calledOnce).to.be.true; 293 | expect(imap._delimiter).to.exist; 294 | expect(imap._prefix).to.exist; 295 | done(); 296 | }); 297 | }); 298 | }); 299 | 300 | describe('#search', function() { 301 | it('should search answered', function(done) { 302 | bboxMock.search.withArgs({ 303 | all: true, 304 | answered: true 305 | }).returns(resolves([1, 3, 5])); 306 | 307 | imap.search({ 308 | path: 'foobar', 309 | answered: true 310 | }).then(function(uids) { 311 | expect(uids.length).to.equal(3); 312 | }).then(done); 313 | }); 314 | 315 | it('should search unanswered', function(done) { 316 | bboxMock.search.withArgs({ 317 | all: true, 318 | unanswered: true 319 | }).returns(resolves([1, 3, 5])); 320 | 321 | imap.search({ 322 | path: 'foobar', 323 | answered: false 324 | }).then(function(uids) { 325 | expect(uids.length).to.equal(3); 326 | }).then(done); 327 | }); 328 | 329 | it('should search header', function(done) { 330 | bboxMock.search.withArgs({ 331 | all: true, 332 | header: ['Foo', 'bar'] 333 | }).returns(resolves([1, 3, 5])); 334 | 335 | imap.search({ 336 | path: 'foobar', 337 | header: ['Foo', 'bar'] 338 | }).then(function(uids) { 339 | expect(uids.length).to.equal(3); 340 | }).then(done); 341 | }); 342 | 343 | it('should search read', function(done) { 344 | bboxMock.search.withArgs({ 345 | all: true, 346 | seen: true 347 | }).returns(resolves([1, 3, 5])); 348 | 349 | imap.search({ 350 | path: 'foobar', 351 | unread: false 352 | }).then(function(uids) { 353 | expect(uids.length).to.equal(3); 354 | }).then(done); 355 | }); 356 | 357 | it('should search unread', function(done) { 358 | bboxMock.search.withArgs({ 359 | all: true, 360 | unseen: true 361 | }).returns(resolves([1, 3, 5])); 362 | 363 | imap.search({ 364 | path: 'foobar', 365 | unread: true 366 | }).then(function(uids) { 367 | expect(uids.length).to.equal(3); 368 | }).then(done); 369 | }); 370 | 371 | it('should not search when not logged in', function(done) { 372 | imap._loggedIn = false; 373 | imap.search({ 374 | path: 'foobar', 375 | subject: 'whiteout ' 376 | }).catch(function() { 377 | done(); 378 | }); 379 | }); 380 | }); 381 | 382 | describe('#listMessages', function() { 383 | it('should list messages by uid', function(done) { 384 | var listing = [{ 385 | uid: 1, 386 | envelope: { 387 | 'message-id': 'beepboop', 388 | from: ['zuhause@aol.com'], 389 | 'reply-to': ['zzz@aol.com'], 390 | to: ['bankrupt@duh.com'], 391 | subject: 'SHIAAAT', 392 | date: new Date() 393 | }, 394 | flags: ['\\Seen', '\\Answered', '\\Flagged'], 395 | bodystructure: { 396 | type: 'multipart/mixed', 397 | childNodes: [{ 398 | part: '1', 399 | type: 'text/plain' 400 | }, { 401 | part: '2', 402 | type: 'text/plain', 403 | size: 211, 404 | disposition: 'attachment', 405 | dispositionParameters: { 406 | filename: 'foobar.md' 407 | } 408 | }] 409 | }, 410 | 'body[header.fields (references)]': 'References: \n \n\n' 411 | }, { 412 | uid: 2, 413 | envelope: { 414 | 'message-id': 'ajabwelvzbslvnasd', 415 | }, 416 | bodystructure: { 417 | type: 'multipart/mixed', 418 | childNodes: [{ 419 | part: '1', 420 | type: 'text/plain', 421 | }, { 422 | part: '2', 423 | type: 'multipart/encrypted', 424 | childNodes: [{ 425 | part: '2.1', 426 | type: 'application/pgp-encrypted', 427 | }, { 428 | part: '2.2', 429 | type: 'application/octet-stream', 430 | }] 431 | }] 432 | } 433 | }]; 434 | bboxMock.listMessages.withArgs('1:2', ['uid', 'bodystructure', 'flags', 'envelope', 'body.peek[header.fields (references)]']).returns(resolves(listing)); 435 | 436 | imap.listMessages({ 437 | path: 'foobar', 438 | firstUid: 1, 439 | lastUid: 2 440 | }).then(function(msgs) { 441 | expect(bboxMock.listMessages.calledOnce).to.be.true; 442 | 443 | expect(msgs.length).to.equal(2); 444 | 445 | expect(msgs[0].uid).to.equal(1); 446 | expect(msgs[0].id).to.equal('beepboop'); 447 | expect(msgs[0].from).to.be.instanceof(Array); 448 | expect(msgs[0].replyTo).to.be.instanceof(Array); 449 | expect(msgs[0].to).to.be.instanceof(Array); 450 | expect(msgs[0].subject).to.equal('SHIAAAT'); 451 | expect(msgs[0].unread).to.be.false; 452 | expect(msgs[0].answered).to.be.true; 453 | expect(msgs[0].flagged).to.be.true; 454 | expect(msgs[0].references).to.deep.equal(['abc', 'def']); 455 | 456 | expect(msgs[0].encrypted).to.be.false; 457 | expect(msgs[1].encrypted).to.be.true; 458 | 459 | expect(msgs[0].bodyParts).to.not.be.empty; 460 | expect(msgs[0].bodyParts[0].type).to.equal('text'); 461 | expect(msgs[0].bodyParts[0].partNumber).to.equal('1'); 462 | expect(msgs[0].bodyParts[1].type).to.equal('attachment'); 463 | expect(msgs[0].bodyParts[1].partNumber).to.equal('2'); 464 | 465 | expect(msgs[1].flagged).to.be.false; 466 | expect(msgs[1].bodyParts[0].type).to.equal('text'); 467 | expect(msgs[1].bodyParts[1].type).to.equal('encrypted'); 468 | expect(msgs[1].bodyParts[1].partNumber).to.equal('2'); 469 | expect(msgs[1].references).to.deep.equal([]); 470 | }).then(done); 471 | }); 472 | 473 | it('should list messages by uid', function(done) { 474 | var listing = [{ 475 | uid: 1, 476 | envelope: { 477 | 'message-id': 'beepboop', 478 | from: ['zuhause@aol.com'], 479 | 'reply-to': ['zzz@aol.com'], 480 | to: ['bankrupt@duh.com'], 481 | subject: 'SHIAAAT', 482 | date: new Date() 483 | }, 484 | flags: ['\\Seen', '\\Answered', '\\Flagged'], 485 | bodystructure: { 486 | type: 'multipart/mixed', 487 | childNodes: [{ 488 | part: '1', 489 | type: 'text/plain' 490 | }, { 491 | part: '2', 492 | type: 'text/plain', 493 | size: 211, 494 | disposition: 'attachment', 495 | dispositionParameters: { 496 | filename: 'foobar.md' 497 | } 498 | }] 499 | }, 500 | 'body[header.fields (references)]': 'References: \n \n\n' 501 | }, { 502 | uid: 4, 503 | envelope: { 504 | 'message-id': 'ajabwelvzbslvnasd', 505 | }, 506 | bodystructure: { 507 | type: 'multipart/mixed', 508 | childNodes: [{ 509 | part: '1', 510 | type: 'text/plain', 511 | }, { 512 | part: '2', 513 | type: 'multipart/encrypted', 514 | childNodes: [{ 515 | part: '2.1', 516 | type: 'application/pgp-encrypted', 517 | }, { 518 | part: '2.2', 519 | type: 'application/octet-stream', 520 | }] 521 | }] 522 | } 523 | }]; 524 | bboxMock.listMessages.withArgs('1,4', ['uid', 'bodystructure', 'flags', 'envelope', 'body.peek[header.fields (references)]']).returns(resolves(listing)); 525 | 526 | imap.listMessages({ 527 | path: 'foobar', 528 | uids: [1,4] 529 | }).then(function(msgs) { 530 | expect(bboxMock.listMessages.calledOnce).to.be.true; 531 | 532 | expect(msgs.length).to.equal(2); 533 | 534 | expect(msgs[0].uid).to.equal(1); 535 | expect(msgs[0].id).to.equal('beepboop'); 536 | expect(msgs[0].from).to.be.instanceof(Array); 537 | expect(msgs[0].replyTo).to.be.instanceof(Array); 538 | expect(msgs[0].to).to.be.instanceof(Array); 539 | expect(msgs[0].subject).to.equal('SHIAAAT'); 540 | expect(msgs[0].unread).to.be.false; 541 | expect(msgs[0].answered).to.be.true; 542 | expect(msgs[0].flagged).to.be.true; 543 | expect(msgs[0].references).to.deep.equal(['abc', 'def']); 544 | 545 | expect(msgs[0].encrypted).to.be.false; 546 | expect(msgs[1].encrypted).to.be.true; 547 | 548 | expect(msgs[0].bodyParts).to.not.be.empty; 549 | expect(msgs[0].bodyParts[0].type).to.equal('text'); 550 | expect(msgs[0].bodyParts[0].partNumber).to.equal('1'); 551 | expect(msgs[0].bodyParts[1].type).to.equal('attachment'); 552 | expect(msgs[0].bodyParts[1].partNumber).to.equal('2'); 553 | 554 | expect(msgs[1].flagged).to.be.false; 555 | expect(msgs[1].bodyParts[0].type).to.equal('text'); 556 | expect(msgs[1].bodyParts[1].type).to.equal('encrypted'); 557 | expect(msgs[1].bodyParts[1].partNumber).to.equal('2'); 558 | expect(msgs[1].references).to.deep.equal([]); 559 | }).then(done); 560 | }); 561 | 562 | it('should not list messages by uid due to list error', function(done) { 563 | bboxMock.listMessages.returns(rejects()); 564 | 565 | imap.listMessages({ 566 | path: 'foobar', 567 | firstUid: 1, 568 | lastUid: 2 569 | }).catch(function() { 570 | done(); 571 | }); 572 | }); 573 | 574 | it('should not list messages by uid when not logged in', function(done) { 575 | imap._loggedIn = false; 576 | imap.listMessages({}).catch(function() { 577 | done(); 578 | }); 579 | }); 580 | }); 581 | 582 | describe('#getBodyParts', function() { 583 | it('should get the plain text body', function(done) { 584 | bboxMock.listMessages.withArgs('123:123', ['body.peek[1.mime]', 'body.peek[1]', 'body.peek[2.mime]', 'body.peek[2]']).returns(resolves([{ 585 | 'body[1.mime]': 'qwe', 586 | 'body[1]': 'asd', 587 | 'body[2.mime]': 'bla', 588 | 'body[2]': 'blubb' 589 | }])); 590 | 591 | var parts = [{ 592 | partNumber: '1' 593 | }, { 594 | partNumber: '2' 595 | }]; 596 | imap.getBodyParts({ 597 | path: 'foobar', 598 | uid: 123, 599 | bodyParts: parts 600 | }).then(function(cbParts) { 601 | expect(cbParts).to.equal(parts); 602 | 603 | expect(parts[0].raw).to.equal('qweasd'); 604 | expect(parts[1].raw).to.equal('blablubb'); 605 | expect(parts[0].partNumber).to.not.exist; 606 | expect(parts[1].partNumber).to.not.exist; 607 | }).then(done); 608 | }); 609 | 610 | it('should do nothing for malformed body parts', function(done) { 611 | var parts = [{}, {}]; 612 | 613 | imap.getBodyParts({ 614 | path: 'foobar', 615 | uid: 123, 616 | bodyParts: parts 617 | }).then(function(cbParts) { 618 | expect(cbParts).to.equal(parts); 619 | expect(bboxMock.listMessages.called).to.be.false; 620 | }).then(done); 621 | }); 622 | 623 | it('should fail when list fails', function(done) { 624 | bboxMock.listMessages.returns(rejects()); 625 | 626 | imap.getBodyParts({ 627 | path: 'foobar', 628 | uid: 123, 629 | bodyParts: [{ 630 | partNumber: '1' 631 | }, { 632 | partNumber: '2' 633 | }] 634 | }).catch(function() { 635 | done(); 636 | }); 637 | }); 638 | 639 | it('should not work when not logged in', function(done) { 640 | imap._loggedIn = false; 641 | imap.getBodyParts({ 642 | path: 'foobar', 643 | uid: 123, 644 | bodyParts: [{ 645 | partNumber: '1' 646 | }, { 647 | partNumber: '2' 648 | }] 649 | }).catch(function() { 650 | done(); 651 | }); 652 | }); 653 | }); 654 | 655 | describe('#updateFlags', function() { 656 | it('should update flags', function(done) { 657 | bboxMock.setFlags.withArgs('123:123', { 658 | add: ['\\Flagged', '\\Answered'] 659 | }).returns(resolves()); 660 | 661 | bboxMock.setFlags.withArgs('123:123', { 662 | remove: ['\\Seen'] 663 | }).returns(resolves()); 664 | 665 | imap.updateFlags({ 666 | path: 'INBOX', 667 | uid: 123, 668 | unread: true, 669 | flagged: true, 670 | answered: true 671 | }).then(function() { 672 | expect(bboxMock.setFlags.calledTwice).to.be.true; 673 | }).then(done); 674 | }); 675 | 676 | it('should update flags and skip add', function(done) { 677 | bboxMock.setFlags.withArgs('123:123', { 678 | remove: ['\\Answered'] 679 | }).returns(resolves()); 680 | 681 | imap.updateFlags({ 682 | path: 'INBOX', 683 | uid: 123, 684 | answered: false 685 | }).then(function() { 686 | expect(bboxMock.setFlags.calledOnce).to.be.true; 687 | }).then(done); 688 | }); 689 | 690 | it('should update flags and skip remove', function(done) { 691 | bboxMock.setFlags.withArgs('123:123', { 692 | add: ['\\Answered'] 693 | }).returns(resolves()); 694 | 695 | imap.updateFlags({ 696 | path: 'INBOX', 697 | uid: 123, 698 | answered: true 699 | }).then(function() { 700 | expect(bboxMock.setFlags.calledOnce).to.be.true; 701 | }).then(done); 702 | }); 703 | 704 | it('should update flags and skip add', function(done) { 705 | bboxMock.setFlags.withArgs('123:123', { 706 | remove: ['\\Seen'] 707 | }).returns(resolves()); 708 | 709 | imap.updateFlags({ 710 | path: 'INBOX', 711 | uid: 123, 712 | unread: true 713 | }).then(function() { 714 | expect(bboxMock.setFlags.calledOnce).to.be.true; 715 | }).then(done); 716 | }); 717 | 718 | it('should fail due to set flags error', function(done) { 719 | bboxMock.setFlags.returns(rejects()); 720 | 721 | imap.updateFlags({ 722 | path: 'INBOX', 723 | uid: 123, 724 | unread: false, 725 | answered: true 726 | }).catch(function() { 727 | done(); 728 | }); 729 | }); 730 | 731 | it('should not update flags when not logged in', function(done) { 732 | imap._loggedIn = false; 733 | imap.updateFlags({}).catch(function() { 734 | done(); 735 | }); 736 | }); 737 | }); 738 | 739 | describe('#moveMessage', function() { 740 | it('should work', function(done) { 741 | bboxMock.moveMessages.withArgs('123:123', 'asdasd').returns(resolves()); 742 | 743 | imap.moveMessage({ 744 | path: 'INBOX', 745 | uid: 123, 746 | destination: 'asdasd' 747 | }).then(function() { 748 | expect(bboxMock.moveMessages.calledOnce).to.be.true; 749 | }).then(done); 750 | }); 751 | 752 | it('should fail due to move error', function(done) { 753 | bboxMock.moveMessages.returns(rejects()); 754 | 755 | imap.moveMessage({ 756 | path: 'INBOX', 757 | uid: 123, 758 | destination: 'asdasd' 759 | }).catch(function() { 760 | done(); 761 | }); 762 | }); 763 | 764 | it('should fail due to not logged in', function(done) { 765 | imap._loggedIn = false; 766 | 767 | imap.moveMessage({}).catch(function() { 768 | done(); 769 | }); 770 | }); 771 | 772 | }); 773 | 774 | describe('#uploadMessage', function() { 775 | var msg = 'asdasdasdasd', 776 | path = 'INBOX'; 777 | 778 | it('should work', function(done) { 779 | bboxMock.upload.withArgs(path, msg).returns(resolves()); 780 | 781 | imap.uploadMessage({ 782 | path: path, 783 | message: msg 784 | }).then(function() { 785 | expect(bboxMock.upload.calledOnce).to.be.true; 786 | }).then(done); 787 | }); 788 | 789 | it('should fail due to move error', function(done) { 790 | bboxMock.upload.returns(rejects()); 791 | 792 | imap.uploadMessage({ 793 | path: path, 794 | message: msg 795 | }).catch(function() { 796 | done(); 797 | }); 798 | }); 799 | }); 800 | 801 | describe('#deleteMessage', function() { 802 | it('should work', function(done) { 803 | bboxMock.deleteMessages.withArgs('123:123').returns(resolves()); 804 | 805 | imap.deleteMessage({ 806 | path: 'INBOX', 807 | uid: 123, 808 | }).then(function() { 809 | expect(bboxMock.deleteMessages.calledOnce).to.be.true; 810 | }).then(done); 811 | 812 | }); 813 | 814 | it('should not fail due to delete error', function(done) { 815 | bboxMock.deleteMessages.returns(rejects()); 816 | 817 | imap.deleteMessage({ 818 | path: 'INBOX', 819 | uid: 123, 820 | }).catch(function() { 821 | done(); 822 | }); 823 | }); 824 | 825 | it('should not fail due to not logged in', function(done) { 826 | imap._loggedIn = false; 827 | 828 | imap.deleteMessage({}).catch(function() { 829 | done(); 830 | }); 831 | }); 832 | }); 833 | 834 | describe('#listenForChanges', function() { 835 | it('should start listening', function(done) { 836 | bboxMock.selectMailbox.withArgs('INBOX').returns(resolves()); 837 | 838 | imap.listenForChanges({ 839 | path: 'INBOX' 840 | }).then(function() { 841 | expect(imap._listenerLoggedIn).to.be.true; 842 | expect(bboxMock.connect.calledOnce).to.be.true; 843 | expect(bboxMock.selectMailbox.calledOnce).to.be.true; 844 | }).then(done); 845 | bboxMock.onauth(); 846 | }); 847 | 848 | it('should return an error when inbox could not be opened', function(done) { 849 | bboxMock.selectMailbox.withArgs('INBOX').returns(rejects()); 850 | imap.listenForChanges({ 851 | path: 'INBOX' 852 | }).catch(function() { 853 | done(); 854 | }); 855 | bboxMock.onauth(); 856 | }); 857 | }); 858 | 859 | describe('#stopListeningForChanges', function() { 860 | it('should stop listening', function(done) { 861 | imap._listenerLoggedIn = true; 862 | 863 | imap.stopListeningForChanges().then(function() { 864 | expect(bboxMock.close.calledOnce).to.be.true; 865 | expect(imap._listenerLoggedIn).to.be.false; 866 | }).then(done); 867 | bboxMock.onclose(); 868 | }); 869 | }); 870 | 871 | describe('#_ensurePath', function() { 872 | var ctx = {}; 873 | 874 | it('should switch mailboxes', function(done) { 875 | bboxMock.selectMailbox.withArgs('qweasdzxc', { 876 | ctx: ctx 877 | }).yields(); 878 | imap._ensurePath('qweasdzxc')(ctx, function(err) { 879 | expect(err).to.not.exist; 880 | expect(bboxMock.selectMailbox.calledOnce).to.be.true; 881 | done(); 882 | }); 883 | }); 884 | 885 | it('should error during switching mailboxes', function(done) { 886 | bboxMock.selectMailbox.withArgs('qweasdzxc', { 887 | ctx: ctx 888 | }).yields(new Error()); 889 | imap._ensurePath('qweasdzxc')(ctx, function(err) { 890 | expect(err).to.exist; 891 | expect(bboxMock.selectMailbox.calledOnce).to.be.true; 892 | done(); 893 | }); 894 | }); 895 | }); 896 | }); 897 | 898 | function resolves(val) { 899 | return new Promise(function(res) { 900 | res(val); 901 | }); 902 | } 903 | 904 | function rejects(val) { 905 | return new Promise(function(res, rej) { 906 | rej(val || new Error()); 907 | }); 908 | } 909 | }); -------------------------------------------------------------------------------- /test/unit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JavaScript Integration Tests 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require.config({ 4 | baseUrl: 'lib', 5 | paths: { 6 | 'test': '..', 7 | 'forge': 'forge.min' 8 | }, 9 | shim: { 10 | forge: { 11 | exports: 'forge' 12 | }, 13 | sinon: { 14 | exports: 'sinon' 15 | } 16 | } 17 | }); 18 | 19 | // add function.bind polyfill 20 | if (!Function.prototype.bind) { 21 | Function.prototype.bind = function(oThis) { 22 | if (typeof this !== "function") { 23 | // closest thing possible to the ECMAScript 5 internal IsCallable function 24 | throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); 25 | } 26 | 27 | var aArgs = Array.prototype.slice.call(arguments, 1), 28 | fToBind = this, 29 | FNOP = function() {}, 30 | fBound = function() { 31 | return fToBind.apply(this instanceof FNOP && oThis ? this : oThis, 32 | aArgs.concat(Array.prototype.slice.call(arguments))); 33 | }; 34 | 35 | FNOP.prototype = this.prototype; 36 | fBound.prototype = new FNOP(); 37 | 38 | return fBound; 39 | }; 40 | } 41 | 42 | mocha.setup('bdd'); 43 | require(['test/unit-test'], function() { 44 | (window.mochaPhantomJS || window.mocha).run(); 45 | }); --------------------------------------------------------------------------------