diff --git a/src/js/dao/email-dao.js b/src/js/dao/email-dao.js index e826d72..6138a0f 100644 --- a/src/js/dao/email-dao.js +++ b/src/js/dao/email-dao.js @@ -6,6 +6,16 @@ define(function(require) { config = require('js/app-config').config, str = require('js/app-config').string; + /** + * High-level data access object that orchestrates everything around the handling of encrypted mails: + * PGP de-/encryption, receiving via IMAP, sending via SMTP, MIME parsing, local db persistence + * + * @param {Object} keychain The keychain DAO handles keys transparently + * @param {Object} crypto Orchestrates decryption + * @param {Object} devicestorage Handles persistence to the local indexed db + * @param {Object} pgpbuilder Generates and encrypts MIME and SMTP messages + * @param {Object} mailreader Parses MIME messages received from IMAP + */ var EmailDAO = function(keychain, crypto, devicestorage, pgpbuilder, mailreader) { this._keychain = keychain; this._crypto = crypto; @@ -22,6 +32,16 @@ define(function(require) { // + /** + * Initializes the email dao: + * - validates the email address + * - retrieves the user's key pair (if available) + * - initializes _account.folders with the content from memory + * + * @param {Object} options.account The account + * @param {String} options.account.emailAddress The user's id + * @param {Function} callback(error, keypair) Invoked with the keypair or error information when the email dao is initialized + */ EmailDAO.prototype.init = function(options, callback) { var self = this, keypair; @@ -70,6 +90,11 @@ define(function(require) { } }; + /** + * Unlocks the keychain by either decrypting an existing private key or generating a new keypair + * @param {String} options.passphrase The passphrase to decrypt the private key + * @param {Function} callback(error) Invoked when the the keychain is unlocked or when an error occurred buring unlocking + */ EmailDAO.prototype.unlock = function(options, callback) { var self = this; @@ -170,6 +195,14 @@ define(function(require) { } }; + /** + * Opens a folder in IMAP so that we can receive updates for it. + * Please note that this is a no-op if you try to open the outbox, since it is not an IMAP folder + * but a virtual folder that only exists on disk. + * + * @param {Object} options.folder The folder to be opened + * @param {Function} callback(error) Invoked when the folder has been opened + */ EmailDAO.prototype.openFolder = function(options, callback) { var self = this, err; @@ -190,6 +223,14 @@ define(function(require) { }, callback); }; + /** + * 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 + * @param {Function} callback [description] + */ EmailDAO.prototype.refreshFolder = function(options, callback) { var self = this, folder = options.folder; @@ -205,8 +246,8 @@ define(function(require) { var storedUids = _.pluck(storedMessages, 'uid'), memoryUids = _.pluck(folder.messages, 'uid'), - newUids = _.difference(storedUids, memoryUids), - removedUids = _.difference(memoryUids, storedUids); + 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) { @@ -240,6 +281,15 @@ define(function(require) { } }; + /** + * Fetches a message's headers from IMAP. + * + * NB! If we fetch a message whose subject line correspond's to that of a verification message, + * we try to verify that, and if that worked, we delete the verified message from IMAP. + * + * @param {Object} options.folder The folder for which to fetch the message + * @param {Function} callback(error) Invoked when the message is persisted and added to folder.messages + */ EmailDAO.prototype.fetchMessages = function(options, callback) { var self = this, folder = options.folder; @@ -328,6 +378,7 @@ define(function(require) { callback(err); } + // Handles verification of public keys, deletion of messages with verified keys function handleVerification(message, localCallback) { self._getBodyParts({ folder: folder, @@ -375,6 +426,17 @@ define(function(require) { } }; + /** + * Delete a message from IMAP, disk and folder.messages. + * + * Please note that this deletes from disk only if you delete from the outbox, + * since it is not an IMAP folder but a virtual folder that only exists on disk. + * + * @param {Object} options.folder The folder from which to delete the messages + * @param {Object} options.message The message that should be deleted + * @param {Boolean} options.localOnly Indicated if the message should not be removed from IMAP + * @param {Function} callback(error) Invoked when the message was delete, or an error occurred + */ EmailDAO.prototype.deleteMessage = function(options, callback) { var self = this, folder = options.folder, @@ -384,6 +446,7 @@ define(function(require) { folder.messages.splice(folder.messages.indexOf(message), 1); + // delete only locally if (options.localOnly || options.folder.path === config.outboxMailboxPath) { deleteLocal(); return; @@ -393,6 +456,7 @@ define(function(require) { function deleteImap() { if (!self._account.online) { + // no action if we're not online done({ errMsg: 'Client is currently offline!', code: 42 @@ -400,6 +464,7 @@ define(function(require) { return; } + // delete from IMAP self._imapDeleteMessage({ folder: folder, uid: message.uid @@ -414,6 +479,7 @@ define(function(require) { } function deleteLocal() { + // delete from indexed db self._localDeleteMessage({ folder: folder, uid: message.uid @@ -430,24 +496,37 @@ define(function(require) { } }; + /** + * Updates a message's 'unread' and 'answered' flags + * + * Please note if you set flags on disk only if you delete from the outbox, + * since it is not an IMAP folder but a virtual folder that only exists on disk. + * + * @param {[type]} options [description] + * @param {Function} callback [description] + */ EmailDAO.prototype.setFlags = function(options, callback) { var self = this, folder = options.folder, message = options.message; - self._account.busy = true; + self._account.busy = true; // start the spinner + // no-op if the message if not present anymore (for whatever reason) if (folder.messages.indexOf(message) < 0) { self._account.busy = false; // stop the spinner return; } + // don't do a roundtrip to IMAP, + // especially if you want to mark outbox messages if (options.localOnly || options.folder.path === config.outboxMailboxPath) { markStorage(); return; } if (!self._account.online) { + // no action if we're not online done({ errMsg: 'Client is currently offline!', code: 42 @@ -458,6 +537,7 @@ define(function(require) { markImap(); function markImap() { + // mark a message unread/answered on IMAP self._imapMark({ folder: folder, uid: options.message.uid, @@ -474,6 +554,9 @@ define(function(require) { } function markStorage() { + // angular pollutes that data transfer objects with helper properties (e.g. $$hashKey), + // which we do not want to persist to disk. in order to avoid that, we load the pristine + // message from disk, change the flags and re-persist it to disk self._localListMessages({ folder: folder, uid: options.message.uid, @@ -483,10 +566,13 @@ define(function(require) { return; } + // set the flags var storedMessage = storedMessages[0]; storedMessage.unread = options.message.unread; storedMessage.answered = options.message.answered; + storedMessage.modseq = options.message.modseq || storedMessage.modseq; + // store self._localStoreMessages({ folder: folder, emails: [storedMessage] @@ -663,6 +749,14 @@ define(function(require) { } }; + /** + * Retrieves an attachment matching a body part for a given uid and a folder + * + * @param {Object} options.folder The folder where to find the attachment + * @param {Number} options.uid The uid for the message the attachment body part belongs to + * @param {Object} options.attachment The attachment body part to fetch and parse from IMAP + * @param {Function} callback(error, attachment) Invoked when the attachment body part was retrieved and parsed, or an error occurred + */ EmailDAO.prototype.getAttachment = function(options, callback) { this._getBodyParts({ folder: options.folder, @@ -674,11 +768,19 @@ define(function(require) { return; } + // add the content to the original object options.attachment.content = parsedBodyParts[0].content; callback(err, err ? undefined : options.attachment); }); }; + /** + * Decrypts a message and replaces sets the decrypted plaintext as the message's body, html, or attachment, respectively. + * The first encrypted body part's ciphertext (in the content property) will be decrypted. + * + * @param {Object} options.message The message + * @param {Function} callback(error, message) + */ EmailDAO.prototype.decryptBody = function(options, callback) { var self = this, message = options.message; @@ -762,6 +864,12 @@ define(function(require) { } }; + /** + * Encrypted (if necessary) and sends a message with a predefined clear text greeting. + * + * @param {Object} options.email The message to be sent + * @param {Function} callback(error) Invoked when the message was sent, or an error occurred + */ EmailDAO.prototype.sendEncrypted = function(options, callback) { var self = this; @@ -782,6 +890,12 @@ define(function(require) { }, callback); }; + /** + * Sends a signed message in the plain + * + * @param {Object} options.email The message to be sent + * @param {Function} callback(error) Invoked when the message was sent, or an error occurred + */ EmailDAO.prototype.sendPlaintext = function(options, callback) { if (!this._account.online) { callback({ @@ -797,6 +911,12 @@ define(function(require) { }, callback); }; + /** + * Signs and encrypts a message + * + * @param {Object} options.email The message to be encrypted + * @param {Function} callback(error, message) Invoked when the message was encrypted, or an error occurred + */ EmailDAO.prototype.encrypt = function(options, callback) { this._pgpbuilder.encrypt(options, callback); }; @@ -809,6 +929,15 @@ define(function(require) { // + /** + * This handler should be invoked when navigator.onLine === true. It will try to connect a + * given instance of the imap client. If the connection attempt was successful, it will + * update the locally available folders with the newly received IMAP folder listing. + * + * @param {Object} options.imapClient The IMAP client used to receive messages + * @param {Object} options.pgpMailer The SMTP client used to send messages + * @param {Function} callback [description] + */ EmailDAO.prototype.onConnect = function(options, callback) { var self = this; @@ -838,7 +967,11 @@ define(function(require) { // attach sync update handler self._imapClient.onSyncUpdate = self._onSyncUpdate.bind(self); - // fill the imap mailboxCache + // fill the imap mailboxCache with information we have locally available: + // - highest locally available moseq + // - list of locally available uids + // - highest locally available uid + // - next expected uid var mailboxCache = {}; self._account.folders.forEach(function(folder) { if (folder.messages.length === 0) { @@ -865,6 +998,7 @@ define(function(require) { }); self._imapClient.mailboxCache = mailboxCache; + // set up the imap client to listen for changes in the inbox var inbox = _.findWhere(self._account.folders, { type: 'Inbox' }); @@ -881,12 +1015,26 @@ define(function(require) { }); }; + /** + * This handler should be invoked when navigator.onLine === false. It will discard + * the imap client and pgp mailer + */ EmailDAO.prototype.onDisconnect = function() { this._account.online = false; this._imapClient = undefined; this._pgpMailer = undefined; }; + /** + * The are updates in the IMAP folder of the following type + * - 'new': a list of uids that are newly available + * - 'deleted': a list of uids that were deleted from IMAP available + * - 'messages': a list of messages (uid + flags) that where changes are available + * + * @param {String} options.type The type of the update + * @param {String} options.path The mailbox for which updates are available + * @param {Array} options.list Array containing update information. Number (uid) or mail with Object (uid and flags), respectively + */ EmailDAO.prototype._onSyncUpdate = function(options) { var self = this; @@ -939,16 +1087,15 @@ define(function(require) { return; } + // update unread, answered, modseq to the latest info message.answered = changedMsg.flags.indexOf('\\Answered') > -1; message.unread = changedMsg.flags.indexOf('\\Seen') === -1; - - if (!message) { - return; - } + message.modseq = changedMsg.modseq; self.setFlags({ folder: folder, - message: message + message: message, + localOnly: true }, self.onError.bind(self)); }); } @@ -963,14 +1110,18 @@ define(function(require) { /** - * List the folders in the user's IMAP mailbox. + * Updates the folder information from memory (if we're offline), or from imap (if we're online), + * and 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 + * + * @param {Function} callback Invoked when the folders are up to date */ EmailDAO.prototype._initFolders = function(callback) { var self = this, - folderDbType = 'folders', - folders; + folderDbType = 'folders'; - self._account.busy = true; + self._account.busy = true; // start the spinner if (!self._account.online) { // fetch list from local cache @@ -987,15 +1138,13 @@ define(function(require) { } else { // fetch list from imap server self._imapClient.listWellKnownFolders(function(err, wellKnownFolders) { - var foldersChanged = false; - if (err) { done(err); return; } // this array is dropped directly into the ui to create the folder list - folders = [ + var folders = [ wellKnownFolders.inbox, wellKnownFolders.sent, { type: 'Outbox', @@ -1005,7 +1154,9 @@ define(function(require) { wellKnownFolders.trash ]; - // are there any new folders? + var foldersChanged = false; // indicates if are there any new/removed folders? + + // check for added folders folders.forEach(function(folder) { if (!_.findWhere(self._account.folders, { path: folder.path @@ -1016,7 +1167,7 @@ define(function(require) { } }); - // have any folders been deleted? + // check for deleted folders self._account.folders.forEach(function(folder) { if (!_.findWhere(folders, { path: folder.path @@ -1027,12 +1178,14 @@ define(function(require) { } }); + // if folder have changed, we need to persist them to disk. if (!foldersChanged) { readMessagesFromDisk(); return; } // persist encrypted list in device storage + // NB! persis the array we received from IMAP! do *not* persist self._account.folders with all the messages... self._devicestorage.storeList([folders], folderDbType, function(err) { if (err) { done(err); @@ -1045,6 +1198,7 @@ define(function(require) { return; } + // fill uninitialized folders with the locally available messages function readMessagesFromDisk() { if (!self._account.folders || self._account.folders.length === 0) { done(); @@ -1060,7 +1214,7 @@ define(function(require) { return; } - // sync: messages on disk -> scope + // sync messages from disk to the folder model self.refreshFolder({ folder: folder }, function(err) { @@ -1088,7 +1242,12 @@ define(function(require) { // /** - * Mark imap messages as un-/read or un-/answered + * Mark messages as un-/read or un-/answered on IMAP + * + * @param {Object} options.folder The folder where to find the message + * @param {Number} options.uid The uid for which to change the flags + * @param {Number} options.unread Un-/Read flag + * @param {Number} options.answered Un-/Answered flag */ EmailDAO.prototype._imapMark = function(options, callback) { if (!this._account.online) { @@ -1103,6 +1262,14 @@ define(function(require) { this._imapClient.updateFlags(options, callback); }; + /** + * If we're in the trash folder or no trash folder is available, this deletes a message from IMAP. + * Otherwise, it moves a message to the trash folder. + * + * @param {Object} options.folder The folder where to find the message + * @param {Number} options.uid The uid of the message + * @param {Function} callback(error) Callback with an error object in case something went wrong. + */ EmailDAO.prototype._imapDeleteMessage = function(options, callback) { if (!this._account.online) { callback({ @@ -1126,6 +1293,7 @@ define(function(require) { return; } + // move the message to the trash folder this._imapClient.moveMessage({ path: options.folder.path, destination: trash.path, @@ -1134,7 +1302,8 @@ define(function(require) { }; /** - * Get an email messsage without the body + * 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) @@ -1192,16 +1361,38 @@ define(function(require) { // + /** + * List the locally available items form the indexed db stored under "email_[FOLDER PATH]_[MESSAGE UID]" (if a message was provided), + * or "email_[FOLDER PATH]", respectively + * + * @param {Object} options.folder The folder for which to list the content + * @param {Object} options.uid A specific uid to look up locally in the folder + * @param {Function} callback(error, list) Invoked with the results of the query, or further information, if an error occurred + */ EmailDAO.prototype._localListMessages = function(options, callback) { var dbType = 'email_' + options.folder.path + (options.uid ? '_' + options.uid : ''); this._devicestorage.listItems(dbType, 0, null, callback); }; + /** + * Stores a bunch of messages to the indexed db. The messages are stored under "email_[FOLDER PATH]_[MESSAGE UID]" + * + * @param {Object} options.folder The folder for which to list the content + * @param {Array} options.messages The messages to store + * @param {Function} callback(error, list) Invoked with the results of the query, or further information, if an error occurred + */ EmailDAO.prototype._localStoreMessages = function(options, callback) { var dbType = 'email_' + options.folder.path; this._devicestorage.storeList(options.emails, dbType, callback); }; + /** + * Stores a bunch of messages to the indexed db. The messages are stored under "email_[FOLDER PATH]_[MESSAGE UID]" + * + * @param {Object} options.folder The folder for which to list the content + * @param {Array} options.messages The messages to store + * @param {Function} callback(error, list) Invoked with the results of the query, or further information, if an error occurred + */ EmailDAO.prototype._localDeleteMessage = function(options, callback) { var path = options.folder.path, uid = options.uid, @@ -1225,21 +1416,25 @@ define(function(require) { // // + /** + * Updates a folder's unread count: + * - For the outbox, that's the total number of messages, + * - For every other folder, it's the number of unread messages + */ function updateUnreadCount(folder) { var allMsgs = folder.messages.length, unreadMsgs = _.filter(folder.messages, function(msg) { return msg.unread; }).length; - // for the outbox, the unread count is determined by ALL the messages - // whereas for normal folders, only the unread messages matter folder.count = folder.path === config.outboxMailboxPath ? allMsgs : unreadMsgs; } /** * Helper function that recursively traverses the body parts tree. Looks for bodyParts that match the provided type and aggregates them - * @param {[type]} bodyParts The bodyParts array - * @param {[type]} type The type to look up + * + * @param {Array} bodyParts The bodyParts array + * @param {String} type The type to look up * @param {undefined} result Leave undefined, only used for recursion */ function filterBodyParts(bodyParts, type, result) { diff --git a/test/new-unit/email-dao-test.js b/test/new-unit/email-dao-test.js index b0f9857..74c3429 100644 --- a/test/new-unit/email-dao-test.js +++ b/test/new-unit/email-dao-test.js @@ -1584,6 +1584,7 @@ define(function(require) { setFlagsStub.withArgs({ folder: inboxFolder, message: msgs[0], + localOnly: true }).yieldsAsync(); dao.onError = function(err) {