diff --git a/package.json b/package.json index cb4d271..8d52ed2 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "grunt-svgmin": "~1.0.0", "grunt-svgstore": "~0.3.4", "iframe-resizer": "^2.8.3", - "imap-client": "~0.12.0", + "imap-client": "https://github.com/whiteout-io/imap-client/tarball/dev/WO-895", "jquery": "~2.1.1", "mailbuild": "^0.3.7", "mailreader": "~0.4.0", diff --git a/src/js/controller/app/mail-list.js b/src/js/controller/app/mail-list.js index 9327aa5..fd6ef07 100644 --- a/src/js/controller/app/mail-list.js +++ b/src/js/controller/app/mail-list.js @@ -54,24 +54,16 @@ var MailListCtrl = function($scope, $timeout, $location, $filter, $q, status, no // scope functions // - $scope.getBody = function(message) { + $scope.getBody = function(messages) { return $q(function(resolve) { resolve(); }).then(function() { return email.getBody({ folder: currentFolder(), - message: message + messages: messages }); - }).then(function() { - // automatically decrypt if it's the selected message - if (message === currentMessage()) { - return email.decryptBody({ - message: message - }); - } - }).catch(function(err) { if (err.code !== 42) { dialog.error(err); @@ -136,6 +128,10 @@ var MailListCtrl = function($scope, $timeout, $location, $filter, $q, status, no * Date formatting */ $scope.formatDate = function(date) { + if (!date) { + return; + } + if (typeof date === 'string') { date = new Date(date); } diff --git a/src/js/controller/app/navigation.js b/src/js/controller/app/navigation.js index 45dfcb8..07d1911 100644 --- a/src/js/controller/app/navigation.js +++ b/src/js/controller/app/navigation.js @@ -112,9 +112,7 @@ var NavigationCtrl = function($scope, $location, $q, $timeout, account, email, o resolve(); }).then(function() { - return email.refreshFolder({ - folder: ob - }); + return email.refreshOutbox(); }).catch(dialog.error); }; diff --git a/src/js/controller/app/read.js b/src/js/controller/app/read.js index 2f5ecad..3c7a926 100644 --- a/src/js/controller/app/read.js +++ b/src/js/controller/app/read.js @@ -52,6 +52,12 @@ var ReadCtrl = function($scope, $location, $q, email, invitation, outbox, pgp, k status.setReading(false); }; + $scope.decrypt = function(message) { + return email.decryptBody({ + message: message + }); + }; + $scope.getKeyId = function(address) { if ($location.search().dev || !address) { return; diff --git a/src/js/directive/mail-list.js b/src/js/directive/mail-list.js index 0ed8679..40b33e8 100644 --- a/src/js/directive/mail-list.js +++ b/src/js/directive/mail-list.js @@ -1,5 +1,7 @@ 'use strict'; +var PREFETCH_ITEMS = 10; + var ngModule = angular.module('woDirectives'); ngModule.directive('listScroll', function($timeout) { @@ -20,7 +22,10 @@ ngModule.directive('listScroll', function($timeout) { inViewport = false, listItem, message, isPartiallyVisibleTop, isPartiallyVisibleBottom, isVisible, - displayMessages = scope[model]; + displayMessages = scope[model], + visible = [], + prefetchLowerBound = displayMessages.length, // lowest index where we need to start prefetching + prefetchUpperBound = 0; // highest index where we need to start prefetching if (!top && !bottom) { // list not visible @@ -38,7 +43,6 @@ ngModule.directive('listScroll', function($timeout) { } message = displayMessages[i]; - isPartiallyVisibleTop = listItem.top < top && listItem.bottom > top; // a portion of the list item is visible on the top isPartiallyVisibleBottom = listItem.top < bottom && listItem.bottom > bottom; // a portion of the list item is visible on the bottom isVisible = (listItem.top || listItem.bottom) && listItem.top >= top && listItem.bottom <= bottom; // the list item is visible as a whole @@ -47,12 +51,27 @@ ngModule.directive('listScroll', function($timeout) { // we are now iterating over visible elements inViewport = true; // load mail body of visible - scope.getBody(message); + visible.push(message); + + prefetchLowerBound = Math.max(Math.min(prefetchLowerBound, i - 1), 0); + prefetchUpperBound = Math.max(prefetchUpperBound, i + 1); } else if (inViewport) { // we are leaving the viewport, so stop iterating over the items break; } } + + // + // prefetch: [prefetchLowerBound - 20 ; prefetchLowerBound] and [prefetchUpperBound; prefetchUpperBound + 20] + // + + // normalize lowest index to 0, slice interprets values <0 as "start from end" + var prefetchLower = displayMessages.slice(Math.max(prefetchLowerBound - PREFETCH_ITEMS, 0), prefetchLowerBound); + var prefetchUpper = displayMessages.slice(prefetchUpperBound, prefetchUpperBound + PREFETCH_ITEMS); + + visible.concat(prefetchLower).concat(prefetchUpper).forEach(function(email) { + scope.getBody([email]); + }); } scope.loadVisibleBodies = function() { diff --git a/src/js/directive/read.js b/src/js/directive/read.js index e86fd7d..eb1c9ec 100644 --- a/src/js/directive/read.js +++ b/src/js/directive/read.js @@ -92,7 +92,14 @@ ngModule.directive('frameLoad', function($window) { function displayText(body) { var mail = scope.state.mailList.selected; - if ((mail && mail.html) || (mail && mail.encrypted && !mail.decrypted)) { + + if (mail && mail.encrypted && !mail.decrypted) { + // decrypt current mail + scope.decrypt(mail); + return; + } + + if (mail && mail.html) { return; } diff --git a/src/js/email/email.js b/src/js/email/email.js index 8f3a8b7..cde2896 100644 --- a/src/js/email/email.js +++ b/src/js/email/email.js @@ -30,6 +30,7 @@ var FOLDER_TYPE_TRASH = 'Trash'; var FOLDER_TYPE_FLAGGED = 'Flagged'; var MSG_ATTR_UID = 'uid'; +var MSG_ATTR_MODSEQ = 'modseq'; var MSG_PART_ATTR_CONTENT = 'content'; var MSG_PART_TYPE_ATTACHMENT = 'attachment'; var MSG_PART_TYPE_ENCRYPTED = 'encrypted'; @@ -83,13 +84,18 @@ function Email(keychain, pgp, accountStore, pgpbuilder, mailreader, dialog, appC * @resolve {Object} keypair */ Email.prototype.init = function(options) { - this._account = options.account; - this._account.busy = 0; // > 0 triggers the spinner - this._account.online = false; - this._account.loggingIn = false; + var self = this; - // init folders from memory - return this._initFoldersFromDisk(); + self._account = options.account; + self._account.busy = 0; // >0 triggers the spinner + self._account.online = false; + self._account.loggingIn = false; + + // fetch folders from idb + return self._devicestorage.listItems(FOLDER_DB_TYPE, true).then(function(stored) { + self._account.folders = stored[0] || []; + return self._initFolders(); + }); }; /** @@ -202,117 +208,6 @@ Email.prototype.openFolder = function(options) { } }); }; -/** - * Synchronizes a folder's contents from disk to memory, i.e. if - * a message has disappeared from the disk, this method will remove it from folder.messages, and - * it adds any messages from disk to memory the are not yet in folder.messages - * - * @param {Object} options.folder The folder to synchronize - */ -Email.prototype.refreshFolder = function(options) { - var self = this, - folder = options.folder; - - self.busy(); - folder.messages = folder.messages || []; - - return self._localListMessages({ - folder: folder - }).then(function(storedMessages) { - var storedUids = _.pluck(storedMessages, MSG_ATTR_UID), - memoryUids = _.pluck(folder.messages, MSG_ATTR_UID), - newUids = _.difference(storedUids, memoryUids), // uids of messages that are not yet in memory - removedUids = _.difference(memoryUids, storedUids); // uids of messages that are no longer stored on the disk - - // which messages are new on the disk that are not yet in memory? - _.filter(storedMessages, function(msg) { - return _.contains(newUids, msg.uid); - }).forEach(function(newMessage) { - // remove the body parts to not load unnecessary data to memory - // however, don't do that for the outbox. load the full message there. - if (folder.path !== config.outboxMailboxPath) { - delete newMessage.bodyParts; - } - - folder.messages.push(newMessage); - }); - - // which messages are no longer on disk, i.e. have been removed/sent/... - _.filter(folder.messages, function(msg) { - return _.contains(removedUids, msg.uid); - }).forEach(function(removedMessage) { - // remove the message - var index = folder.messages.indexOf(removedMessage); - folder.messages.splice(index, 1); - }); - - }).then(done).catch(done); - - function done(err) { - self.done(); // stop the spinner - updateUnreadCount(folder); // update the unread count - - if (err) { - throw err; - } - } -}; - -/** - * Fetches a message's headers from IMAP. - * - * @param {Object} options.folder The folder for which to fetch the message - */ -Email.prototype.fetchMessages = function(options) { - var self = this, - folder = options.folder; - - self.busy(); - - return new Promise(function(resolve) { - self.checkOnline(); - resolve(); - - }).then(function() { - // list the messages starting from the lowest new uid to the highest new uid - return self._imapListMessages(options); - - }).then(function(messages) { - if (_.isEmpty(messages)) { - // nothing to do, we're done here - return; - } - - // persist the messages to the local storage - return self._localStoreMessages({ - folder: folder, - emails: messages - }).then(function() { - // show the attachment clip in the message list ui - messages.forEach(function(message) { - message.attachments = message.bodyParts.filter(function(bodyPart) { - return bodyPart.type === MSG_PART_TYPE_ATTACHMENT; - }); - }); - - [].unshift.apply(folder.messages, messages); // add the new messages to the folder - updateUnreadCount(folder); // update the unread count - - // notify about new messages only for the inbox - if (folder.type === FOLDER_TYPE_INBOX) { - self.onIncomingMessage(messages); - } - }); - - }).then(done).catch(done); - - function done(err) { - self.done(); // stop the spinner - if (err) { - throw err; - } - } -}; /** * Delete a message from IMAP, disk and folder.messages. @@ -520,173 +415,112 @@ Email.prototype.moveMessage = function(options) { */ Email.prototype.getBody = function(options) { var self = this, - message = options.message, - folder = options.folder, - localMessage, attachmentParts; + messages = options.messages, + folder = options.folder; - // the message either already has a body or is fetching it right now, so no need to become active here - if (message.loadingBody || typeof message.body !== 'undefined') { + messages = messages.filter(function(message) { + // the message either already has a body or is fetching it right now, so no need to become active here + return !(message.loadingBody || typeof message.body !== 'undefined'); + }); + + if (!messages.length) { return new Promise(function(resolve) { resolve(); }); } - message.loadingBody = true; + messages.forEach(function(message) { + message.loadingBody = true; + }); + self.busy(); - /* - * read this before inspecting the method! - * - * you will wonder about the round trip to the disk where we load the persisted object. there are two reasons for this behavior: - * 1) if you work with a message that was loaded from the disk, we strip the message.bodyParts array, - * because it is not really necessary to keep everything in memory - * 2) the message in memory is polluted by angular. angular tracks ordering of a list by adding a property - * to the model. this property is auto generated and must not be persisted. - */ + var loadedMessages; - // load the local message from memory + // load the message from disk return self._localListMessages({ folder: folder, - uid: message.uid + uid: _.pluck(messages, MSG_ATTR_UID) }).then(function(localMessages) { - localMessage = localMessages[0]; + loadedMessages = localMessages; - if (!localMessage) { - // the message has been deleted in the meantime - var error = new Error('Can not get the contents of this message. It has already been deleted!'); - error.hide = true; - throw error; + // find out which messages are not available on disk (uids not included in disk roundtrip) + var localUids = _.pluck(localMessages, MSG_ATTR_UID); + var needsImapFetch = messages.filter(function(msg) { + return !_.contains(localUids, msg.uid); + }); + return needsImapFetch; + + }).then(function(needsImapFetch) { + // get the missing messages from imap + + if (!needsImapFetch.length) { + // no imap roundtrip needed, we're done + return loadedMessages; } - // treat attachment and non-attachment body parts separately: - // we need to fetch the content for non-attachment body parts (encrypted, signed, text, html, resources referenced from the html) - // but we spare the effort and fetch attachment content later upon explicit user request. - var contentParts = localMessage.bodyParts.filter(function(bodyPart) { - return bodyPart.type !== MSG_PART_TYPE_ATTACHMENT || (bodyPart.type === MSG_PART_TYPE_ATTACHMENT && bodyPart.id); - }); - attachmentParts = localMessage.bodyParts.filter(function(bodyPart) { - return bodyPart.type === MSG_PART_TYPE_ATTACHMENT && !bodyPart.id; - }); + // do the imap roundtrip + return self._fetchMessages({ + messages: needsImapFetch, + folder: folder + }).then(function(imapMessages) { + // add the messages from imap to the loaded messages + loadedMessages = loadedMessages.concat(imapMessages); - // do we need to fetch content from the imap server? - var needsFetch = false; - contentParts.forEach(function(part) { - needsFetch = (typeof part.content === 'undefined'); - }); + }).catch(function(err) { + axe.error('Can not fetch messages from IMAP. Reason: ' + err.message + (err.stack ? ('\n' + err.stack) : '')); - if (!needsFetch) { - // if we have all the content we need, - // we can extract the content - message.bodyParts = localMessage.bodyParts; - return; - } - - // get the raw content from the imap server - return self._getBodyParts({ - folder: folder, - uid: localMessage.uid, - bodyParts: contentParts - }).then(function(parsedBodyParts) { - // piece together the parsed bodyparts and the empty attachments which have not been parsed - message.bodyParts = parsedBodyParts.concat(attachmentParts); - localMessage.bodyParts = parsedBodyParts.concat(attachmentParts); - - // persist it to disk - return self._localStoreMessages({ - folder: folder, - emails: [localMessage] + // stop the loading spinner for those messages we can't fetch + needsImapFetch.forEach(function(message) { + message.loadingBody = false; }); + + // we can't fetch from imap, just continue with what we have + messages = _.difference(messages, needsImapFetch); }); }).then(function() { - // extract the content - if (message.encrypted) { - // show the encrypted message - message.body = filterBodyParts(message.bodyParts, MSG_PART_TYPE_ENCRYPTED)[0].content; - return; - } + // enhance dummy messages with content + messages.forEach(function(message) { + var loadedMessage = _.findWhere(loadedMessages, { + uid: message.uid + }); - var root = message.bodyParts; - - if (message.signed) { - // PGP/MIME signed - var signedRoot = filterBodyParts(message.bodyParts, MSG_PART_TYPE_SIGNED)[0]; // in case of a signed message, you only want to show the signed content and ignore the rest - message.signedMessage = signedRoot.signedMessage; - message.signature = signedRoot.signature; - root = signedRoot.content; - } - - var body = _.pluck(filterBodyParts(root, MSG_PART_TYPE_TEXT), MSG_PART_ATTR_CONTENT).join('\n'); - - /* - * if the message is plain text and contains pgp/inline, we are only interested in the encrypted - * content, the rest (corporate mail footer, attachments, etc.) is discarded. - * "-----BEGIN/END (...)-----" must be at the start/end of a line, - * the regex must not match a pgp block in a plain text reply or forward of a pgp/inline message, - * the encryption will break for replies/forward, because "> " corrupts the PGP block with non-radix-64 characters, - */ - var pgpInlineMatch = /^-{5}BEGIN PGP MESSAGE-{5}[\s\S]*-{5}END PGP MESSAGE-{5}$/im.exec(body); - if (pgpInlineMatch) { - message.body = pgpInlineMatch[0]; // show the plain text content - message.encrypted = true; // signal the ui that we're handling encrypted content - - // replace the bodyParts info with an artificial bodyPart of type "encrypted" - message.bodyParts = [{ - type: MSG_PART_TYPE_ENCRYPTED, - content: pgpInlineMatch[0], - _isPgpInline: true // used internally to avoid trying to parse non-MIME text with the mailreader - }]; - return; - } - - /* - * any content before/after the PGP block will be discarded, - * "-----BEGIN/END (...)-----" must be at the start/end of a line, - * after the hash (and possibly other) arbitrary headers, the signed payload begins, - * the text is followed by a final \n and then the pgp signature begins - * untrusted attachments and html is ignored - */ - var clearSignedMatch = /^-{5}BEGIN PGP SIGNED MESSAGE-{5}\nHash:[ ][^\n]+\n(?:[A-Za-z]+:[ ][^\n]+\n)*\n([\s\S]*)\n-{5}BEGIN PGP SIGNATURE-{5}[\S\s]*-{5}END PGP SIGNATURE-{5}$/im.exec(body); - if (clearSignedMatch) { - // PGP/INLINE signed - message.signed = true; - message.clearSignedMessage = clearSignedMatch[0]; - body = clearSignedMatch[1]; - } - - if (!message.signed) { - // message is not signed, so we're done here - return setBody(body, root); - } - - // check the signatures for signed messages - return self._checkSignatures(message).then(function(signaturesValid) { - message.signaturesValid = signaturesValid; - setBody(body, root); + // enhance the dummy message with the loaded content + _.extend(message, loadedMessage); }); }).then(function() { - self.done(); - message.loadingBody = false; - return message; + // extract the message body + var jobs = []; + messages.forEach(function(message) { + var job = self._extractBody(message).catch(function(err) { + axe.error('Can extract body for message uid ' + message.uid + ' . Reason: ' + err.message + (err.stack ? ('\n' + err.stack) : '')); + }); + jobs.push(job); + }); + + return Promise.all(jobs); + }).then(function() { + done(); + + if (options.notifyNew && messages.length) { + // notify for incoming mail + self.onIncomingMessage(messages); + } + + return messages; }).catch(function(err) { - self.done(); - message.loadingBody = false; - if (err.hide) { - // ignore errors with err.hide - return message; - } + done(); throw err; }); - function setBody(body, root) { - message.body = body; - if (!message.clearSignedMessage) { - message.attachments = filterBodyParts(root, MSG_PART_TYPE_ATTACHMENT); - message.html = _.pluck(filterBodyParts(root, MSG_PART_TYPE_HTML), MSG_PART_ATTR_CONTENT).join('\n'); - inlineExternalImages(message); - } + function done() { + messages.forEach(function(message) { + message.loadingBody = false; + }); + self.done(); } }; @@ -939,6 +773,47 @@ Email.prototype.encrypt = function(options) { }); }; +/** + * Synchronizes the outbox's contents from disk to memory. + * If a message has disappeared from the disk, this method will remove + * it from folder.messages, and it adds any messages from disk to memory the are not yet in folder.messages + * + * @param {Object} options.folder The folder to synchronize + */ +Email.prototype.refreshOutbox = function() { + var outbox = _.findWhere(this._account.folders, { + type: config.outboxMailboxType + }); + + return this._localListMessages({ + folder: outbox, + exactmatch: false + }).then(function(storedMessages) { + var storedUids = _.pluck(storedMessages, MSG_ATTR_UID), + memoryUids = _.pluck(outbox.messages, MSG_ATTR_UID), + newUids = _.difference(storedUids, memoryUids), // uids of messages that are not yet in memory + removedUids = _.difference(memoryUids, storedUids); // uids of messages that are no longer stored on the disk + + // add new messages that are not yet in memory + _.filter(storedMessages, function(msg) { + return _.contains(newUids, msg.uid); + }).forEach(function(newMessage) { + outbox.messages.push(newMessage); + }); + + // remove messages that are no longer on disk, i.e. have been removed/sent/... + _.filter(outbox.messages, function(msg) { + return _.contains(removedUids, msg.uid); + }).forEach(function(removedMessage) { + var index = outbox.messages.indexOf(removedMessage); + outbox.messages.splice(index, 1); + }); + + updateUnreadCount(outbox); // update the unread count + }); +}; + + // // @@ -987,7 +862,7 @@ Email.prototype.onConnect = function(imap) { }).then(function() { self._account.loggingIn = false; // init folders - return self._initFoldersFromImap(); + return self._updateFolders(); }).then(function() { // fill the imap mailboxCache with information we have locally available: @@ -997,31 +872,17 @@ Email.prototype.onConnect = function(imap) { // - next expected uid var mailboxCache = {}; self._account.folders.forEach(function(folder) { - if (folder.messages.length === 0) { - return; - } - - var uids, highestModseq, lastUid; - - uids = _.pluck(folder.messages, MSG_ATTR_UID).sort(function(a, b) { + var uids = folder.uids.sort(function(a, b) { return a - b; }); - lastUid = uids[uids.length - 1]; + var lastUid = uids[uids.length - 1]; - highestModseq = (_.pluck(folder.messages, 'modseq').sort(function(a, b) { - // We treat modseq values as numbers here as an exception, should - // be strings everywhere else. - // If it turns out that someone actually uses 64 bit uint numbers - // that do not fit to the JavaScript number type then we should - // use a helper for handling big integers. - return (Number(a) || 0) - (Number(b) || 0); - }).pop() || 0).toString(); mailboxCache[folder.path] = { exists: lastUid, uidNext: lastUid + 1, uidlist: uids, - highestModseq: highestModseq + highestModseq: '' + folder.modseq }; }); self._imapClient.mailboxCache = mailboxCache; @@ -1099,27 +960,54 @@ Email.prototype.onDisconnect = function() { * @param {Array} options.list Array containing update information. Number (uid) or mail with Object (uid and flags), respectively */ Email.prototype._onSyncUpdate = function(options) { - var self = this; + var self = this, + uids = options.list; var folder = _.findWhere(self._account.folders, { path: options.path }); if (!folder) { - // ignore updates for an unknown folder - return; + return; // ignore updates for an unknown folder } if (options.type === SYNC_TYPE_NEW) { - // new messages available on imap, fetch from imap and store to disk and memory - self.fetchMessages({ - folder: folder, - firstUid: Math.min.apply(null, options.list), - lastUid: Math.max.apply(null, options.list) - }).then(self._dialog.error).catch(self._dialog.error); + // new messages available on imap, add the new uids to the folder + + uids = _.difference(uids, folder.uids); // eliminate duplicates + var maxUid = folder.uids.length ? Math.max.apply(null, folder.uids) : 0; // find highest uid prior to update + + // add to folder's uids, persist folder + Array.prototype.push.apply(folder.uids, uids); + self._localStoreFolders(); + + // add dummy messages to the message list + Array.prototype.push.apply(folder.messages, uids.map(function(uid) { + return { + uid: uid + }; + })); + + if (maxUid) { + // folder not empty, find and download the 20 newest bodies. Notify for the inbox + var fetch = _.filter(folder.messages, function(msg) { + return msg.uid > maxUid; + }).sort(function(a, b) { + return a.uid - b.uid; + }).slice(-20); + + self.getBody({ + folder: folder, + messages: fetch, + notifyNew: folder.type === FOLDER_TYPE_INBOX + }).catch(self._dialog.error); + } + } else if (options.type === SYNC_TYPE_DELETED) { - // messages have been deleted, remove from local storage and memory - options.list.forEach(function(uid) { + // messages have been deleted + + folder.uids = _.difference(folder.uids, uids); // remove the uids from the uid list + uids.forEach(function(uid) { var message = _.findWhere(folder.messages, { uid: uid }); @@ -1132,12 +1020,12 @@ Email.prototype._onSyncUpdate = function(options) { folder: folder, message: message, localOnly: true - }).then(self._dialog.error).catch(self._dialog.error); + }).catch(self._dialog.error); }); } else if (options.type === SYNC_TYPE_MSGS) { // NB! several possible reasons why this could be called. // if a message in the array has uid value and flag array, it had a possible flag update - options.list.forEach(function(changedMsg) { + uids.forEach(function(changedMsg) { if (!changedMsg.uid || !changedMsg.flags) { return; } @@ -1146,7 +1034,7 @@ Email.prototype._onSyncUpdate = function(options) { uid: changedMsg.uid }); - if (!message) { + if (!message || !message.bodyParts) { return; } @@ -1159,7 +1047,14 @@ Email.prototype._onSyncUpdate = function(options) { folder: folder, message: message, localOnly: true - }).then(self._dialog.error).catch(self._dialog.error); + }).then(function() { + // update the folder's last known modseq if necessary + var modseq = parseInt(changedMsg.modseq, 10); + if (modseq > folder.modseq) { + folder.modseq = modseq; + return self._localStoreFolders(); + } + }).catch(self._dialog.error); }); } }; @@ -1172,36 +1067,12 @@ Email.prototype._onSyncUpdate = function(options) { // -/** - * Updates the folder information from memory, and adds/removes folders in account.folders. - * The locally available messages are loaded from memory - */ -Email.prototype._initFoldersFromDisk = function() { - var self = this; - - self.busy(); // start the spinner - - // fetch list from local cache - return self._devicestorage.listItems(FOLDER_DB_TYPE, 0, null).then(function(stored) { - self._account.folders = stored[0] || []; - return self._initMessagesFromDisk(); - - }).then(done).catch(done); - - function done(err) { - self.done(); // stop the spinner - if (err) { - throw err; - } - } -}; - /** * Updates the folder information from imap (if we're online). Adds/removes folders in account.folders, * if we added/removed folder in IMAP. If we have an uninitialized folder that lacks folder.messages, * all the locally available messages are loaded from memory. */ -Email.prototype._initFoldersFromImap = function() { +Email.prototype._updateFolders = function() { var self = this; self.busy(); // start the spinner @@ -1313,25 +1184,12 @@ Email.prototype._initFoldersFromImap = function() { }); // if folders have not changed, can fill them with messages directly - if (!foldersChanged) { - return; + if (foldersChanged) { + return self._localStoreFolders(); } - // persist encrypted list in device storage - // note: the folders in the ui also include the messages array, so let's create a clean array here - var folders = self._account.folders.map(function(folder) { - return { - name: folder.name, - path: folder.path, - type: folder.type, - wellknown: !!folder.wellknown - }; - }); - - return self._devicestorage.storeList([folders], FOLDER_DB_TYPE); - }).then(function() { - return self._initMessagesFromDisk(); + return self._initFolders(); }).then(function() { self.done(); @@ -1342,28 +1200,32 @@ Email.prototype._initFoldersFromImap = function() { }); }; -/** - * Fill uninitialized folders with the locally available messages. - */ -Email.prototype._initMessagesFromDisk = function() { +Email.prototype._initFolders = function() { var self = this; - var jobs = []; self._account.folders.forEach(function(folder) { - if (folder.messages) { - // the folder is already initialized - return; - } - - // sync messages from disk to the folder model - jobs.push(self.refreshFolder({ - folder: folder - })); + folder.modseq = folder.modseq || 0; + folder.uids = folder.uids || []; // attach an empty uids array to the folder + folder.uids.sort(function(a, b) { + return a - b; + }); + folder.messages = folder.messages || folder.uids.map(function(uid) { + // fill the messages array with dummy messages, messages will be fetched later + return { + uid: uid + }; + }); }); - return Promise.all(jobs).then(function() { - return; // don't return promise array + var inbox = _.findWhere(self._account.folders, { + type: FOLDER_TYPE_INBOX }); + if (inbox && inbox.messages.length) { + return self.getBody({ + folder: inbox, + messages: inbox.messages.slice(-30) + }).catch(self._dialog.error); + } }; Email.prototype.busy = function() { @@ -1461,27 +1323,6 @@ Email.prototype._imapMoveMessage = function(options) { }); }; - -/** - * Get list messsage headers without the body - * - * @param {String} options.folder The folder - * @param {Number} options.firstUid The lower bound of the uid (inclusive) - * @param {Number} options.lastUid The upper bound of the uid range (inclusive) - * @return {Promise} - * @resolve {Array} messages The message meta data - */ -Email.prototype._imapListMessages = function(options) { - var self = this; - return new Promise(function(resolve) { - self.checkOnline(); - resolve(); - }).then(function() { - options.path = options.folder.path; - return self._imapClient.listMessages(options); - }); -}; - /** * Uploads a built message to a folder * @@ -1497,11 +1338,98 @@ Email.prototype._imapUploadMessage = function(options) { }); }; +/** + * Fetch messages from imap + */ +Email.prototype._fetchMessages = function(options) { + var self = this, + messages = options.messages, + folder = options.folder; + + return new Promise(function(resolve) { + self.checkOnline(); + resolve(); + + }).then(function() { + // fetch all the metadata at once + return self._imapClient.listMessages({ + path: folder.path, + uids: _.pluck(messages, MSG_ATTR_UID) + }); + + }).then(function(msgs) { + messages = msgs; + // displays the clip in the UI if the message contains attachments + messages.forEach(function(message) { + message.attachments = message.bodyParts.filter(function(bodyPart) { + return bodyPart.type === MSG_PART_TYPE_ATTACHMENT; + }); + }); + + // get the bodies from imap (individual roundtrips per msg) + var jobs = []; + + messages.forEach(function(message) { + // fetch only the content for non-attachment body parts (encrypted, signed, text, html, resources referenced from the html) + var contentParts = message.bodyParts.filter(function(bodyPart) { + return bodyPart.type !== MSG_PART_TYPE_ATTACHMENT || (bodyPart.type === MSG_PART_TYPE_ATTACHMENT && bodyPart.id); + }); + var attachmentParts = message.bodyParts.filter(function(bodyPart) { + return bodyPart.type === MSG_PART_TYPE_ATTACHMENT && !bodyPart.id; + }); + + if (!contentParts.length) { + return; + } + + // do the imap roundtrip + var job = self._getBodyParts({ + folder: folder, + uid: message.uid, + bodyParts: contentParts + }).then(function(parsedBodyParts) { + // concat parsed bodyparts and the empty attachment parts + message.bodyParts = parsedBodyParts.concat(attachmentParts); + + // store fetched message + return self._localStoreMessages({ + folder: folder, + emails: [message] + }); + }).catch(function(err) { + // ignore errors with err.hide, throw otherwise + if (err.hide) { + return; + } else { + throw err; + } + }); + + jobs.push(job); + }); + + return Promise.all(jobs); + }).then(function() { + // update the folder's last known modseq if necessary + var highestModseq = Math.max.apply(null, _.pluck(messages, MSG_ATTR_MODSEQ).map(function(modseq) { + return parseInt(modseq, 10); + })); + if (highestModseq > folder.modseq) { + folder.modseq = highestModseq; + return self._localStoreFolders(); + } + + }).then(function() { + updateUnreadCount(folder); // update the unread count + return messages; + }); +}; + /** * Stream an email messsage's body * @param {String} options.folder The folder * @param {String} options.uid the message's uid - * @param {Object} options.bodyParts The message, as retrieved by _imapListMessages + * @param {Object} options.bodyParts The message parts */ Email.prototype._getBodyParts = function(options) { var self = this; @@ -1513,8 +1441,8 @@ Email.prototype._getBodyParts = function(options) { return self._imapClient.getBodyParts(options); }).then(function() { if (options.bodyParts.filter(function(bodyPart) { - return !(bodyPart.raw || bodyPart.content); - }).length) { + return !(bodyPart.raw || bodyPart.content); + }).length) { var error = new Error('Can not get the contents of this message. It has already been deleted!'); error.hide = true; throw error; @@ -1531,6 +1459,24 @@ Email.prototype._getBodyParts = function(options) { // // +/** + * persist encrypted list in device storage + * note: the folders in the ui also include the messages array, so let's create a clean array here + */ +Email.prototype._localStoreFolders = function() { + var folders = this._account.folders.map(function(folder) { + return { + name: folder.name, + path: folder.path, + type: folder.type, + modseq: folder.modseq, + wellknown: !!folder.wellknown, + uids: folder.uids + }; + }); + + return this._devicestorage.storeList([folders], FOLDER_DB_TYPE); +}; /** * List the locally available items form the indexed db stored under "email_[FOLDER PATH]_[MESSAGE UID]" (if a message was provided), @@ -1540,8 +1486,21 @@ Email.prototype._getBodyParts = function(options) { * @param {Object} options.uid A specific uid to look up locally in the folder */ Email.prototype._localListMessages = function(options) { - var dbType = 'email_' + options.folder.path + (options.uid ? '_' + options.uid : ''); - return this._devicestorage.listItems(dbType, 0, null); + var query; + + var needsExactMatch = typeof options.exactmatch === 'undefined' ? true : options.exactmatch; + + if (Array.isArray(options.uid)) { + // batch list + query = options.uid.map(function(uid) { + return 'email_' + options.folder.path + (uid ? '_' + uid : ''); + }); + } else { + // single list + query = 'email_' + options.folder.path + (options.uid ? '_' + options.uid : ''); + } + + return this._devicestorage.listItems(query, needsExactMatch); }; /** @@ -1584,6 +1543,84 @@ Email.prototype._localDeleteMessage = function(options) { // +/** + * Helper method that extracts a message body from the body parts + * + * @param {Object} message DTO + */ +Email.prototype._extractBody = function(message) { + var self = this; + + return new Promise(function(resolve) { + resolve(); + + }).then(function() { + // extract the content + if (message.encrypted) { + // show the encrypted message + message.body = filterBodyParts(message.bodyParts, MSG_PART_TYPE_ENCRYPTED)[0].content; + return; + } + + var root = message.bodyParts; + + if (message.signed) { + // PGP/MIME signed + var signedRoot = filterBodyParts(message.bodyParts, MSG_PART_TYPE_SIGNED)[0]; // in case of a signed message, you only want to show the signed content and ignore the rest + message.signedMessage = signedRoot.signedMessage; + message.signature = signedRoot.signature; + root = signedRoot.content; + } + + var body = _.pluck(filterBodyParts(root, MSG_PART_TYPE_TEXT), MSG_PART_ATTR_CONTENT).join('\n'); + + // if the message is plain text and contains pgp/inline, we are only interested in the encrypted content, the rest (corporate mail footer, attachments, etc.) is discarded. + var pgpInlineMatch = /^-{5}BEGIN PGP MESSAGE-{5}[\s\S]*-{5}END PGP MESSAGE-{5}$/im.exec(body); + if (pgpInlineMatch) { + message.body = pgpInlineMatch[0]; // show the plain text content + message.encrypted = true; // signal the ui that we're handling encrypted content + + // replace the bodyParts info with an artificial bodyPart of type "encrypted" + message.bodyParts = [{ + type: MSG_PART_TYPE_ENCRYPTED, + content: pgpInlineMatch[0], + _isPgpInline: true // used internally to avoid trying to parse non-MIME text with the mailreader + }]; + return; + } + + // any content before/after the PGP block will be discarded, untrusted attachments and html is ignored + var clearSignedMatch = /^-{5}BEGIN PGP SIGNED MESSAGE-{5}\nHash:[ ][^\n]+\n(?:[A-Za-z]+:[ ][^\n]+\n)*\n([\s\S]*)\n-{5}BEGIN PGP SIGNATURE-{5}[\S\s]*-{5}END PGP SIGNATURE-{5}$/im.exec(body); + if (clearSignedMatch) { + // PGP/INLINE signed + message.signed = true; + message.clearSignedMessage = clearSignedMatch[0]; + body = clearSignedMatch[1]; + } + + if (!message.signed) { + // message is not signed, so we're done here + return setBody(body, root); + } + + // check the signatures for signed messages + return self._checkSignatures(message).then(function(signaturesValid) { + message.signed = typeof signaturesValid !== 'undefined'; + message.signaturesValid = signaturesValid; + setBody(body, root); + }); + }); + + function setBody(body, root) { + message.body = body; + if (!message.clearSignedMessage) { + message.attachments = filterBodyParts(root, MSG_PART_TYPE_ATTACHMENT); + message.html = _.pluck(filterBodyParts(root, MSG_PART_TYPE_HTML), MSG_PART_ATTR_CONTENT).join('\n'); + inlineExternalImages(message); + } + } +}; + /** * Parse an email using the mail reader * @param {Object} options The option to be passed to the mailreader diff --git a/src/js/email/outbox.js b/src/js/email/outbox.js index f84d542..cfca924 100644 --- a/src/js/email/outbox.js +++ b/src/js/email/outbox.js @@ -135,7 +135,7 @@ Outbox.prototype._processOutbox = function(callback) { self._outboxBusy = true; // get pending mails from the outbox - self._devicestorage.listItems(outboxDb, 0, null).then(function(pendingMails) { + self._devicestorage.listItems(outboxDb).then(function(pendingMails) { // if we're not online, don't even bother sending mails. if (!self._emailDao._account.online || _.isEmpty(pendingMails)) { unsentMails = pendingMails.length; diff --git a/src/js/service/auth.js b/src/js/service/auth.js index 140b700..e6dde9e 100644 --- a/src/js/service/auth.js +++ b/src/js/service/auth.js @@ -297,7 +297,7 @@ Auth.prototype._loadCredentials = function() { }); function loadFromDB(key) { - return self._appConfigStore.listItems(key, 0, null).then(function(cachedItems) { + return self._appConfigStore.listItems(key).then(function(cachedItems) { return cachedItems && cachedItems[0]; }); } diff --git a/src/js/service/devicestorage.js b/src/js/service/devicestorage.js index 92ee808..959a2fd 100644 --- a/src/js/service/devicestorage.js +++ b/src/js/service/devicestorage.js @@ -83,14 +83,13 @@ DeviceStorage.prototype.removeList = function(type) { /** * List stored items of a given type - * @param type [String] The type of item e.g. 'email' - * @param offset [Number] The offset of items to fetch (0 is the last stored item) - * @param num [Number] The number of items to fetch (null means fetch all) + * @param {String/Array} query The type of item e.g. 'email' + * @param {Boolean} exactMatchOnly Specifies if only exact matches are extracted from the DB as opposed to keys that start with the query * @return {Promise} */ -DeviceStorage.prototype.listItems = function(type, offset, num) { - // fetch all items of a certain type from the data-store - return this._lawnchairDAO.list(type, offset, num); +DeviceStorage.prototype.listItems = function(query, exactMatchOnly) { + // fetch all items of a certain query from the data-store + return this._lawnchairDAO.list(query, exactMatchOnly); }; /** diff --git a/src/js/service/keychain.js b/src/js/service/keychain.js index 819d3d4..3001bf6 100644 --- a/src/js/service/keychain.js +++ b/src/js/service/keychain.js @@ -147,7 +147,7 @@ Keychain.prototype.getReceiverPublicKey = function(userId) { var self = this; // search local keyring for public key - return self._lawnchairDAO.list(DB_PUBLICKEY, 0, null).then(function(allPubkeys) { + return self._lawnchairDAO.list(DB_PUBLICKEY).then(function(allPubkeys) { var userIds; // query primary email address var pubkey = _.findWhere(allPubkeys, { @@ -209,7 +209,7 @@ Keychain.prototype.getUserKeyPair = function(userId) { var self = this; // search for user's public key locally - return self._lawnchairDAO.list(DB_PUBLICKEY, 0, null).then(function(allPubkeys) { + return self._lawnchairDAO.list(DB_PUBLICKEY).then(function(allPubkeys) { var pubkey = _.findWhere(allPubkeys, { userId: userId }); @@ -342,7 +342,7 @@ Keychain.prototype.lookupPublicKey = function(id) { */ Keychain.prototype.listLocalPublicKeys = function() { // search local keyring for public key - return this._lawnchairDAO.list(DB_PUBLICKEY, 0, null); + return this._lawnchairDAO.list(DB_PUBLICKEY); }; Keychain.prototype.removeLocalPublicKey = function(id) { diff --git a/src/js/service/lawnchair.js b/src/js/service/lawnchair.js index 8ec2d50..8bf8ac5 100644 --- a/src/js/service/lawnchair.js +++ b/src/js/service/lawnchair.js @@ -104,60 +104,49 @@ LawnchairDAO.prototype.read = function(key) { /** * List all the items of a certain type * @param type [String] The type of item e.g. 'email' - * @param offset [Number] The offset of items to fetch (0 is the last stored item) - * @param num [Number] The number of items to fetch (null means fetch all) * @return {Promise} */ -LawnchairDAO.prototype.list = function(type, offset, num) { +LawnchairDAO.prototype.list = function(query, exactMatchOnly) { var self = this; return new Promise(function(resolve) { - var i, from, to, - matchingKeys = [], - intervalKeys = [], - list = []; + var matchingKeys = []; // validate input - if (!type || typeof offset === 'undefined' || typeof num === 'undefined') { + if ((Array.isArray(query) && query.length === 0) || (!Array.isArray(query) && !query)) { throw new Error('Args not is not set!'); } + // this method operates on arrays of keys, so normalize input 'key' -> ['key'] + if (!Array.isArray(query)) { + query = [query]; + } + // get all keys self._db.keys(function(keys) { - // check if key begins with type - keys.forEach(function(key) { - if (key.indexOf(type) === 0) { - matchingKeys.push(key); - } + // check if there are keys in the db that start with the respective query + matchingKeys = keys.filter(function(key) { + return query.filter(function(type) { + if (exactMatchOnly) { + return key === type; + } else { + return key.indexOf(type) === 0; + } + }).length > 0; }); - // sort keys - matchingKeys.sort(); - - // set window of items to fetch - // if num is null, list all items - from = (num) ? matchingKeys.length - offset - num : 0; - to = matchingKeys.length - 1 - offset; - // filter items within requested interval - for (i = 0; i < matchingKeys.length; i++) { - if (i >= from && i <= to) { - intervalKeys.push(matchingKeys[i]); - } - } - - // return if there are no matching keys - if (intervalKeys.length === 0) { - resolve(list); + if (matchingKeys.length === 0) { + // no matching keys, resolve + resolve([]); return; } - // fetch all items from data-store with matching key - self._db.get(intervalKeys, function(intervalList) { - intervalList.forEach(function(item) { - list.push(item.object); + // fetch all items from data-store with matching keys + self._db.get(matchingKeys, function(intervalList) { + var result = intervalList.map(function(item) { + return item.object; }); - // return only the interval between offset and num - resolve(list); + resolve(result); }); }); }); diff --git a/src/js/util/update/update-handler.js b/src/js/util/update/update-handler.js index 3a31192..20c86f3 100644 --- a/src/js/util/update/update-handler.js +++ b/src/js/util/update/update-handler.js @@ -32,7 +32,7 @@ UpdateHandler.prototype.update = function() { targetVersion = cfg.dbVersion, versionDbType = 'dbVersion'; - return self._appConfigStorage.listItems(versionDbType, 0, null).then(function(items) { + return self._appConfigStorage.listItems(versionDbType).then(function(items) { // parse the database version number if (items && items.length > 0) { currentVersion = parseInt(items[0], 10); diff --git a/src/js/util/update/update-v4.js b/src/js/util/update/update-v4.js index e44971d..9c0340e 100644 --- a/src/js/util/update/update-v4.js +++ b/src/js/util/update/update-v4.js @@ -66,7 +66,7 @@ function update(options) { }); function loadFromDB(key) { - return options.appConfigStorage.listItems(key, 0, null).then(function(cachedItems) { + return options.appConfigStorage.listItems(key).then(function(cachedItems) { return cachedItems && cachedItems[0]; }); } diff --git a/src/js/util/update/update-v5.js b/src/js/util/update/update-v5.js index f431cc2..6c511ec 100644 --- a/src/js/util/update/update-v5.js +++ b/src/js/util/update/update-v5.js @@ -18,7 +18,7 @@ var POST_UPDATE_DB_VERSION = 5; */ function update(options) { // remove the emails - return options.userStorage.listItems(FOLDER_DB_TYPE, 0, null).then(function(stored) { + return options.userStorage.listItems(FOLDER_DB_TYPE).then(function(stored) { var folders = stored[0] || []; [FOLDER_TYPE_INBOX, FOLDER_TYPE_SENT, FOLDER_TYPE_DRAFTS, FOLDER_TYPE_TRASH].forEach(function(mbxType) { var foldersForType = folders.filter(function(mbx) { diff --git a/test/integration/email-dao-test.js b/test/integration/email-dao-test.js index 6fd5a81..cfd0103 100644 --- a/test/integration/email-dao-test.js +++ b/test/integration/email-dao-test.js @@ -266,8 +266,13 @@ describe('Email DAO integration tests', function() { sinon.stub(emailDao._keychain._publicKeyDao, 'get').returns(resolves(mockKeyPair.publicKey)); sinon.stub(emailDao._keychain._publicKeyDao, 'getByUserId').returns(resolves(mockKeyPair.publicKey)); - emailDao.onIncomingMessage = function(messages) { - expect(messages.length).to.equal(imapMessages.length); + emailDao.unlock({ + passphrase: testAccount.pass, + keypair: mockKeyPair + }).then(function() { + sinon.stub(accountService._emailDao, 'isOnline').returns(true); + return accountService._emailDao.onConnect(imapClient); + }).then(function() { inbox = emailDao._account.folders.filter(function(folder) { return folder.path === 'INBOX'; }).pop(); @@ -277,24 +282,12 @@ describe('Email DAO integration tests', function() { expect(inbox).to.exist; expect(spam).to.exist; - inbox.messages.sort(function(a, b) { - return a.uid - b.uid; - }); - // phantomjs is really slow, so setting the tcp socket timeouts to 200s will effectively disarm the timeout imapClient._client.client.TIMEOUT_SOCKET_LOWER_BOUND = 999999999; imapClient._listeningClient.client.TIMEOUT_SOCKET_LOWER_BOUND = 999999999; smtpClient.TIMEOUT_SOCKET_LOWER_BOUND = 999999999; done(); - }; - - emailDao.unlock({ - passphrase: testAccount.pass, - keypair: mockKeyPair - }).then(function() { - sinon.stub(accountService._emailDao, 'isOnline').returns(true); - accountService._emailDao.onConnect(imapClient); }); }); }); @@ -315,14 +308,18 @@ describe('Email DAO integration tests', function() { describe('IMAP Integration Tests', function() { beforeEach(function(done) { - if (emailDao._imapClient._client.selectedMailbox !== inbox.path) { - emailDao.openFolder({ - folder: inbox - }).then(done); - return; - } + setTimeout(function() { + inbox.messages.sort(function(a, b) { + return a.uid - b.uid; + }); - done(); + emailDao.getBody({ + folder: inbox, + messages: inbox.messages + }).then(function(messages) { + expect(messages.length).to.equal(imapMessages.length); + }).then(done); + }, 200); }); describe('basic functionality', function() { @@ -371,24 +368,12 @@ describe('Email DAO integration tests', function() { }); }); - it('should get body', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[2] - }).then(function(message) { - expect(message.body).to.equal('World 5!'); - done(); - }); + it('should get body', function() { + expect(inbox.messages[2].body).to.equal('World 5!'); }); - it('should insert images into html mail', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[3] - }).then(function(message) { - expect(message.html).to.equal('asd'); - done(); - }); + it('should insert images into html mail', function() { + expect(inbox.messages[3].html).to.equal('asd'); }); it('should set flags', function(done) { @@ -408,268 +393,174 @@ describe('Email DAO integration tests', function() { describe('Real-world data mail parsing', function() { it('should parse Apple Mail (attachment - PGP/MIME): Encrypted', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[4] + emailDao.decryptBody({ + message: inbox.messages[4], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.false; - expect(message.signaturesValid).to.be.undefined; - expect(message.attachments.length).to.equal(1); - expect(message.body).to.equal('test16'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.false; + expect(message.signaturesValid).to.be.undefined; + expect(message.attachments.length).to.equal(1); + expect(message.body).to.equal('test16'); + done(); }); }); it('should parse Apple Mail (attachment - PGP/MIME): Encrypted and signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[5] + emailDao.decryptBody({ + message: inbox.messages[5], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(1); - expect(message.body).to.equal('test15'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.true; + expect(message.signaturesValid).to.be.true; + expect(message.attachments.length).to.equal(1); + expect(message.body).to.equal('test15'); + done(); }); }); it('should parse Apple Mail (no attachment): Encrypted and signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[6] + emailDao.decryptBody({ + message: inbox.messages[6], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test12'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.true; + expect(message.signaturesValid).to.be.true; + expect(message.attachments.length).to.equal(0); + expect(message.body).to.equal('test12'); + done(); }); }); it('should parse Apple Mail (no attachment): Encrypted', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[7] + emailDao.decryptBody({ + message: inbox.messages[7], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.false; - expect(message.signaturesValid).to.be.undefined; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test13'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.false; + expect(message.signaturesValid).to.be.undefined; + expect(message.attachments.length).to.equal(0); + expect(message.body).to.equal('test13'); + done(); }); }); - it('should parse Apple Mail (attachment - PGP/MIME): Signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[8] - }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.false; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(1); - expect(message.body).to.equal('test17\n'); - done(); - }); - }); + it('should parse Apple Mail (attachment - PGP/MIME): Signed', function() { + expect(inbox.messages[8].encrypted).to.be.false; + expect(inbox.messages[8].signed).to.be.true; + expect(inbox.messages[8].signaturesValid).to.be.true; + expect(inbox.messages[8].attachments.length).to.equal(1); + expect(inbox.messages[8].body).to.equal('test17\n'); }); - it('should parse Apple Mail (no attachment): Signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[9] - }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.false; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test14'); - done(); - }); - }); + it('should parse Apple Mail (no attachment): Signed', function() { + expect(inbox.messages[9].encrypted).to.be.false; + expect(inbox.messages[9].signed).to.be.true; + expect(inbox.messages[9].signaturesValid).to.be.true; + expect(inbox.messages[9].attachments.length).to.equal(0); + expect(inbox.messages[9].body).to.equal('test14'); }); it('should parse Thunderbird (attachment - PGP/MIME): Encrypted', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[10] + emailDao.decryptBody({ + message: inbox.messages[10], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.false; - expect(message.signaturesValid).to.be.undefined; - expect(message.attachments.length).to.equal(1); - expect(message.body).to.equal('test10'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.false; + expect(message.signaturesValid).to.be.undefined; + expect(message.attachments.length).to.equal(1); + expect(message.body).to.equal('test10'); + done(); }); }); it('should parse Thunderbird (attachment - PGP/MIME): Encrypted and signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[11] + emailDao.decryptBody({ + message: inbox.messages[11], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(1); - expect(message.body).to.equal('test9'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.true; + expect(message.signaturesValid).to.be.true; + expect(message.attachments.length).to.equal(1); + expect(message.body).to.equal('test9'); + done(); }); }); it('should parse Thunderbird (no attachment): Encrypted and signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[12] + emailDao.decryptBody({ + message: inbox.messages[12], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test4\n'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.true; + expect(message.signaturesValid).to.be.true; + expect(message.attachments.length).to.equal(0); + expect(message.body).to.equal('test4\n'); + done(); }); }); it('should parse Thunderbird (no attachment): Encrypted', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[13] + emailDao.decryptBody({ + message: inbox.messages[13], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.false; - expect(message.signaturesValid).to.be.undefined; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test5\n'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.false; + expect(message.signaturesValid).to.be.undefined; + expect(message.attachments.length).to.equal(0); + expect(message.body).to.equal('test5\n'); + done(); }); }); it('should parse Thunderbird (no attachment): plaintext reply to an encrypted message', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[14] + emailDao.decryptBody({ + message: inbox.messages[14], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.false; - expect(message.signed).to.be.false; - expect(message.signaturesValid).to.be.undefined; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test8\n\n23.06.14 21:12, safewithme kirjutas:\n> test8'); - done(); - }); + expect(message.encrypted).to.be.false; + expect(message.signed).to.be.false; + expect(message.signaturesValid).to.be.undefined; + expect(message.attachments.length).to.equal(0); + expect(message.body).to.equal('test8\n\n23.06.14 21:12, safewithme kirjutas:\n> test8'); + done(); }); }); - it('should parse Thunderbird (attachment - PGP/MIME): Signed', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[15] - }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.false; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(1); - expect(message.body).to.equal('test11'); - done(); - }); - }); + it('should parse Thunderbird (attachment - PGP/MIME): Signed', function() { + expect(inbox.messages[15].encrypted).to.be.false; + expect(inbox.messages[15].signed).to.be.true; + expect(inbox.messages[15].signaturesValid).to.be.true; + expect(inbox.messages[15].attachments.length).to.equal(1); + expect(inbox.messages[15].body).to.equal('test11'); }); - it('should parse Thunderbird (no attachment): Signed w/ PGP/INLINE', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[16] - }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.false; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('test6'); - done(); - }); - }); + it('should parse Thunderbird (no attachment): Signed w/ PGP/INLINE', function() { + expect(inbox.messages[16].encrypted).to.be.false; + expect(inbox.messages[16].signed).to.be.true; + expect(inbox.messages[16].signaturesValid).to.be.true; + expect(inbox.messages[16].attachments.length).to.equal(0); + expect(inbox.messages[16].body).to.equal('test6'); }); it('should parse Mailvelope: encrypted (unsigned) w/PGP/INLINE', function(done) { - emailDao.getBody({ - folder: inbox, - message: inbox.messages[17] + emailDao.decryptBody({ + message: inbox.messages[17], + folder: inbox }).then(function(message) { - emailDao.decryptBody({ - message: message, - folder: inbox - }).then(function() { - expect(message.encrypted).to.be.true; - expect(message.signed).to.be.false; - expect(message.signaturesValid).to.be.undefined; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal('this is a test'); - done(); - }); + expect(message.encrypted).to.be.true; + expect(message.signed).to.be.false; + expect(message.signaturesValid).to.be.undefined; + expect(message.attachments.length).to.equal(0); + expect(message.body).to.equal('this is a test'); + done(); }); }); }); @@ -744,17 +635,12 @@ describe('Email DAO integration tests', function() { var expectedBody = "asdasdasdasdasdasdasdasdasdasdasdasd asdasdasdasdasdasdasdasdasdasdasdasd"; emailDao.onIncomingMessage = function(messages) { - emailDao.getBody({ - folder: inbox, - message: messages[0] - }).then(function(message) { - expect(message.encrypted).to.be.false; - expect(message.signed).to.be.true; - expect(message.signaturesValid).to.be.true; - expect(message.attachments.length).to.equal(0); - expect(message.body).to.equal(expectedBody + str.signature + config.keyServerUrl + '/' + testAccount.user); - done(); - }); + expect(messages[0].encrypted).to.be.false; + expect(messages[0].signed).to.be.true; + expect(messages[0].signaturesValid).to.be.true; + expect(messages[0].attachments.length).to.equal(0); + expect(messages[0].body).to.equal(expectedBody + str.signature + config.keyServerUrl + '/' + testAccount.user); + done(); }; emailDao.sendPlaintext({ @@ -772,13 +658,8 @@ describe('Email DAO integration tests', function() { var expectedBody = "asdasdasdasdasdasdasdasdasdasdasdasd asdasdasdasdasdasdasdasdasdasdasdasd"; emailDao.onIncomingMessage = function(messages) { - emailDao.getBody({ - folder: inbox, + return emailDao.decryptBody({ message: messages[0] - }).then(function(message) { - return emailDao.decryptBody({ - message: message - }); }).then(function(message) { expect(message.encrypted).to.be.true; expect(message.signed).to.be.true; diff --git a/test/unit/controller/app/read-ctrl-test.js b/test/unit/controller/app/read-ctrl-test.js index 3dba37d..7fb7486 100644 --- a/test/unit/controller/app/read-ctrl-test.js +++ b/test/unit/controller/app/read-ctrl-test.js @@ -63,6 +63,21 @@ describe('Read Controller unit test', function() { }); }); + describe('decrypt', function() { + it('should decrypt a message', function(done) { + var msg = {}; + + emailMock.decryptBody.withArgs({ + message: msg + }).returns(resolves()); + + scope.decrypt(msg).then(function() { + expect(emailMock.decryptBody.calledOnce).to.be.true; + done(); + }); + }); + }); + describe('getKeyId', function() { var address = 'asfd@asdf.com'; diff --git a/test/unit/email/email-dao-test.js b/test/unit/email/email-dao-test.js index b4d98c7..98d1d0e 100644 --- a/test/unit/email/email-dao-test.js +++ b/test/unit/email/email-dao-test.js @@ -42,49 +42,63 @@ describe('Email DAO unit tests', function() { name: 'Inbox', type: 'Inbox', path: 'INBOX', - messages: [] + messages: [], + uids: [], + modseq: 123 }; sentFolder = { name: 'Sent', type: 'Sent', path: 'SENT', - messages: [] + messages: [], + uids: [], + modseq: 123 }; draftsFolder = { name: 'Drafts', type: 'Drafts', path: 'DRAFTS', - messages: [] + messages: [], + uids: [], + modseq: 123 }; outboxFolder = { name: 'Outbox', type: 'Outbox', path: 'OUTBOX', - messages: [] + messages: [], + uids: [], + modseq: 123 }; trashFolder = { name: 'Trash', type: 'Trash', path: 'TRASH', - messages: [] + messages: [], + uids: [], + modseq: 123 }; flaggedFolder = { name: 'Flagged', type: 'Flagged', path: 'FLAGGED', - messages: [] + messages: [], + uids: [], + modseq: 123 }; otherFolder = { name: 'Other', type: 'Other', path: 'OTHER', - messages: [] + messages: [], + uids: [], + modseq: 123 }; folders = [inboxFolder, outboxFolder, trashFolder, sentFolder, otherFolder]; @@ -146,20 +160,19 @@ describe('Email DAO unit tests', function() { describe('#init', function() { - var initFoldersStub; - beforeEach(function() { delete dao._account; - initFoldersStub = sinon.stub(dao, '_initFoldersFromDisk'); }); it('should initialize folders', function(done) { - initFoldersStub.returns(resolves()); + devicestorageStub.listItems.withArgs('folders', true).returns(resolves([ + [inboxFolder] + ])); dao.init({ account: account }).then(function() { - expect(initFoldersStub.calledOnce).to.be.true; + expect(devicestorageStub.listItems.calledOnce).to.be.true; done(); }); @@ -301,10 +314,11 @@ describe('Email DAO unit tests', function() { }); }); - describe('#refreshFolder', function() { + describe('#refreshOutbox', function() { var localListStub, mail; beforeEach(function() { + account.folders = [outboxFolder]; localListStub = sinon.stub(dao, '_localListMessages'); mail = { uid: 123, @@ -314,115 +328,43 @@ describe('Email DAO unit tests', function() { it('should add messages from disk', function(done) { localListStub.withArgs({ - folder: inboxFolder + folder: outboxFolder, + exactmatch: false }).returns(resolves([mail])); - dao.refreshFolder({ - folder: inboxFolder - }).then(function() { - expect(inboxFolder.count).to.equal(1); - expect(inboxFolder.messages).to.contain(mail); + dao.refreshOutbox().then(function() { + expect(outboxFolder.count).to.equal(1); + expect(outboxFolder.messages).to.contain(mail); done(); }); }); it('should not add messages from disk', function(done) { - inboxFolder.messages = [mail]; + outboxFolder.messages = [mail]; localListStub.withArgs({ - folder: inboxFolder + folder: outboxFolder, + exactmatch: false }).returns(resolves([mail])); - dao.refreshFolder({ - folder: inboxFolder - }).then(function() { - expect(inboxFolder.count).to.equal(1); - expect(inboxFolder.messages).to.contain(mail); + dao.refreshOutbox().then(function() { + expect(outboxFolder.count).to.equal(1); + expect(outboxFolder.messages).to.contain(mail); done(); }); }); it('should remove messages from memory', function(done) { - inboxFolder.messages = [mail]; + outboxFolder.messages = [mail]; localListStub.withArgs({ - folder: inboxFolder + folder: outboxFolder, + exactmatch: false }).returns(resolves([])); - dao.refreshFolder({ - folder: inboxFolder - }).then(function() { - expect(inboxFolder.count).to.equal(0); - expect(inboxFolder.messages).to.be.empty; - - done(); - }); - }); - }); - - describe('#fetchMessages', function() { - var imapListStub, imapGetStub, imapDeleteStub, localStoreStub; - var opts, message; - var notified; - - beforeEach(function() { - imapListStub = sinon.stub(dao, '_imapListMessages'); - imapDeleteStub = sinon.stub(dao, '_imapDeleteMessage'); - imapGetStub = sinon.stub(dao, '_getBodyParts'); - localStoreStub = sinon.stub(dao, '_localStoreMessages'); - - opts = { - folder: inboxFolder, - firstUid: 123, - lastUid: 123 - }; - message = { - uid: 123, - subject: 'asdasd', - unread: true, - bodyParts: [] - }; - - notified = false; - dao.onIncomingMessage = function(newMessages) { - expect(newMessages).to.contain(message); - notified = true; - }; - }); - - it('should fetch message downstream', function(done) { - imapListStub.withArgs(opts).returns(resolves([message])); - - localStoreStub.withArgs({ - folder: inboxFolder, - emails: [message] - }).returns(resolves()); - - dao.fetchMessages(opts).then(function() { - expect(inboxFolder.messages).to.contain(message); - expect(notified).to.be.true; - expect(localStoreStub.calledOnce).to.be.true; - expect(imapListStub.calledOnce).to.be.true; - - done(); - }); - }); - - it('should not notify for other folders', function(done) { - opts.folder = sentFolder; - - imapListStub.withArgs(opts).returns(resolves([message])); - - localStoreStub.withArgs({ - folder: sentFolder, - emails: [message] - }).returns(resolves()); - - dao.fetchMessages(opts).then(function() { - expect(sentFolder.messages).to.contain(message); - expect(notified).to.be.false; - expect(localStoreStub.calledOnce).to.be.true; - expect(imapListStub.calledOnce).to.be.true; + dao.refreshOutbox().then(function() { + expect(outboxFolder.count).to.equal(0); + expect(outboxFolder.messages).to.be.empty; done(); }); @@ -794,13 +736,13 @@ describe('Email DAO unit tests', function() { }); describe('#getBody', function() { - var localListStub, localStoreStub, imapGetStub, uid; + var localListStub, localStoreStub, fetchMessagesStub, uid; beforeEach(function() { uid = 12345; localListStub = sinon.stub(dao, '_localListMessages'); localStoreStub = sinon.stub(dao, '_localStoreMessages'); - imapGetStub = sinon.stub(dao, '_getBodyParts'); + fetchMessagesStub = sinon.stub(dao, '_fetchMessages'); }); it('should not do anything if the message already has content', function() { @@ -809,24 +751,23 @@ describe('Email DAO unit tests', function() { }; dao.getBody({ - message: message + messages: [message] }); // should do nothing }); it('should read an unencrypted body from the device', function(done) { - var message, body; - - body = 'bender is great! bender is great!'; - message = { + var body = 'bender is great! bender is great!'; + var message = { uid: uid }; localListStub.withArgs({ folder: inboxFolder, - uid: uid + uid: [uid] }).returns(resolves([{ + uid: uid, bodyParts: [{ type: 'text', content: body @@ -834,12 +775,11 @@ describe('Email DAO unit tests', function() { }])); dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(body); - expect(msg.loadingBody).to.be.false; + }).then(function() { + expect(message.body).to.equal(body); + expect(message.loadingBody).to.be.false; expect(localListStub.calledOnce).to.be.true; @@ -849,19 +789,18 @@ describe('Email DAO unit tests', function() { }); it('should read a pgp/mime from the device', function(done) { - var message, ct, pt; - - pt = 'bender is great!'; - ct = '-----BEGIN PGP MESSAGE-----asdasdasd-----END PGP MESSAGE-----'; - message = { + var pt = 'bender is great!'; + var ct = '-----BEGIN PGP MESSAGE-----asdasdasd-----END PGP MESSAGE-----'; + var message = { uid: uid, encrypted: true }; localListStub.withArgs({ folder: inboxFolder, - uid: uid + uid: [uid] }).returns(resolves([{ + uid: uid, bodyParts: [{ type: 'text', content: pt @@ -872,12 +811,11 @@ describe('Email DAO unit tests', function() { }])); dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(ct); - expect(msg.encrypted).to.be.true; + }).then(function() { + expect(message.body).to.equal(ct); + expect(message.encrypted).to.be.true; expect(message.loadingBody).to.be.false; expect(localListStub.calledOnce).to.be.true; @@ -888,13 +826,11 @@ describe('Email DAO unit tests', function() { }); it('should read a signed pgp/mime from the device', function(done) { - var message, signed, pt, signedMimeTree, signature; - - pt = 'bender is great!'; - signed = 'omg signed text'; - signedMimeTree = 'trallalalalala'; - signature = 'ugauga'; - message = { + var pt = 'bender is great!'; + var signed = 'omg signed text'; + var signedMimeTree = 'trallalalalala'; + var signature = 'ugauga'; + var message = { uid: uid, signed: true, from: [{ @@ -904,8 +840,9 @@ describe('Email DAO unit tests', function() { localListStub.withArgs({ folder: inboxFolder, - uid: uid + uid: [uid] }).returns(resolves([{ + uid: uid, bodyParts: [{ type: 'text', content: pt @@ -923,11 +860,10 @@ describe('Email DAO unit tests', function() { pgpStub.verifySignedMessage.withArgs(signedMimeTree, signature, mockKeyPair.publicKey.publicKey).returns(resolves(true)); dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(signed); + }).then(function() { + expect(message.body).to.equal(signed); expect(message.signed).to.be.true; expect(message.signaturesValid).to.be.true; expect(message.loadingBody).to.be.false; @@ -942,15 +878,14 @@ describe('Email DAO unit tests', function() { }); it('should read a pgp/inline from the device', function(done) { - var message, ct, pt; - - ct = '-----BEGIN PGP MESSAGE-----\nasdasdasd\n-----END PGP MESSAGE-----'; - pt = 'bla bla yadda yadda'; - message = { + var ct = '-----BEGIN PGP MESSAGE-----\nasdasdasd\n-----END PGP MESSAGE-----'; + var pt = 'bla bla yadda yadda'; + var message = { uid: uid }; localListStub.returns(resolves([{ + uid: uid, bodyParts: [{ type: 'text', content: pt @@ -964,14 +899,13 @@ describe('Email DAO unit tests', function() { }])); dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(ct); - expect(msg.bodyParts[0].type).to.equal('encrypted'); - expect(msg.bodyParts[0].content).to.equal(ct); - expect(msg.encrypted).to.be.true; + }).then(function() { + expect(message.body).to.equal(ct); + expect(message.bodyParts[0].type).to.equal('encrypted'); + expect(message.bodyParts[0].content).to.equal(ct); + expect(message.encrypted).to.be.true; expect(message.loadingBody).to.be.false; expect(localListStub.calledOnce).to.be.true; @@ -982,11 +916,9 @@ describe('Email DAO unit tests', function() { }); it('should read a signed pgp/inline from the device', function(done) { - var message, pt, expected; - - expected = 'Lorem ipsum Aliquip tempor veniam proident.\n\nafguab;igab;igubalw\n\nLorem ipsum Dolor sed irure sint in non.\n\n\n'; - pt = '-----BEGIN PGP SIGNED MESSAGE-----\nHash: WTFHASH\n\n' + expected + '\n-----BEGIN PGP SIGNATURE----------END PGP SIGNATURE-----'; - message = { + var expected = 'Lorem ipsum Aliquip tempor veniam proident.\n\nafguab;igab;igubalw\n\nLorem ipsum Dolor sed irure sint in non.\n\n\n'; + var pt = '-----BEGIN PGP SIGNED MESSAGE-----\nHash: WTFHASH\n\n' + expected + '\n-----BEGIN PGP SIGNATURE----------END PGP SIGNATURE-----'; + var message = { uid: uid, from: [{ address: 'asdasdasd' @@ -994,6 +926,7 @@ describe('Email DAO unit tests', function() { }; localListStub.returns(resolves([{ + uid: uid, bodyParts: [{ type: 'text', content: pt @@ -1003,11 +936,10 @@ describe('Email DAO unit tests', function() { pgpStub.verifyClearSignedMessage.withArgs(pt, mockKeyPair.publicKey.publicKey).returns(resolves(true)); dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(expected); + }).then(function() { + expect(message.body).to.equal(expected); expect(message.signed).to.be.true; expect(message.signaturesValid).to.be.true; expect(message.loadingBody).to.be.false; @@ -1021,238 +953,79 @@ describe('Email DAO unit tests', function() { expect(message.loadingBody).to.be.true; }); - it('should stream from imap and set plain text body', function(done) { - var message, body, uid; - - body = 'bender is great! bender is great!'; - uid = 1234; - message = { - uid: uid, - bodyParts: [{ - type: 'text' - }] + it('should stream from imap and set body', function(done) { + var body = 'bender is great! bender is great!'; + var uid = 1234; + var message = { + uid: uid }; localListStub.withArgs({ folder: inboxFolder, - uid: uid - }).returns(resolves([message])); + uid: [uid] + }).returns(resolves([])); - localStoreStub.withArgs({ - folder: inboxFolder, - emails: [message] - }).returns(resolves()); - - imapGetStub.withArgs({ - folder: inboxFolder, - uid: message.uid, - bodyParts: message.bodyParts + fetchMessagesStub.withArgs({ + messages: [message], + folder: inboxFolder }).returns(resolves([{ - type: 'text', - content: body + uid: uid, + encrypted: false, + bodyParts: [{ + type: 'text', + content: body + }] }])); dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(body); - expect(msg.loadingBody).to.be.false; - - expect(localListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(localStoreStub.calledOnce).to.be.true; - - done(); - }); - expect(message.loadingBody).to.be.true; - }); - - it('should stream from imap and set encrypted body', function(done) { - var message, ct, pt; - - pt = 'bender is great'; - ct = '-----BEGIN PGP MESSAGE-----asdasdasd-----END PGP MESSAGE-----'; - message = { - uid: uid, - encrypted: true, - bodyParts: [{ - type: 'text' - }, { - type: 'encrypted' - }] - }; - - localListStub.withArgs({ - folder: inboxFolder, - uid: uid - }).returns(resolves([message])); - - localStoreStub.withArgs({ - folder: inboxFolder, - emails: [message] - }).returns(resolves()); - - imapGetStub.withArgs({ - folder: inboxFolder, - uid: message.uid, - bodyParts: message.bodyParts - }).returns(resolves([{ - type: 'text', - content: pt - }, { - type: 'encrypted', - content: ct - }])); - - - dao.getBody({ - message: message, - folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.equal(ct); - expect(msg.encrypted).to.be.true; - expect(msg.loadingBody).to.be.false; - - expect(localListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(localStoreStub.calledOnce).to.be.true; - - done(); - }); - expect(message.loadingBody).to.be.true; - }); - - it('should not error when message is deleted from imap', function(done) { - var error = new Error('Can not get the contents of this message. It has already been deleted!'); - error.hide = true; - - var message = { - uid: uid, - encrypted: true, - bodyParts: [{ - type: 'text' - }] - }; - - localListStub.withArgs({ - folder: inboxFolder, - uid: uid - }).returns(resolves([message])); - - localStoreStub.withArgs({ - folder: inboxFolder, - emails: [message] - }).returns(resolves()); - - imapGetStub.withArgs({ - folder: inboxFolder, - uid: message.uid, - bodyParts: message.bodyParts - }).returns(rejects(error)); - - - dao.getBody({ - message: message, - folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.not.exist; - expect(msg.loadingBody).to.be.false; - - expect(localListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(localStoreStub.called).to.be.false; - - done(); - }); - expect(message.loadingBody).to.be.true; - }); - - it('should not error when message has already been removed from memory', function(done) { - var message = { - uid: uid, - encrypted: true, - bodyParts: [{ - type: 'text' - }] - }; - - localListStub.returns(resolves([])); - - dao.getBody({ - message: message, - folder: inboxFolder - }).then(function(msg) { - expect(msg).to.equal(message); - expect(msg.body).to.not.exist; - expect(msg.loadingBody).to.be.false; - - expect(localListStub.calledOnce).to.be.true; - expect(imapGetStub.called).to.be.false; - expect(localStoreStub.called).to.be.false; - - done(); - }); - expect(message.loadingBody).to.be.true; - }); - - it('fail to stream from imap due to error when persisting', function(done) { - var message = { - uid: uid, - bodyParts: [{ - type: 'text' - }] - }; - - localListStub.returns(resolves([message])); - localStoreStub.returns(rejects({})); - imapGetStub.returns(resolves([{ - type: 'text', - content: 'bender is great! bender is great!' - }])); - - dao.getBody({ - message: message, - folder: inboxFolder - }).catch(function(err) { - expect(err).to.exist; - expect(localListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(localStoreStub.calledOnce).to.be.true; - + }).then(function() { + expect(message.body).to.equal(body); expect(message.loadingBody).to.be.false; + expect(localListStub.calledOnce).to.be.true; + expect(fetchMessagesStub.calledOnce).to.be.true; + done(); }); + expect(message.loadingBody).to.be.true; }); - it('fail to stream from imap due to stream error', function(done) { + it('should not error when message is not available in imap', function(done) { var message = { uid: uid, + encrypted: true, bodyParts: [{ type: 'text' }] }; - localListStub.returns(resolves([message])); - imapGetStub.returns(rejects({})); + localListStub.withArgs({ + folder: inboxFolder, + uid: [uid] + }).returns(resolves()); + + fetchMessagesStub.withArgs({ + messages: [message], + folder: inboxFolder + }).returns(rejects(new Error())); + dao.getBody({ - message: message, + messages: [message], folder: inboxFolder - }).catch(function(err) { - expect(err).to.exist; - expect(localListStub.calledOnce).to.be.true; - expect(imapGetStub.calledOnce).to.be.true; - expect(localStoreStub.called).to.be.false; - + }).then(function(msgs) { + expect(msgs).to.be.empty; + expect(message.body).to.not.exist; expect(message.loadingBody).to.be.false; + expect(localListStub.calledOnce).to.be.true; + expect(fetchMessagesStub.calledOnce).to.be.true; + done(); }); + expect(message.loadingBody).to.be.true; }); }); @@ -1707,7 +1480,6 @@ describe('Email DAO unit tests', function() { done(); }); }); - }); describe('#sendPlaintext', function() { @@ -1830,7 +1602,7 @@ describe('Email DAO unit tests', function() { var initFoldersStub, credentials; beforeEach(function() { - initFoldersStub = sinon.stub(dao, '_initFoldersFromImap'); + initFoldersStub = sinon.stub(dao, '_updateFolders'); sinon.stub(dao, 'isOnline'); delete dao._imapClient; @@ -1840,10 +1612,8 @@ describe('Email DAO unit tests', function() { }); it('should connect', function(done) { - inboxFolder.messages = [{ - uid: 123, - modseq: '123' - }]; + account.folders = [inboxFolder]; + inboxFolder.uids = [123]; dao.isOnline.returns(true); authStub.getCredentials.returns(resolves(credentials)); imapClientStub.login.returns(resolves()); @@ -1897,35 +1667,45 @@ describe('Email DAO unit tests', function() { }); describe('#_onSyncUpdate', function() { - var fetchMessagesStub, deleteMessagesStub, setFlagsStub, msgs; + var getBodyStub, deleteMessagesStub, setFlagsStub, localStoreFoldersStub; + var msgs; beforeEach(function() { msgs = [{ uid: 5, - flags: ['\\Answered', '\\Seen'] + flags: ['\\Answered', '\\Seen'], + bodyParts: [] }]; inboxFolder.messages = msgs; - fetchMessagesStub = sinon.stub(dao, 'fetchMessages'); + inboxFolder.uids = [5]; + getBodyStub = sinon.stub(dao, 'getBody'); deleteMessagesStub = sinon.stub(dao, 'deleteMessage'); setFlagsStub = sinon.stub(dao, 'setFlags'); + localStoreFoldersStub = sinon.stub(dao, '_localStoreFolders'); }); it('should get new message', function(done) { - fetchMessagesStub.withArgs({ + getBodyStub.withArgs({ folder: inboxFolder, - firstUid: 1, - lastUid: 3 + messages: [{ + uid: 7 + }, { + uid: 8 + }], + notifyNew: true }).returns(resolves()); + localStoreFoldersStub.returns(resolves()); + dao._onSyncUpdate({ type: 'new', path: inboxFolder.path, - list: [1, 3] + list: [1, 7, 8] }); setTimeout(function() { - expect(dialogStub.error.calledOnce).to.be.true; - expect(fetchMessagesStub.calledOnce).to.be.true; + expect(getBodyStub.calledOnce).to.be.true; + expect(localStoreFoldersStub.calledOnce).to.be.true; done(); }, 0); }); @@ -1944,7 +1724,6 @@ describe('Email DAO unit tests', function() { }); setTimeout(function() { - expect(dialogStub.error.calledOnce).to.be.true; expect(deleteMessagesStub.calledOnce).to.be.true; done(); }, 0); @@ -1964,7 +1743,6 @@ describe('Email DAO unit tests', function() { }); setTimeout(function() { - expect(dialogStub.error.calledOnce).to.be.true; expect(setFlagsStub.calledOnce).to.be.true; done(); }, 0); @@ -2054,50 +1832,15 @@ describe('Email DAO unit tests', function() { }); }); - describe('#_initFoldersFromDisk', function() { + describe('#_updateFolders', function() { beforeEach(function() { - sinon.stub(dao, 'refreshFolder'); - }); - - it('should initialize from disk if offline and not refresh folder', function(done) { - devicestorageStub.listItems.withArgs('folders').returns(resolves([ - [inboxFolder] - ])); - dao.refreshFolder.withArgs({ - folder: inboxFolder - }).returns(resolves()); - - dao._initFoldersFromDisk().then(function() { - expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(dao.refreshFolder.called).to.be.false; - done(); - }); - }); - - it('should initialize from disk if offline and refresh folder', function(done) { - delete inboxFolder.messages; - devicestorageStub.listItems.withArgs('folders').returns(resolves([ - [inboxFolder] - ])); - dao.refreshFolder.withArgs({ - folder: inboxFolder - }).returns(resolves()); - - dao._initFoldersFromDisk().then(function() { - expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(dao.refreshFolder.calledOnce).to.be.true; - done(); - }); - }); - }); - - describe('#_initFoldersFromImap', function() { - beforeEach(function() { - sinon.stub(dao, 'refreshFolder'); + sinon.stub(dao, 'getBody'); }); it('should initialize from imap if online', function(done) { account.folders = []; + inboxFolder.uids = [7]; + inboxFolder.messages = undefined; imapClientStub.listWellKnownFolders.returns(resolves({ Inbox: [inboxFolder], Sent: [sentFolder], @@ -2131,11 +1874,17 @@ describe('Email DAO unit tests', function() { return true; }), 'folders').returns(resolves()); - dao.refreshFolder.returns(resolves()); + dao.getBody.withArgs({ + folder: inboxFolder, + messages: [{ + uid: 7 + }] + }).returns(resolves()); - dao._initFoldersFromImap().then(function() { + dao._updateFolders().then(function() { expect(imapClientStub.listWellKnownFolders.calledOnce).to.be.true; expect(devicestorageStub.storeList.calledOnce).to.be.true; + expect(dao.getBody.calledOnce).to.be.true; done(); }); }); @@ -2156,51 +1905,34 @@ describe('Email DAO unit tests', function() { Other: [otherFolder] })); devicestorageStub.storeList.withArgs(sinon.match(function(arg) { - expect(arg[0]).to.deep.equal([{ - name: inboxFolder.name, - path: inboxFolder.path, - type: inboxFolder.type, - wellknown: true - }, { - name: sentFolder.name, - path: sentFolder.path, - type: sentFolder.type, - wellknown: true - }, { - name: outboxFolder.name, - path: outboxFolder.path, - type: outboxFolder.type, - wellknown: true - }, { - name: draftsFolder.name, - path: draftsFolder.path, - type: draftsFolder.type, - wellknown: true - }, { - name: trashFolder.name, - path: trashFolder.path, - type: trashFolder.type, - wellknown: true - }, { - name: flaggedFolder.name, - path: flaggedFolder.path, - type: flaggedFolder.type, - wellknown: true - }, { - name: otherFolder.name, - path: otherFolder.path, - type: otherFolder.type, - wellknown: false - }]); - + expect(arg[0][0].name).to.deep.equal(inboxFolder.name); + expect(arg[0][0].path).to.deep.equal(inboxFolder.path); + expect(arg[0][0].type).to.deep.equal(inboxFolder.type); + expect(arg[0][1].name).to.deep.equal(sentFolder.name); + expect(arg[0][1].path).to.deep.equal(sentFolder.path); + expect(arg[0][1].type).to.deep.equal(sentFolder.type); + expect(arg[0][2].name).to.deep.equal(outboxFolder.name); + expect(arg[0][2].path).to.deep.equal(outboxFolder.path); + expect(arg[0][2].type).to.deep.equal(outboxFolder.type); + expect(arg[0][3].name).to.deep.equal(draftsFolder.name); + expect(arg[0][3].path).to.deep.equal(draftsFolder.path); + expect(arg[0][3].type).to.deep.equal(draftsFolder.type); + expect(arg[0][4].name).to.deep.equal(trashFolder.name); + expect(arg[0][4].path).to.deep.equal(trashFolder.path); + expect(arg[0][4].type).to.deep.equal(trashFolder.type); + expect(arg[0][5].name).to.deep.equal(flaggedFolder.name); + expect(arg[0][5].path).to.deep.equal(flaggedFolder.path); + expect(arg[0][5].type).to.deep.equal(flaggedFolder.type); + expect(arg[0][6].name).to.deep.equal(otherFolder.name); + expect(arg[0][6].path).to.deep.equal(otherFolder.path); + expect(arg[0][6].type).to.deep.equal(otherFolder.type); return true; }), 'folders').returns(resolves()); - dao.refreshFolder.returns(resolves()); - - dao._initFoldersFromImap().then(function() { + dao._updateFolders().then(function() { expect(imapClientStub.listWellKnownFolders.calledOnce).to.be.true; expect(devicestorageStub.storeList.calledOnce).to.be.true; + expect(dao.getBody.called).to.be.false; done(); }); }); @@ -2282,26 +2014,62 @@ describe('Email DAO unit tests', function() { }); }); - describe('#_imapListMessages', function() { - var firstUid = 1337, - lastUid = 1339; + describe('#_fetchMessages', function() { + var localStoreStub, localStoreFoldersStub, getBodyPartsStub; + var messages; + + beforeEach(function() { + localStoreStub = sinon.stub(dao, '_localStoreMessages'); + localStoreFoldersStub = sinon.stub(dao, '_localStoreFolders'); + getBodyPartsStub = sinon.stub(dao, '_getBodyParts'); + messages = [{ + uid: 1337, + subject: 'asdasd', + unread: true, + modseq: '124', + bodyParts: [{ + type: 'text' + }] + }, { + uid: 1339, + subject: 'asdasd', + unread: true, + modseq: '124', + bodyParts: [{ + type: 'attachment' + }] + }]; + }); it('should list messages', function(done) { imapClientStub.listMessages.withArgs({ - folder: inboxFolder, path: inboxFolder.path, - firstUid: firstUid, - lastUid: lastUid - }).returns(resolves([])); - - dao._imapListMessages({ + uids: [1337, 1339] + }).returns(resolves(messages)); + localStoreStub.withArgs({ folder: inboxFolder, - firstUid: firstUid, - lastUid: lastUid + emails: [messages[0]] + }).returns(resolves()); + localStoreStub.withArgs({ + folder: inboxFolder, + emails: [messages[1]] + }).returns(resolves()); + localStoreFoldersStub.returns(resolves()); + getBodyPartsStub.withArgs({ + folder: inboxFolder, + uid: messages[0].uid, + bodyParts: messages[0].bodyParts + }).returns(resolves(messages[0].bodyParts)); + + dao._fetchMessages({ + folder: inboxFolder, + messages: messages }).then(function(msgs) { expect(msgs).to.exist; expect(imapClientStub.listMessages.calledOnce).to.be.true; + expect(localStoreStub.calledOnce).to.be.true; + expect(getBodyPartsStub.calledOnce).to.be.true; done(); }); @@ -2310,10 +2078,9 @@ describe('Email DAO unit tests', function() { it('should fail when listMessages fails', function(done) { imapClientStub.listMessages.returns(rejects({})); - dao._imapListMessages({ + dao._fetchMessages({ folder: inboxFolder, - firstUid: firstUid, - lastUid: lastUid + messages: messages }).catch(function(err) { expect(err).to.exist; expect(imapClientStub.listMessages.calledOnce).to.be.true; @@ -2325,7 +2092,7 @@ describe('Email DAO unit tests', function() { it('should fail when disconnected', function(done) { dao._account.online = false; - dao._imapListMessages({}).catch(function(err) { + dao._fetchMessages({}).catch(function(err) { expect(err.code).to.equal(42); done(); }); @@ -2456,7 +2223,7 @@ describe('Email DAO unit tests', function() { var uid = 123; it('should list without uid', function(done) { - devicestorageStub.listItems.withArgs('email_' + inboxFolder.path, 0, null).returns(resolves([{}])); + devicestorageStub.listItems.withArgs('email_' + inboxFolder.path, true).returns(resolves([{}])); dao._localListMessages({ folder: inboxFolder @@ -2467,7 +2234,7 @@ describe('Email DAO unit tests', function() { }); it('should list with uid', function(done) { - devicestorageStub.listItems.withArgs('email_' + inboxFolder.path + '_' + uid, 0, null).returns(resolves([{}])); + devicestorageStub.listItems.withArgs('email_' + inboxFolder.path + '_' + uid, true).returns(resolves([{}])); dao._localListMessages({ folder: inboxFolder, diff --git a/test/unit/service/auth-test.js b/test/unit/service/auth-test.js index 34ca988..c634c61 100644 --- a/test/unit/service/auth-test.js +++ b/test/unit/service/auth-test.js @@ -68,12 +68,12 @@ describe('Auth unit tests', function() { describe('#getCredentials', function() { it('should load credentials and retrieve credentials from cfg', function(done) { - storageStub.listItems.withArgs(EMAIL_ADDR_DB_KEY, 0, null).returns(resolves([emailAddress])); - storageStub.listItems.withArgs(PASSWD_DB_KEY, 0, null).returns(resolves([encryptedPassword])); - storageStub.listItems.withArgs(USERNAME_DB_KEY, 0, null).returns(resolves([username])); - storageStub.listItems.withArgs(REALNAME_DB_KEY, 0, null).returns(resolves([realname])); - storageStub.listItems.withArgs(IMAP_DB_KEY, 0, null).returns(resolves([imap])); - storageStub.listItems.withArgs(SMTP_DB_KEY, 0, null).returns(resolves([smtp])); + storageStub.listItems.withArgs(EMAIL_ADDR_DB_KEY).returns(resolves([emailAddress])); + storageStub.listItems.withArgs(PASSWD_DB_KEY).returns(resolves([encryptedPassword])); + storageStub.listItems.withArgs(USERNAME_DB_KEY).returns(resolves([username])); + storageStub.listItems.withArgs(REALNAME_DB_KEY).returns(resolves([realname])); + storageStub.listItems.withArgs(IMAP_DB_KEY).returns(resolves([imap])); + storageStub.listItems.withArgs(SMTP_DB_KEY).returns(resolves([smtp])); pgpStub.decrypt.withArgs(encryptedPassword, undefined).returns(resolves({ decrypted: password, signaturesValid: true @@ -236,12 +236,12 @@ describe('Auth unit tests', function() { describe('#_loadCredentials', function() { it('should work', function(done) { - storageStub.listItems.withArgs(EMAIL_ADDR_DB_KEY, 0, null).returns(resolves([emailAddress])); - storageStub.listItems.withArgs(PASSWD_DB_KEY, 0, null).returns(resolves([encryptedPassword])); - storageStub.listItems.withArgs(USERNAME_DB_KEY, 0, null).returns(resolves([username])); - storageStub.listItems.withArgs(REALNAME_DB_KEY, 0, null).returns(resolves([realname])); - storageStub.listItems.withArgs(IMAP_DB_KEY, 0, null).returns(resolves([imap])); - storageStub.listItems.withArgs(SMTP_DB_KEY, 0, null).returns(resolves([smtp])); + storageStub.listItems.withArgs(EMAIL_ADDR_DB_KEY).returns(resolves([emailAddress])); + storageStub.listItems.withArgs(PASSWD_DB_KEY).returns(resolves([encryptedPassword])); + storageStub.listItems.withArgs(USERNAME_DB_KEY).returns(resolves([username])); + storageStub.listItems.withArgs(REALNAME_DB_KEY).returns(resolves([realname])); + storageStub.listItems.withArgs(IMAP_DB_KEY).returns(resolves([imap])); + storageStub.listItems.withArgs(SMTP_DB_KEY).returns(resolves([smtp])); auth._loadCredentials().then(function() { expect(auth.emailAddress).to.equal(emailAddress); diff --git a/test/unit/service/devicestorage-dao-test.js b/test/unit/service/devicestorage-dao-test.js index 43fd792..bbf0eb8 100644 --- a/test/unit/service/devicestorage-dao-test.js +++ b/test/unit/service/devicestorage-dao-test.js @@ -82,7 +82,7 @@ describe('Device Storage DAO unit tests', function() { it('should work', function(done) { lawnchairDaoStub.list.returns(resolves()); - storageDao.listItems('email', 0, null).then(function() { + storageDao.listItems('email').then(function() { expect(lawnchairDaoStub.list.calledOnce).to.be.true; done(); }); diff --git a/test/unit/service/keychain-dao-test.js b/test/unit/service/keychain-dao-test.js index a7739ae..4a8f1aa 100644 --- a/test/unit/service/keychain-dao-test.js +++ b/test/unit/service/keychain-dao-test.js @@ -54,7 +54,7 @@ describe('Keychain DAO unit tests', function() { describe('listLocalPublicKeys', function() { it('should work', function(done) { - lawnchairDaoStub.list.withArgs('publickey', 0, null).returns(resolves()); + lawnchairDaoStub.list.withArgs('publickey').returns(resolves()); keychainDao.listLocalPublicKeys().then(function() { expect(lawnchairDaoStub.list.callCount).to.equal(1); diff --git a/test/unit/service/lawnchair-dao-test.js b/test/unit/service/lawnchair-dao-test.js index a66ea97..4889c6c 100644 --- a/test/unit/service/lawnchair-dao-test.js +++ b/test/unit/service/lawnchair-dao-test.js @@ -43,7 +43,7 @@ describe('Lawnchair DAO unit tests', function() { describe('list', function() { it('should fail', function(done) { - lawnchairDao.list(undefined, 0, null).catch(function(err) { + lawnchairDao.list(undefined).catch(function(err) { expect(err).to.exist; done(); }); @@ -106,13 +106,13 @@ describe('Lawnchair DAO unit tests', function() { }]; lawnchairDao.batch(list).then(function() { - return lawnchairDao.list('type', 0, null); + return lawnchairDao.list('type'); }).then(function(fetched) { expect(fetched.length).to.equal(2); expect(fetched[0]).to.deep.equal(list[0].object); return lawnchairDao.removeList('type'); }).then(function() { - return lawnchairDao.list('type', 0, null); + return lawnchairDao.list('type'); }).then(function(fetched) { expect(fetched).to.exist; expect(fetched.length).to.equal(0);