diff --git a/Gruntfile.js b/Gruntfile.js index 010714c..6f4f542 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -133,15 +133,17 @@ module.exports = function(grunt) { 'imap-client/node_modules/inbox/node_modules/node-shims/src/*.js', 'imap-client/node_modules/inbox/node_modules/utf7/src/utf7.js', 'imap-client/node_modules/inbox/node_modules/xoauth2/src/xoauth2.js', - 'imap-client/node_modules/mimelib/src/mimelib.js', - 'imap-client/node_modules/mimelib/node_modules/addressparser/src/addressparser.js', - 'imap-client/node_modules/mimelib/node_modules/encoding/src/encoding.js', - 'imap-client/node_modules/mimelib/node_modules/encoding/node_modules/iconv-lite/src/*.js', - 'imap-client/node_modules/mailparser/src/*.js', - 'imap-client/node_modules/mime/src/mime.js', + 'mailreader/src/*.js', + 'mailreader/node_modules/mailparser/src/*.js', + 'mailreader/node_modules/mailparser/node_modules/encoding/src/encoding.js', + 'mailreader/node_modules/mailparser/node_modules/mimelib/src/mimelib.js', + 'mailreader/node_modules/mailparser/node_modules/mimelib/node_modules/addressparser/src/addressparser.js', + 'mailreader/node_modules/mailparser/node_modules/encoding/node_modules/iconv-lite/src/*.js', + 'mailreader/node_modules/mailparser/node_modules/mime/src/mime.js', 'pgpmailer/src/*.js', 'pgpmailer/node_modules/simplesmtp/src/*', - 'pgpmailer/node_modules/mailbuilder/src/*.js' + 'pgpbuilder/src/*.js', + 'pgpbuilder/node_modules/mailbuilder/src/*.js' ], dest: 'src/lib/' }, diff --git a/package.json b/package.json index 8f17a48..04c4347 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "dependencies": { "crypto-lib": "https://github.com/whiteout-io/crypto-lib/tarball/master", "imap-client": "https://github.com/whiteout-io/imap-client/tarball/master", + "mailreader": "https://github.com/whiteout-io/mailreader/tarball/master", "pgpmailer": "https://github.com/whiteout-io/pgpmailer/tarball/master", + "pgpbuilder": "https://github.com/whiteout-io/pgpbuilder/tarball/master", "requirejs": "2.1.10" }, "devDependencies": { @@ -33,4 +35,4 @@ "grunt-contrib-copy": "~0.4.1", "grunt-contrib-compress": "~0.5.2" } -} \ No newline at end of file +} diff --git a/src/js/app-controller.js b/src/js/app-controller.js index 98caa47..2c55d38 100644 --- a/src/js/app-controller.js +++ b/src/js/app-controller.js @@ -6,6 +6,7 @@ define(function(require) { var $ = require('jquery'), ImapClient = require('imap-client'), + mailreader = require('mailreader'), PgpMailer = require('pgpmailer'), EmailDAO = require('js/dao/email-dao'), RestDAO = require('js/dao/rest-dao'), @@ -16,6 +17,7 @@ define(function(require) { InvitationDAO = require('js/dao/invitation-dao'), OutboxBO = require('js/bo/outbox'), PGP = require('js/crypto/pgp'), + PgpBuilder = require('pgpbuilder'), config = require('js/app-config').config; var self = {}; @@ -116,8 +118,8 @@ define(function(require) { onError: console.error }; - imapClient = new ImapClient(imapOptions); - pgpMailer = new PgpMailer(smtpOptions); + imapClient = new ImapClient(imapOptions, mailreader); + pgpMailer = new PgpMailer(smtpOptions, self._pgpbuilder); imapClient.onError = function(err) { console.log('IMAP error.', err); @@ -341,7 +343,7 @@ define(function(require) { self.buildModules = function() { var lawnchairDao, restDao, pubkeyDao, invitationDao, - emailDao, keychain, pgp, userStorage; + emailDao, keychain, pgp, userStorage, pgpbuilder; // init objects and inject dependencies restDao = new RestDAO(); @@ -354,7 +356,8 @@ define(function(require) { self._keychain = keychain; pgp = new PGP(); self._crypto = pgp; - self._emailDao = emailDao = new EmailDAO(keychain, pgp, userStorage); + self._pgpbuilder = pgpbuilder = new PgpBuilder(); + self._emailDao = emailDao = new EmailDAO(keychain, pgp, userStorage, pgpbuilder, mailreader); self._outboxBo = new OutboxBO(emailDao, keychain, userStorage, invitationDao); }; @@ -385,9 +388,6 @@ define(function(require) { return; } - // init outbox - self._outboxBo.init(); - callback(null, keypair); }); }); diff --git a/src/js/bo/outbox.js b/src/js/bo/outbox.js index 3a0d054..69e7036 100644 --- a/src/js/bo/outbox.js +++ b/src/js/bo/outbox.js @@ -2,10 +2,11 @@ define(function(require) { 'use strict'; var _ = require('underscore'), + util = require('cryptoLib/util'), str = require('js/app-config').string, config = require('js/app-config').config, InvitationDAO = require('js/dao/invitation-dao'), - dbType = 'email_OUTBOX'; + outboxDb = 'email_OUTBOX'; /** * High level business object that orchestrates the local outbox. @@ -30,18 +31,6 @@ define(function(require) { * Semaphore-esque flag to avoid 'concurrent' calls to _processOutbox when the timeout fires, but a call is still in process. * @private */ this._outboxBusy = false; - - /** - * Pending, unsent emails stored in the outbox. Updated on each call to _processOutbox - * @public */ - this.pendingEmails = []; - }; - - OutboxBO.prototype.init = function() { - var outboxFolder = _.findWhere(this._emailDao._account.folders, { - type: 'Outbox' - }); - outboxFolder.messages = this.pendingEmails; }; /** @@ -66,12 +55,60 @@ define(function(require) { }; /** - * Private Api which is called whenever a message has been sent - * The public callback "onSent" can be set by the caller to get notified. + * Put a email dto in the outbox for sending when ready + * @param {Object} mail The Email DTO + * @param {Function} callback Invoked when the object was encrypted and persisted to disk */ - OutboxBO.prototype._onSent = function(message) { - if (typeof this.onSent === 'function') { - this.onSent(message); + OutboxBO.prototype.put = function(mail, callback) { + var self = this, + allReaders = mail.from.concat(mail.to.concat(mail.cc.concat(mail.bcc))); // all the users that should be able to read the mail + + mail.publicKeysArmored = []; // gather the public keys + mail.unregisteredUsers = []; // gather the recipients for which no public key is available + mail.id = util.UUID(); // the mail needs a random uuid for storage in the database + + checkRecipients(allReaders); + + // check if there are unregistered recipients + function checkRecipients(recipients) { + var after = _.after(recipients.length, function() { + encryptAndPersist(); + }); + + // find out if there are unregistered users + recipients.forEach(function(recipient) { + self._keychain.getReceiverPublicKey(recipient.address, function(err, key) { + if (err) { + callback(err); + return; + } + + // if a public key is available, add the recipient's key to the armored public keys, + // otherwise remember the recipient as unregistered for later sending + if (key) { + mail.publicKeysArmored.push(key.publicKey); + } else { + mail.unregisteredUsers.push(recipient); + } + + after(); + }); + }); + } + + // encrypts the body and attachments and persists the mail object + function encryptAndPersist() { + self._emailDao.encrypt({ + mail: mail, + publicKeysArmored: mail.publicKeysArmored + }, function(err) { + if (err) { + callback(err); + return; + } + + self._devicestorage.storeList([mail], outboxDb, callback); + }); } }; @@ -81,152 +118,213 @@ define(function(require) { */ OutboxBO.prototype._processOutbox = function(callback) { var self = this, - emails; + unsentMails = 0; - // if a _processOutbox call is still in progress when a new timeout kicks - // in, since sending mails might take time, ignore it. otherwise, mails - // could get sent multiple times + // also, if a _processOutbox call is still in progress, ignore it. if (self._outboxBusy) { return; } - checkStorage(); + self._outboxBusy = true; - function checkStorage() { - self._outboxBusy = true; + // get pending mails from the outbox + self._devicestorage.listItems(outboxDb, 0, null, function(err, pendingMails) { + // error, we're done here + if (err) { + self._outboxBusy = false; + callback(err); + return; + } - // get last item from outbox - self._emailDao.listForOutbox(function(err, pending) { + // if we're not online, don't even bother sending mails. + if (!self._emailDao._account.online || _.isEmpty(pendingMails)) { + self._outboxBusy = false; + callback(null, pendingMails.length); + return; + } + + // we're done after all the mails have been handled + // update the outbox count... + var after = _.after(pendingMails.length, function() { + self._outboxBusy = false; + callback(null, unsentMails); + }); + + // send pending mails if possible + pendingMails.forEach(function(mail) { + handleMail(mail, after); + }); + }); + + // if we can send the mail, do that. otherwise check if there are users that need to be invited + function handleMail(mail, done) { + // no unregistered users, go straight to send + if (mail.unregisteredUsers.length === 0) { + send(mail, done); + return; + } + + var after = _.after(mail.unregisteredUsers.length, function() { + // invite unregistered users if necessary + if (mail.unregisteredUsers.length > 0) { + unsentMails++; + self._invite({ + sender: mail.from[0], + recipients: mail.unregisteredUsers + }, done); + return; + } + + // there are public keys available for the missing users, + // so let's re-encrypt the mail for them and send it + reencryptAndSend(mail, done); + }); + + // find out if the unregistered users have registered in the meantime + mail.unregisteredUsers.forEach(function(recipient) { + self._keychain.getReceiverPublicKey(recipient.address, function(err, key) { + var index; + + if (err) { + self._outboxBusy = false; + callback(err); + return; + } + + if (key) { + // remove the newly joined users from the unregistered users + index = mail.unregisteredUsers.indexOf(recipient); + mail.unregisteredUsers.splice(index, 1); + mail.publicKeysArmored.push(key.publicKey); + } + + after(); + }); + }); + } + + // all the recipients have public keys available, so let's re-encrypt the mail + // to make it available for them, too + function reencryptAndSend(mail, done) { + self._emailDao.reEncrypt({ + mail: mail, + publicKeysArmored: mail.publicKeysArmored + }, function(err) { if (err) { self._outboxBusy = false; callback(err); return; } - // update outbox folder count - emails = pending; + // stores the newly encrypted mail object to disk in case something funky + // happens during sending and we need do re-send the mail later. + // avoids doing the encryption twice... + self._devicestorage.storeList([mail], outboxDb, function(err) { + if (err) { + self._outboxBusy = false; + callback(err); + return; + } - // fill all the pending mails into the pending mails array - self.pendingEmails.length = 0; //fastest way to empty an array - pending.forEach(function(i) { - self.pendingEmails.push(i); + send(mail, done); }); + }); + } - // we're not online, don't even bother sending mails - if (!self._emailDao._account.online) { + // send the encrypted message + function send(mail, done) { + self._emailDao.sendEncrypted({ + email: mail + }, function(err) { + if (err) { self._outboxBusy = false; - callback(null, self.pendingEmails.length); + if (err.code === 42) { + // offline try again later + done(); + } else { + self._outboxBusy = false; + callback(err); + } return; } - // sending pending mails - processMails(); + // remove the pending mail from the storage + removeFromStorage(mail, done); + + // fire sent notification + if (typeof self.onSent === 'function') { + self.onSent(mail); + } }); } - // process the next pending mail - function processMails() { - if (emails.length === 0) { - // in the navigation controller, this updates the folder count - self._outboxBusy = false; - callback(null, self.pendingEmails.length); - return; - } - - // in the navigation controller, this updates the folder count - callback(null, self.pendingEmails.length); - var email = emails.shift(); - checkReceivers(email); - } - - // check whether there are unregistered receivers, i.e. receivers without a public key - function checkReceivers(email) { - var unregisteredUsers, receiverChecked; - - unregisteredUsers = []; - receiverChecked = _.after(email.to.length, function() { - // invite unregistered users if necessary - if (unregisteredUsers.length > 0) { - invite(unregisteredUsers); + // removes the mail object from disk after successfully sending it + function removeFromStorage(mail, done) { + self._devicestorage.removeList(outboxDb + '_' + mail.id, function(err) { + if (err) { + self._outboxBusy = false; + callback(err); return; } - sendEncrypted(email); + done(); }); + } + }; - // find out if there are unregistered users - email.to.forEach(function(recipient) { - self._keychain.getReceiverPublicKey(recipient.address, function(err, key) { - if (err) { - self._outboxBusy = false; - callback(err); - return; - } + /** + * Sends an invitation mail to an array of users that have no public key available yet + * @param {Array} recipients Array of objects with information on the sender (name, address) + * @param {Function} callback Invoked when the mail was sent + */ + OutboxBO.prototype._invite = function(options, callback) { + var self = this, + sender = options.sender; - if (!key) { - unregisteredUsers.push(recipient); - } + var after = _.after(options.recipients.length, callback); - receiverChecked(); - }); + options.recipients.forEach(function(recipient) { + checkInvitationStatus(recipient, after); + }); + + // checks the invitation status. if an invitation is pending, we do not need to resend the invitation mail + function checkInvitationStatus(recipient, done) { + self._invitationDao.check({ + recipient: recipient.address, + sender: sender.address + }, function(err, status) { + if (err) { + callback(err); + return; + } + + if (status === InvitationDAO.INVITE_PENDING) { + // the recipient is already invited, we're done here. + done(); + return; + } + + invite(recipient, done); }); } - // invite the unregistered receivers, if necessary - function invite(addresses) { - var sender = self._emailDao._account.emailAddress; - - var invitationFinished = _.after(addresses.length, function() { - // after all of the invitations are checked and sent (if necessary), - processMails(); - }); - - // check which of the adresses has pending invitations - addresses.forEach(function(recipient) { - var recipientAddress = recipient.address; - - self._invitationDao.check({ - recipient: recipientAddress, - sender: sender - }, function(err, status) { - if (err) { - self._outboxBusy = false; - callback(err); - return; - } - - if (status === InvitationDAO.INVITE_PENDING) { - // the recipient is already invited, we're done here. - invitationFinished(); - return; - } - - // the recipient is not yet invited, so let's do that - self._invitationDao.invite({ - recipient: recipientAddress, - sender: sender - }, function(err, status) { - if (err) { - self._outboxBusy = false; - callback(err); - return; - } - if (status !== InvitationDAO.INVITE_SUCCESS) { - self._outboxBusy = false; - callback({ - errMsg: 'could not successfully invite ' + recipientAddress - }); - return; - } - - sendInvitationMail(recipient, sender); + // let's invite the recipient and send him a mail to inform him to join whiteout + function invite(recipient, done) { + self._invitationDao.invite({ + recipient: recipient.address, + sender: sender.address + }, function(err, status) { + if (err) { + callback(err); + return; + } + if (status !== InvitationDAO.INVITE_SUCCESS) { + callback({ + errMsg: 'Could not successfully invite ' + recipient }); + return; + } - }); - }); - - // send an invitation to the unregistered user, aka the recipient - function sendInvitationMail(recipient, sender) { var invitationMail = { from: [sender], to: [recipient], @@ -239,10 +337,9 @@ define(function(require) { email: invitationMail }, function(err) { if (err) { - self._outboxBusy = false; if (err.code === 42) { // offline try again later - callback(); + done(); } else { callback(err); } @@ -250,63 +347,12 @@ define(function(require) { } // fire sent notification - self._onSent(invitationMail); - - invitationFinished(); - }); - } - } - - function sendEncrypted(email) { - self._emailDao.sendEncrypted({ - email: email - }, function(err) { - if (err) { - self._outboxBusy = false; - if (err.code === 42) { - // offline try again later - callback(); - } else { - callback(err); + if (typeof self.onSent === 'function') { + self.onSent(invitationMail); } - return; - } - // the email has been sent, remove from pending mails - removeFromPendingMails(email); - - // fire sent notification - self._onSent(email); - - removeFromStorage(email.id); - }); - } - - // update the member so that the outbox can visualize - function removeFromPendingMails(email) { - var i = self.pendingEmails.indexOf(email); - self.pendingEmails.splice(i, 1); - } - - function removeFromStorage(id) { - if (!id) { - self._outboxBusy = false; - callback({ - errMsg: 'Cannot remove email from storage without a valid id!' + done(); }); - return; - } - - // delete email from local storage - var key = dbType + '_' + id; - self._devicestorage.removeList(key, function(err) { - if (err) { - self._outboxBusy = false; - callback(err); - return; - } - - processMails(); }); } }; diff --git a/src/js/controller/mail-list.js b/src/js/controller/mail-list.js index b090553..6484284 100644 --- a/src/js/controller/mail-list.js +++ b/src/js/controller/mail-list.js @@ -59,11 +59,6 @@ define(function(require) { // $scope.getBody = function(email) { - // don't stream message content of outbox messages... - if (getFolder().type === 'Outbox') { - return; - } - emailDao.getBody({ folder: getFolder().path, message: email @@ -78,7 +73,7 @@ define(function(require) { // automatically decrypt if it's the selected email if (email === $scope.state.mailList.selected) { - emailDao.decryptMessageContent({ + emailDao.decryptBody({ message: email }, $scope.onError); } @@ -98,12 +93,9 @@ define(function(require) { $scope.state.mailList.selected = email; $scope.state.read.toggle(true); - // if we're in the outbox, don't decrypt as usual - if (getFolder().type !== 'Outbox') { - emailDao.decryptMessageContent({ - message: email - }, $scope.onError); - } + emailDao.decryptBody({ + message: email + }, $scope.onError); // if the email is unread, please sync the new state. // otherweise forget about it. @@ -127,19 +119,21 @@ define(function(require) { * Synchronize the selected imap folder to local storage */ $scope.synchronize = function(callback) { - // if we're in the outbox, don't do an imap sync - if (getFolder().type === 'Outbox') { - updateStatus('Last update: ', new Date()); - selectFirstMessage(outboxBo.pendingEmails); - return; - } - updateStatus('Syncing ...'); // let email dao handle sync transparently - emailDao.sync({ - folder: getFolder().path - }, function(err) { + if ($scope.state.nav.currentFolder.type === 'Outbox') { + emailDao.syncOutbox({ + folder: getFolder().path + }, done); + } else { + emailDao.sync({ + folder: getFolder().path + }, done); + } + + + function done(err) { if (err && err.code === 409) { // sync still busy return; @@ -167,7 +161,7 @@ define(function(require) { if (callback) { callback(); } - }); + } }; /** @@ -178,17 +172,7 @@ define(function(require) { return; } - var index, currentFolder, outboxFolder; - - currentFolder = getFolder(); - // trashFolder = _.findWhere($scope.folders, { - // type: 'Trash' - // }); - outboxFolder = _.findWhere($scope.account.folders, { - type: 'Outbox' - }); - - if (currentFolder === outboxFolder) { + if (getFolder().type === 'Outbox') { $scope.onError({ errMsg: 'Deleting messages from the outbox is not yet supported.' }); @@ -199,7 +183,7 @@ define(function(require) { $scope.synchronize(); function removeAndShowNext() { - index = getFolder().messages.indexOf(email); + var index = getFolder().messages.indexOf(email); // show the next mail if (getFolder().messages.length > 1) { // if we're about to delete the last entry of the array, show the previous (i.e. the one below in the list), @@ -242,13 +226,6 @@ define(function(require) { // production... in chrome packaged app - // if we're in the outbox, read directly from there. - if (getFolder().type === 'Outbox') { - updateStatus('Last update: ', new Date()); - selectFirstMessage(outboxBo.pendingEmails); - return; - } - // unselect selection from old folder $scope.select(); // display and select first diff --git a/src/js/controller/navigation.js b/src/js/controller/navigation.js index 1b33ddf..5a3af95 100644 --- a/src/js/controller/navigation.js +++ b/src/js/controller/navigation.js @@ -42,51 +42,24 @@ define(function(require) { }; $scope.onOutboxUpdate = function(err, count) { - var outbox, mail; - if (err) { $scope.onError(err); return; } - outbox = _.findWhere($scope.account.folders, { + // update the outbox mail count. this should normally happen during the delta sync + // problem is that the outbox continuously retries in the background, whereas the delta sync only runs + // when the outbox is currently viewed... + var outbox = _.findWhere($scope.account.folders, { type: 'Outbox' }); - // update the outbox mail count - outbox.count = count; - // if we're NOT viewing the outbox or we're not looking at any mail now, we're done - if ($scope.state.nav.currentFolder !== outbox || !$scope.state.mailList.selected) { - $scope.$apply(); - return; - } - - // so we're currently in the outbox. - // if the currently selected mail is still among the pending mails, re-select it, since the object has changed. - // however, if the mail is NOT in the outbox anymore, select another pending mail - // - // this is a workaround due to the fact that the outbox loads pending messages from the indexedDB, - // where object identity is broken when you read an object twice, which happens upon the next retry - // to send the pending messages... - - mail = _.findWhere(outbox.messages, { - id: $scope.state.mailList.selected.id - }); - - if (mail) { - // select the 'new old' mail - $scope.state.mailList.selected = mail; + if (outbox === $scope.state.nav.currentFolder) { + $scope.state.mailList.synchronize(); } else { - if (outbox.messages.length) { - // there are more mails to show, select another one in the list - $scope.state.mailList.selected = outbox.messages[0]; - } else { - // no mails to show, don't select anything... - $scope.state.mailList.selected = undefined; - } + outbox.count = count; + $scope.$apply(); } - - $scope.$apply(); }; // diff --git a/src/js/controller/write.js b/src/js/controller/write.js index 9484c2c..ce7a7c4 100644 --- a/src/js/controller/write.js +++ b/src/js/controller/write.js @@ -6,7 +6,7 @@ define(function(require) { aes = require('cryptoLib/aes-cbc'), util = require('cryptoLib/util'), str = require('js/app-config').string, - crypto, emailDao; + crypto, emailDao, outbox; // // Controller @@ -14,7 +14,8 @@ define(function(require) { var WriteCtrl = function($scope, $filter) { crypto = appController._crypto; - emailDao = appController._emailDao; + emailDao = appController._emailDao, + outbox = appController._outboxBo; // set default value so that the popover height is correct on init $scope.keyId = 'XXXXXXXX'; @@ -211,83 +212,63 @@ define(function(require) { // build email model for smtp-client email = { - to: [], - cc: [], - bcc: [], + from: [{ + address: emailDao._account.emailAddress + }], + to: $scope.to.filter(filterEmptyAddresses), + cc: $scope.cc.filter(filterEmptyAddresses), + bcc: $scope.bcc.filter(filterEmptyAddresses), subject: $scope.subject.trim() ? $scope.subject.trim() : str.fallbackSubject, // Subject line, or the fallback subject, if nothing valid was entered - body: $scope.body.trim() // use parsed plaintext body + body: $scope.body.trim(), // use parsed plaintext body + attachments: $scope.attachments }; - email.from = [{ - name: '', - address: emailDao._account.emailAddress - }]; - // validate recipients and gather public keys - email.receiverKeys = []; // gather public keys for emailDao._encrypt - - appendReceivers($scope.to, email.to); - appendReceivers($scope.cc, email.cc); - appendReceivers($scope.bcc, email.bcc); - - function appendReceivers(srcField, destField) { - srcField.forEach(function(recipient) { - // validate address - if (!util.validateEmailAddress(recipient.address)) { - return; - } - - // append address to email model - destField.push({ - address: recipient.address - }); - - // add public key to list of recipient keys - if (recipient.key && recipient.key.publicKey) { - email.receiverKeys.push(recipient.key.publicKey); - } - }); - } - - // add attachment to email object - if ($scope.attachments.length > 0) { - email.attachments = $scope.attachments; - } - - // persist the email locally for later smtp transmission - emailDao.storeForOutbox(email, function(err) { + // persist the email to disk for later sending + outbox.put(email, function(err) { if (err) { $scope.onError(err); return; } + // helper flag to remember if we need to sync back to imap + // in case the replyTo.answered changed + var needsSync = false; + + // mark replyTo as answered, if necessary + if ($scope.replyTo && !$scope.replyTo.answered) { + $scope.replyTo.answered = true; + needsSync = true; + } + + // close the writer $scope.state.writer.close(); + + // update the ui the scope $scope.$apply(); - $scope.emptyOutbox($scope.onOutboxUpdate); - markAnswered(); - }); - }; - - function markAnswered() { - // mark replyTo as answered - if (!$scope.replyTo) { - return; - } - - // mark list object - $scope.replyTo.answered = true; - emailDao.sync({ - folder: $scope.state.nav.currentFolder.path - }, function(err) { - if (err && err.code === 42) { - // offline - $scope.onError(); + // if we need to synchronize replyTo.answered, let's do that. + // otherwise, we're done + if (!needsSync) { return; } - $scope.onError(err); + emailDao.sync({ + folder: $scope.state.nav.currentFolder.path + }, function(err) { + if (err && err.code === 42) { + // offline + $scope.onError(); + return; + } + + $scope.onError(err); + }); }); - } + + function filterEmptyAddresses(addr) { + return !!addr.address; + } + }; }; // diff --git a/src/js/dao/email-dao.js b/src/js/dao/email-dao.js index 205e64a..3e7fe63 100644 --- a/src/js/dao/email-dao.js +++ b/src/js/dao/email-dao.js @@ -6,12 +6,12 @@ define(function(require) { str = require('js/app-config').string, config = require('js/app-config').config; - var EmailDAO = function(keychain, crypto, devicestorage) { - var self = this; - - self._keychain = keychain; - self._crypto = crypto; - self._devicestorage = devicestorage; + var EmailDAO = function(keychain, crypto, devicestorage, pgpbuilder, mailreader) { + this._keychain = keychain; + this._crypto = crypto; + this._devicestorage = devicestorage; + this._pgpbuilder = pgpbuilder; + this._mailreader = mailreader; }; // @@ -74,10 +74,6 @@ define(function(require) { self._imapClient = options.imapClient; self._pgpMailer = options.pgpMailer; - // set private key - if (self._crypto && self._crypto._privateKey) { - self._pgpMailer._privateKey = self._crypto._privateKey; - } // delegation-esque pattern to mitigate between node-style events and plain js self._imapClient.onIncomingMessage = function(message) { @@ -137,9 +133,16 @@ define(function(require) { passphrase: options.passphrase, privateKeyArmored: options.keypair.privateKey.encryptedKey, publicKeyArmored: options.keypair.publicKey.publicKey - }, callback); - // set decrypted privateKey to pgpMailer - self._pgpMailer._privateKey = self._crypto._privateKey; + }, function(err) { + if (err) { + callback(err); + return; + } + + // set decrypted privateKey to pgpMailer + self._pgpbuilder._privateKey = self._crypto._privateKey; + callback(); + }); return; } @@ -182,11 +185,88 @@ define(function(require) { encryptedKey: generatedKeypair.privateKeyArmored } }; - self._keychain.putUserKeyPair(newKeypair, callback); + self._keychain.putUserKeyPair(newKeypair, function(err) { + if (err) { + callback(err); + return; + } + + // set decrypted privateKey to pgpMailer + self._pgpbuilder._privateKey = self._crypto._privateKey; + callback(); + }); }); } }; + /** + * Syncs outbox content from disk to memory, not vice-versa + */ + EmailDAO.prototype.syncOutbox = function(options, callback) { + var self = this; + + // check busy status + if (self._account.busy) { + callback({ + errMsg: 'Sync aborted: Previous sync still in progress', + code: 409 + }); + return; + } + + // make sure two syncs for the same folder don't interfere + self._account.busy = true; + + var folder = _.findWhere(self._account.folders, { + path: options.folder + }); + + folder.messages = folder.messages || []; + + self._localListMessages({ + folder: folder.path + }, function(err, storedMessages) { + if (err) { + self._account.busy = false; + callback(err); + return; + } + + // calculate the diffs between memory and disk + var storedIds = _.pluck(storedMessages, 'id'), + inMemoryIds = _.pluck(folder.messages, 'id'), + newIds = _.difference(storedIds, inMemoryIds), + removedIds = _.difference(inMemoryIds, storedIds); + + // which messages are new on the disk that are not yet in memory? + var newMessages = _.filter(storedMessages, function(msg) { + return _.contains(newIds, msg.id); + }); + + // which messages are no longer on disk, i.e. have been sent + var removedMessages = _.filter(folder.messages, function(msg) { + return _.contains(removedIds, msg.id); + }); + + // add the new messages to memory + newMessages.forEach(function(newMessage) { + folder.messages.push(newMessage); + }); + + // remove the sent messages from memory + removedMessages.forEach(function(removedMessage) { + var index = folder.messages.indexOf(removedMessage); + folder.messages.splice(index, 1); + }); + + // update the folder count and we're done. + folder.count = folder.messages.length; + self._account.busy = false; + + callback(); + }); + }; + EmailDAO.prototype.sync = function(options, callback) { /* * Here's how delta sync works: @@ -206,8 +286,7 @@ define(function(require) { * deltaF4: imap > memory => we changed flags directly on the remote, sync them to the storage and memory */ - var self = this, - folder, isFolderInitialized; + var self = this; // validate options if (!options.folder) { @@ -226,16 +305,18 @@ define(function(require) { return; } - // not busy -> set busy + // make sure two syncs for the same folder don't interfere self._account.busy = true; - folder = _.findWhere(self._account.folders, { + var folder = _.findWhere(self._account.folders, { path: options.folder }); - isFolderInitialized = !! folder.messages; - // initial filling from local storage is an exception from the normal sync. - // after reading from local storage, do imap sync + /* + * if the folder is not initialized with the messages from the memory, we need to fill it first, otherwise the delta sync obviously breaks. + * initial filling from local storage is an exception from the normal sync. after reading from local storage, do imap sync + */ + var isFolderInitialized = !! folder.messages; if (!isFolderInitialized) { initFolderMessages(); return; @@ -293,6 +374,7 @@ define(function(require) { storedMessageUids = _.pluck(storedMessages, 'uid'), delta1 = _.difference(storedMessageUids, inMemoryUids); // delta1 contains only uids + // if we're we are done here if (_.isEmpty(delta1)) { doDeltaF2(); return; @@ -664,19 +746,22 @@ define(function(require) { }); } - - function finishSync() { - // after all the tags are up to date, let's adjust the unread mail count - folder.count = _.filter(folder.messages, function(msg) { - return msg.unread === true; - }).length; - // allow the next sync to take place - self._account.busy = false; - callback(); - } } } + function finishSync() { + // whereas normal folders show the unread messages count only, + // the outbox shows the total count + // after all the tags are up to date, let's adjust the unread mail count + folder.count = _.filter(folder.messages, function(msg) { + return msg.unread === true; + }).length; + + // allow the next sync to take place + self._account.busy = false; + callback(); + } + /* * checks if there are some flags that have changed in a and b */ @@ -867,7 +952,7 @@ define(function(require) { }; - EmailDAO.prototype.decryptMessageContent = function(options, callback) { + EmailDAO.prototype.decryptBody = function(options, callback) { var self = this, message = options.message; @@ -900,7 +985,7 @@ define(function(require) { decrypted = decrypted || err.errMsg || 'Error occurred during decryption'; // this is a very primitive detection if we have PGP/MIME or PGP/INLINE - if (decrypted.indexOf('Content-Transfer-Encoding:') === -1 && decrypted.indexOf('Content-Type:') === -1) { + if (!self._mailreader.isRfc(decrypted)) { message.body = decrypted; message.decrypted = true; message.decryptingBody = false; @@ -911,7 +996,7 @@ define(function(require) { // parse the decrypted MIME message self._imapParseMessageBlock({ message: message, - block: decrypted + raw: decrypted }, function(error) { if (error) { message.decryptingBody = false; @@ -934,39 +1019,6 @@ define(function(require) { }); }; - EmailDAO.prototype._imapMark = function(options, callback) { - if (!this._account.online) { - callback({ - errMsg: 'Client is currently offline!', - code: 42 - }); - return; - } - - this._imapClient.updateFlags({ - path: options.folder, - uid: options.uid, - unread: options.unread, - answered: options.answered - }, callback); - }; - - EmailDAO.prototype.move = function(options, callback) { - if (!this._account.online) { - callback({ - errMsg: 'Client is currently offline!', - code: 42 - }); - return; - } - - this._imapClient.moveMessage({ - path: options.folder, - uid: options.uid, - destination: options.destination - }, callback); - }; - EmailDAO.prototype.getAttachment = function(options, callback) { if (!this._account.online) { callback({ @@ -980,8 +1032,7 @@ define(function(require) { }; EmailDAO.prototype.sendEncrypted = function(options, callback) { - var self = this, - email = options.email; + var self = this; if (!this._account.online) { callback({ @@ -991,35 +1042,16 @@ define(function(require) { return; } - // validate the email input - if (!email.to || !email.from || !email.to[0].address || !email.from[0].address || !Array.isArray(email.receiverKeys)) { - callback({ - errMsg: 'Invalid email object!' - }); - return; - } + // add whiteout tag to subject + options.email.subject = str.subjectPrefix + options.email.subject; - // get own public key so send message can be read - self._crypto.exportKeys(function(err, ownKeys) { - if (err) { - callback(err); - return; - } - - // add own public key to receiver list - email.receiverKeys.push(ownKeys.publicKeyArmored); - - // add whiteout tag to subject - email.subject = str.subjectPrefix + email.subject; - - // mime encode, sign, encrypt and send email via smtp - self._pgpMailer.send({ - encrypt: true, - cleartextMessage: str.message, - mail: email, - publicKeysArmored: email.receiverKeys - }, callback); - }); + // mime encode, sign, encrypt and send email via smtp + self._pgpMailer.send({ + encrypt: true, + cleartextMessage: str.message, + mail: options.email, + publicKeysArmored: options.email.publicKeysArmored + }, callback); }; EmailDAO.prototype.sendPlaintext = function(options, callback) { @@ -1040,6 +1072,14 @@ define(function(require) { }, callback); }; + EmailDAO.prototype.encrypt = function(options, callback) { + this._pgpbuilder.encrypt(options, callback); + }; + + EmailDAO.prototype.reEncrypt = function(options, callback) { + this._pgpbuilder.reEncrypt(options, callback); + }; + // // Internal API // @@ -1080,9 +1120,28 @@ define(function(require) { this._pgpMailer.login(); }; - // IMAP API + /** + * Mark imap messages as un-/read or un-/answered + */ + EmailDAO.prototype._imapMark = function(options, callback) { + if (!this._account.online) { + callback({ + errMsg: 'Client is currently offline!', + code: 42 + }); + return; + } + + this._imapClient.updateFlags({ + path: options.folder, + uid: options.uid, + unread: options.unread, + answered: options.answered + }, callback); + }; + /** * Login the imap client */ @@ -1161,7 +1220,7 @@ define(function(require) { }; EmailDAO.prototype._imapParseMessageBlock = function(options, callback) { - this._imapClient.parseDecryptedMessageBlock(options, callback); + this._mailreader.parseRfc(options, callback); }; /** @@ -1277,81 +1336,5 @@ define(function(require) { } }; - /** - * Persists an email object for the outbox, encrypted with the user's public key - * @param {Object} email The email object - * @param {Function} callback(error) Invoked when the email was encrypted and persisted, contains information in case of an error - */ - EmailDAO.prototype.storeForOutbox = function(email, callback) { - var self = this, - dbType = 'email_OUTBOX', - plaintext = email.body; - - // give the email a random identifier (used for storage) - email.id = util.UUID(); - - // get own public key so send message can be read - self._crypto.exportKeys(function(err, ownKeys) { - if (err) { - callback(err); - return; - } - - // encrypt the email with the user's public key - self._crypto.encrypt(plaintext, [ownKeys.publicKeyArmored], function(err, ciphertext) { - if (err) { - callback(err); - return; - } - - // replace plaintext body with pgp message - email.body = ciphertext; - - // store to local storage - self._devicestorage.storeList([email], dbType, callback); - }); - }); - }; - - /** - * Reads and decrypts persisted email objects for the outbox - * @param {Function} callback(error, emails) Invoked when the email was encrypted and persisted, contains information in case of an error - */ - EmailDAO.prototype.listForOutbox = function(callback) { - var self = this, - dbType = 'email_OUTBOX'; - - self._devicestorage.listItems(dbType, 0, null, function(err, mails) { - if (err) { - callback(err); - return; - } - - if (mails.length === 0) { - callback(null, []); - return; - } - - self._crypto.exportKeys(function(err, ownKeys) { - if (err) { - callback(err); - return; - } - - var after = _.after(mails.length, function() { - callback(null, mails); - }); - - mails.forEach(function(mail) { - self._crypto.decrypt(mail.body, ownKeys.publicKeyArmored, function(err, decrypted) { - mail.body = err ? err.errMsg : decrypted; - after(); - }); - mail.encrypted = true; - }); - }); - }); - }; - return EmailDAO; }); \ No newline at end of file diff --git a/test/new-unit/app-controller-test.js b/test/new-unit/app-controller-test.js index ed46db4..0d40aab 100644 --- a/test/new-unit/app-controller-test.js +++ b/test/new-unit/app-controller-test.js @@ -261,13 +261,11 @@ define(function(require) { emailDaoStub.init.yields(null, {}); onConnectStub.yields(); - outboxStub.init.returns(); controller.init({}, function(err, keypair) { expect(err).to.not.exist; expect(keypair).to.exist; expect(onConnectStub.calledOnce).to.be.true; - expect(outboxStub.init.calledOnce).to.be.true; done(); }); }); diff --git a/test/new-unit/email-dao-test.js b/test/new-unit/email-dao-test.js index 3de97ea..dfd5120 100644 --- a/test/new-unit/email-dao-test.js +++ b/test/new-unit/email-dao-test.js @@ -5,18 +5,20 @@ define(function(require) { KeychainDAO = require('js/dao/keychain-dao'), ImapClient = require('imap-client'), PgpMailer = require('pgpmailer'), + PgpBuilder = require('pgpbuilder'), PGP = require('js/crypto/pgp'), DeviceStorageDAO = require('js/dao/devicestorage-dao'), + mailreader = require('mailreader'), str = require('js/app-config').string, expect = chai.expect; chai.Assertion.includeStack = true; describe('Email DAO unit tests', function() { - var dao, keychainStub, imapClientStub, pgpMailerStub, pgpStub, devicestorageStub; + var dao, keychainStub, imapClientStub, pgpMailerStub, pgpBuilderStub, pgpStub, devicestorageStub; var emailAddress, passphrase, asymKeySize, mockkeyId, dummyEncryptedMail, - dummyDecryptedMail, dummyLegacyDecryptedMail, mockKeyPair, account, publicKey, verificationMail, verificationUuid, + dummyDecryptedMail, mockKeyPair, account, verificationMail, verificationUuid, corruptedVerificationMail, corruptedVerificationUuid, nonWhitelistedMail; @@ -78,21 +80,6 @@ define(function(require) { body: 'Content-Type: multipart/signed;\r\n boundary="Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2";\r\n protocol="application/pgp-signature";\r\n micalg=pgp-sha512\r\n\r\n\r\n--Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2\r\nContent-Type: multipart/mixed;\r\n boundary="Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA"\r\n\r\n\r\n--Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/plain;\r\n charset=us-ascii\r\n\r\nasdasd \r\n--Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA\r\nContent-Disposition: attachment;\r\n filename=dummy.txt\r\nContent-Type: text/plain;\r\n name="dummy.txt"\r\nContent-Transfer-Encoding: 7bit\r\n\r\noaudbcoaurbvosuabvlasdjbfalwubjvawvb\r\n--Apple-Mail=_8ED7DC84-6AD9-4A08-8327-80B62D6BCBFA--\r\n\r\n--Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2\r\nContent-Transfer-Encoding: 7bit\r\nContent-Disposition: attachment;\r\n filename=signature.asc\r\nContent-Type: application/pgp-signature;\r\n name=signature.asc\r\nContent-Description: Message signed with OpenPGP using GPGMail\r\n\r\n-----BEGIN PGP SIGNATURE-----\r\nComment: GPGTools - https://gpgtools.org\r\n\r\niQEcBAEBCgAGBQJS2kO1AAoJEDzmUwH7XO/cP+YH/2PSBxX1ZZd83Uf9qBGDY807\r\niHOdgPFXm64YjSnohO7XsPcnmihqP1ipS2aaCXFC3/Vgb9nc4isQFS+i1VdPwfuR\r\n1Pd2l3dC4/nD4xO9h/W6JW7Yd24NS5TJD5cA7LYwQ8LF+rOzByMatiTMmecAUCe8\r\nEEalEjuogojk4IacA8dg/bfLqQu9E+0GYUJBcI97dx/0jZ0qMOxbWOQLsJ3DnUnV\r\nOad7pAIbHEO6T0EBsH7TyTj4RRHkP6SKE0mm6ZYUC7KCk2Z3MtkASTxUrnqW5qZ5\r\noaXUO9GEc8KZcmbCdhZY2Y5h+dmucaO0jpbeSKkvtYyD4KZrSvt7NTb/0dSLh4Y=\r\n=G8km\r\n-----END PGP SIGNATURE-----\r\n\r\n--Apple-Mail=_1D8756C0-F347-4D7A-A8DB-7869CBF14FD2--\r\n', unread: false, answered: false, - receiverKeys: ['-----BEGIN PGP PUBLIC KEY-----\nasd\n-----END PGP PUBLIC KEY-----'] - }; - dummyLegacyDecryptedMail = { - uid: 1234, - from: [{ - address: 'asd@asd.de' - }], - to: [{ - address: 'qwe@qwe.de' - }], - subject: 'qweasd', - body: 'asd', - unread: false, - answered: false, - receiverKeys: ['-----BEGIN PGP PUBLIC KEY-----\nasd\n-----END PGP PUBLIC KEY-----'] }; nonWhitelistedMail = { uid: 1234, @@ -122,15 +109,15 @@ define(function(require) { asymKeySize: asymKeySize, busy: false }; - publicKey = "-----BEGIN PUBLIC KEY-----\r\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxy+Te5dyeWd7g0P+8LNO7fZDQ\r\n" + "g96xTb1J6pYE/pPTMlqhB6BRItIYjZ1US5q2vk5Zk/5KasBHAc9RbCqvh9v4XFEY\r\n" + "JVmTXC4p8ft1LYuNWIaDk+R3dyYXmRNct/JC4tks2+8fD3aOvpt0WNn3R75/FGBt\r\n" + "h4BgojAXDE+PRQtcVQIDAQAB\r\n" + "-----END PUBLIC KEY-----"; keychainStub = sinon.createStubInstance(KeychainDAO); imapClientStub = sinon.createStubInstance(ImapClient); pgpMailerStub = sinon.createStubInstance(PgpMailer); + pgpBuilderStub = sinon.createStubInstance(PgpBuilder); pgpStub = sinon.createStubInstance(PGP); devicestorageStub = sinon.createStubInstance(DeviceStorageDAO); - dao = new EmailDAO(keychainStub, pgpStub, devicestorageStub); + dao = new EmailDAO(keychainStub, pgpStub, devicestorageStub, pgpBuilderStub, mailreader); dao._account = account; expect(dao._keychain).to.equal(keychainStub); @@ -369,14 +356,15 @@ define(function(require) { describe('unlock', function() { it('should unlock', function(done) { - var importMatcher = sinon.match(function(o) { - expect(o.passphrase).to.equal(passphrase); - expect(o.privateKeyArmored).to.equal(mockKeyPair.privateKey.encryptedKey); - expect(o.publicKeyArmored).to.equal(mockKeyPair.publicKey.publicKey); - return true; - }); + dao._pgpMailer = { + _pgpbuilder: {} + }; - pgpStub.importKeys.withArgs(importMatcher).yields(); + pgpStub.importKeys.withArgs({ + passphrase: passphrase, + privateKeyArmored: mockKeyPair.privateKey.encryptedKey, + publicKeyArmored: mockKeyPair.publicKey.publicKey + }).yields(); dao.unlock({ passphrase: passphrase, @@ -391,33 +379,29 @@ define(function(require) { }); it('should generate a keypair and unlock', function(done) { - var genKeysMatcher, persistKeysMatcher, importMatcher, keypair; + var keypair; + + dao._pgpMailer = { + _pgpbuilder: {} + }; keypair = { keyId: 123, publicKeyArmored: mockKeyPair.publicKey.publicKey, privateKeyArmored: mockKeyPair.privateKey.encryptedKey }; - genKeysMatcher = sinon.match(function(o) { - expect(o.emailAddress).to.equal(emailAddress); - expect(o.keySize).to.equal(asymKeySize); - expect(o.passphrase).to.equal(passphrase); - return true; - }); - importMatcher = sinon.match(function(o) { - expect(o.passphrase).to.equal(passphrase); - expect(o.privateKeyArmored).to.equal(mockKeyPair.privateKey.encryptedKey); - expect(o.publicKeyArmored).to.equal(mockKeyPair.publicKey.publicKey); - return true; - }); - persistKeysMatcher = sinon.match(function(o) { - expect(o).to.deep.equal(mockKeyPair); - return true; - }); + pgpStub.generateKeys.withArgs({ + emailAddress: emailAddress, + keySize: asymKeySize, + passphrase: passphrase + }).yields(null, keypair); - pgpStub.generateKeys.withArgs(genKeysMatcher).yields(null, keypair); - pgpStub.importKeys.withArgs(importMatcher).yields(); + pgpStub.importKeys.withArgs({ + passphrase: passphrase, + privateKeyArmored: mockKeyPair.privateKey.encryptedKey, + publicKeyArmored: mockKeyPair.publicKey.publicKey + }).yields(); keychainStub.putUserKeyPair.withArgs().yields(); dao.unlock({ @@ -495,14 +479,12 @@ define(function(require) { describe('_imapParseMessageBlock', function() { it('should parse a message', function(done) { - imapClientStub.parseDecryptedMessageBlock.yields(null, {}); + var parseRfc = sinon.stub(mailreader, 'parseRfc').withArgs({}).yields(); - dao._imapParseMessageBlock(function(err, msg) { - expect(err).to.not.exist; - expect(msg).to.exist; + dao._imapParseMessageBlock({}, function() { + expect(parseRfc.calledOnce).to.be.true; done(); }); - }); }); @@ -1185,13 +1167,13 @@ define(function(require) { }); }); - describe('decryptMessageContent', function() { + describe('decryptBody', function() { it('should not do anything when the message is not encrypted', function() { var message = { encrypted: false }; - dao.decryptMessageContent({ + dao.decryptBody({ message: message }); @@ -1204,7 +1186,7 @@ define(function(require) { decrypted: true }; - dao.decryptMessageContent({ + dao.decryptBody({ message: message }); @@ -1223,20 +1205,20 @@ define(function(require) { body: '-----BEGIN PGP MESSAGE-----asdasdasd-----END PGP MESSAGE-----' }; - mimeBody = 'Content-Transfer-Encoding: Content-Type:'; + mimeBody = 'Content-Type: asdasdasd'; parsedBody = 'body? yes.'; keychainStub.getReceiverPublicKey.withArgs(message.from[0].address).yieldsAsync(null, mockKeyPair.publicKey); pgpStub.decrypt.withArgs(message.body, mockKeyPair.publicKey.publicKey).yieldsAsync(null, mimeBody); parseStub = sinon.stub(dao, '_imapParseMessageBlock', function(o, cb) { expect(o.message).to.equal(message); - expect(o.block).to.equal(mimeBody); + expect(o.raw).to.equal(mimeBody); o.message.body = parsedBody; cb(null, o.message); }); - dao.decryptMessageContent({ + dao.decryptBody({ message: message }, function(error, msg) { expect(error).to.not.exist; @@ -1274,7 +1256,7 @@ define(function(require) { pgpStub.decrypt.withArgs(message.body, mockKeyPair.publicKey.publicKey).yieldsAsync(null, plaintextBody); parseStub = sinon.stub(dao, '_imapParseMessageBlock'); - dao.decryptMessageContent({ + dao.decryptBody({ message: message }, function(error, msg) { expect(error).to.not.exist; @@ -1314,7 +1296,7 @@ define(function(require) { }); parseStub = sinon.stub(dao, '_imapParseMessageBlock'); - dao.decryptMessageContent({ + dao.decryptBody({ message: message }, function(error, msg) { expect(error).to.not.exist; @@ -1347,7 +1329,7 @@ define(function(require) { keychainStub.getReceiverPublicKey.yields({}); parseStub = sinon.stub(dao, '_imapParseMessageBlock'); - dao.decryptMessageContent({ + dao.decryptBody({ message: message }, function(error, msg) { expect(error).to.exist; @@ -2566,26 +2548,6 @@ define(function(require) { }); }); - describe('move', function() { - it('should work', function(done) { - imapClientStub.moveMessage.withArgs({ - path: 'asdf', - uid: 1, - destination: 'asdasd' - }).yields(); - - dao.move({ - folder: 'asdf', - uid: 1, - destination: 'asdasd' - }, function(err) { - expect(imapClientStub.moveMessage.calledOnce).to.be.true; - expect(err).to.not.exist; - done(); - }); - }); - }); - describe('sendPlaintext', function() { it('should work', function(done) { pgpMailerStub.send.withArgs({ @@ -2616,16 +2578,14 @@ define(function(require) { describe('sendEncrypted', function() { it('should work', function(done) { - pgpStub.exportKeys.yields(null, { - privateKeyArmored: mockKeyPair.privateKey.encryptedKey, - publicKeyArmored: mockKeyPair.publicKey.publicKey - }); + var publicKeys = ["PUBLIC KEY"]; + dummyDecryptedMail.publicKeysArmored = publicKeys; pgpMailerStub.send.withArgs({ encrypt: true, cleartextMessage: str.message, mail: dummyDecryptedMail, - publicKeysArmored: dummyDecryptedMail.receiverKeys + publicKeysArmored: publicKeys }).yields(); dao.sendEncrypted({ @@ -2633,17 +2593,14 @@ define(function(require) { }, function(err) { expect(err).to.not.exist; - expect(pgpStub.exportKeys.calledOnce).to.be.true; expect(pgpMailerStub.send.calledOnce).to.be.true; done(); }); }); it('should not work when pgpmailer fails', function(done) { - pgpStub.exportKeys.yields(null, { - privateKeyArmored: mockKeyPair.privateKey.encryptedKey, - publicKeyArmored: mockKeyPair.publicKey.publicKey - }); + var publicKeys = ["PUBLIC KEY"]; + dummyDecryptedMail.publicKeysArmored = publicKeys; pgpMailerStub.send.yields({}); dao.sendEncrypted({ @@ -2651,176 +2608,53 @@ define(function(require) { }, function(err) { expect(err).to.exist; - expect(pgpStub.exportKeys.calledOnce).to.be.true; expect(pgpMailerStub.send.calledOnce).to.be.true; done(); }); }); - it('should not work when sender key export fails', function(done) { - pgpStub.exportKeys.yields({}); + }); - dao.sendEncrypted({ - email: dummyDecryptedMail + describe('encrypt', function() { + it('should encrypt', function(done) { + pgpBuilderStub.encrypt.yields(); + + dao.encrypt({}, function() { + expect(pgpBuilderStub.encrypt.calledOnce).to.be.true; + done(); + }); + }); + }); + + describe('reEncrypt', function() { + it('should re-encrypt', function(done) { + pgpBuilderStub.reEncrypt.yields(); + + dao.reEncrypt({}, function() { + expect(pgpBuilderStub.reEncrypt.calledOnce).to.be.true; + done(); + }); + }); + }); + + describe('syncOutbox', function() { + it('should sync the outbox', function(done) { + var folder = 'FOLDAAAA'; + dao._account.folders = [{ + type: 'Folder', + path: folder + }]; + + var localListStub = sinon.stub(dao, '_localListMessages').withArgs({ + folder: folder + }).yields(null, [dummyEncryptedMail]); + + dao.syncOutbox({ + folder: folder }, function(err) { - expect(err).to.exist; - - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpMailerStub.send.calledOnce).to.be.false; - - done(); - }); - }); - }); - - describe('storeForOutbox', function() { - it('should work', function(done) { - var key = 'omgsocrypto'; - - pgpStub.exportKeys.yields(null, { - publicKeyArmored: key - }); - pgpStub.encrypt.withArgs(dummyDecryptedMail.body, [key]).yields(null, 'asdfasfd'); - devicestorageStub.storeList.withArgs([dummyDecryptedMail], 'email_OUTBOX').yields(); - - dao.storeForOutbox(dummyDecryptedMail, function(err) { expect(err).to.not.exist; - - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.encrypt.calledOnce).to.be.true; - expect(devicestorageStub.storeList.calledOnce).to.be.true; - - done(); - }); - }); - - it('should work when store fails', function(done) { - var key = 'omgsocrypto'; - - pgpStub.exportKeys.yields(null, { - publicKeyArmored: key - }); - pgpStub.encrypt.yields(null, 'asdfasfd'); - devicestorageStub.storeList.yields({}); - - dao.storeForOutbox(dummyDecryptedMail, function(err) { - expect(err).to.exist; - - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.encrypt.calledOnce).to.be.true; - expect(devicestorageStub.storeList.calledOnce).to.be.true; - - done(); - }); - }); - - it('should work when encryption fails', function(done) { - var key = 'omgsocrypto'; - - pgpStub.exportKeys.yields(null, { - publicKeyArmored: key - }); - pgpStub.encrypt.yields({}); - - dao.storeForOutbox(dummyDecryptedMail, function(err) { - expect(err).to.exist; - - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.encrypt.calledOnce).to.be.true; - expect(devicestorageStub.storeList.called).to.be.false; - - done(); - }); - }); - - it('should work when key export fails', function(done) { - pgpStub.exportKeys.yields({}); - - dao.storeForOutbox(dummyDecryptedMail, function(err) { - expect(err).to.exist; - - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.encrypt.called).to.be.false; - expect(devicestorageStub.storeList.called).to.be.false; - - done(); - }); - }); - }); - - describe('listForOutbox', function() { - it('should work', function(done) { - var key = 'omgsocrypto'; - - devicestorageStub.listItems.withArgs('email_OUTBOX', 0, null).yields(null, [dummyEncryptedMail]); - pgpStub.exportKeys.yields(null, { - publicKeyArmored: key - }); - pgpStub.decrypt.withArgs(dummyEncryptedMail.body, key).yields(null, dummyDecryptedMail.body); - - dao.listForOutbox(function(err, mails) { - expect(err).to.not.exist; - - expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.decrypt.calledOnce).to.be.true; - expect(mails.length).to.equal(1); - expect(mails[0].body).to.equal(dummyDecryptedMail.body); - - done(); - }); - }); - - it('should not work when decryption fails', function(done) { - var key = 'omgsocrypto', - errMsg = 'THIS IS AN ERROR!'; - - devicestorageStub.listItems.yields(null, [dummyEncryptedMail]); - pgpStub.exportKeys.yields(null, { - publicKeyArmored: key - }); - pgpStub.decrypt.yields({ - errMsg: errMsg - }); - - dao.listForOutbox(function(err, mails) { - expect(err).to.not.exist; - expect(mails[0].body).to.equal(errMsg); - - expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.decrypt.calledOnce).to.be.true; - - done(); - }); - }); - - it('should not work when key export fails', function(done) { - devicestorageStub.listItems.yields(null, [dummyEncryptedMail]); - pgpStub.exportKeys.yields({}); - - dao.listForOutbox(function(err, mails) { - expect(err).to.exist; - expect(mails).to.not.exist; - - expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(pgpStub.exportKeys.calledOnce).to.be.true; - expect(pgpStub.decrypt.called).to.be.false; - - done(); - }); - }); - - it('should not work when list from storage fails', function(done) { - devicestorageStub.listItems.yields({}); - - dao.listForOutbox(function(err, mails) { - expect(err).to.exist; - expect(mails).to.not.exist; - - expect(devicestorageStub.listItems.calledOnce).to.be.true; - expect(pgpStub.exportKeys.called).to.be.false; - expect(pgpStub.decrypt.called).to.be.false; + expect(localListStub.calledOnce).to.be.true; + expect(dao._account.folders[0].messages.length).to.equal(1); done(); }); diff --git a/test/new-unit/mail-list-ctrl-test.js b/test/new-unit/mail-list-ctrl-test.js index f09e72d..4b3eae2 100644 --- a/test/new-unit/mail-list-ctrl-test.js +++ b/test/new-unit/mail-list-ctrl-test.js @@ -205,24 +205,6 @@ define(function(require) { }); }); - - it('should read directly from outbox instead of doing a full imap sync', function() { - scope._stopWatchTask(); - - var currentFolder = { - type: 'Outbox' - }; - scope.folders = [currentFolder]; - scope.state.nav = { - currentFolder: currentFolder - }; - scope.state.mailList.selected = undefined; - - scope.synchronize(); - - // emails array is also used as the outbox's pending mail - expect(scope.state.mailList.selected).to.deep.equal(emails[emails.length - 1]); - }); }); describe('getBody', function() { @@ -260,7 +242,7 @@ define(function(require) { scope.select(mail); - expect(emailDaoMock.decryptMessageContent.calledOnce).to.be.true; + expect(emailDaoMock.decryptBody.calledOnce).to.be.true; expect(synchronizeMock.calledOnce).to.be.true; expect(scope.state.mailList.selected).to.equal(mail); @@ -288,7 +270,7 @@ define(function(require) { scope.select(mail); - expect(emailDaoMock.decryptMessageContent.calledOnce).to.be.true; + expect(emailDaoMock.decryptBody.calledOnce).to.be.true; expect(synchronizeMock.called).to.be.false; expect(scope.state.mailList.selected).to.equal(mail); diff --git a/test/new-unit/outbox-bo-test.js b/test/new-unit/outbox-bo-test.js index 46c950c..afb7ce3 100644 --- a/test/new-unit/outbox-bo-test.js +++ b/test/new-unit/outbox-bo-test.js @@ -2,13 +2,14 @@ define(function(require) { 'use strict'; var expect = chai.expect, - _ = require('underscore'), OutboxBO = require('js/bo/outbox'), KeychainDAO = require('js/dao/keychain-dao'), EmailDAO = require('js/dao/email-dao'), DeviceStorageDAO = require('js/dao/devicestorage-dao'), InvitationDAO = require('js/dao/invitation-dao'); + chai.Assertion.includeStack = true; + describe('Outbox Business Object unit test', function() { var outbox, emailDaoStub, devicestorageStub, invitationDaoStub, keychainStub, dummyUser = 'spiderpig@springfield.com'; @@ -26,24 +27,10 @@ define(function(require) { keychainStub = sinon.createStubInstance(KeychainDAO); invitationDaoStub = sinon.createStubInstance(InvitationDAO); outbox = new OutboxBO(emailDaoStub, keychainStub, devicestorageStub, invitationDaoStub); - outbox.init(); }); afterEach(function() {}); - describe('init', function() { - it('should work', function() { - expect(outbox).to.exist; - expect(outbox._emailDao).to.equal(emailDaoStub); - expect(outbox._keychain).to.equal(keychainStub); - expect(outbox._devicestorage).to.equal(devicestorageStub); - expect(outbox._invitationDao).to.equal(invitationDaoStub); - expect(outbox._outboxBusy).to.be.false; - expect(outbox.pendingEmails).to.be.empty; - expect(emailDaoStub._account.folders[0].messages).to.equal(outbox.pendingEmails); - }); - }); - describe('start/stop checking', function() { it('should work', function() { function onOutboxUpdate(err) { @@ -58,77 +45,171 @@ define(function(require) { }); }); - describe('process outbox', function() { - it('should send to registered users and update pending mails', function(done) { - var member, invited, notinvited, dummyMails, unsentCount; + describe('put', function() { + it('should encrypt and store a mail', function(done) { + var mail, senderKey, receiverKey; - member = { - id: '123', - to: [{ + senderKey = { + publicKey: 'SENDER PUBLIC KEY' + }; + receiverKey = { + publicKey: 'RECEIVER PUBLIC KEY' + }; + mail = { + from: [{ name: 'member', address: 'member@whiteout.io' - }] + }], + to: [{ + name: 'member', + address: 'member' + }, { + name: 'notamember', + address: 'notamember' + }], + cc: [], + bcc: [] + }; + + keychainStub.getReceiverPublicKey.withArgs(mail.from[0].address).yieldsAsync(null, senderKey); + keychainStub.getReceiverPublicKey.withArgs(mail.to[0].address).yieldsAsync(null, receiverKey); + keychainStub.getReceiverPublicKey.withArgs(mail.to[1].address).yieldsAsync(); + + emailDaoStub.encrypt.withArgs({ + mail: mail, + publicKeysArmored: [senderKey.publicKey, receiverKey.publicKey] + }).yieldsAsync(); + + devicestorageStub.storeList.withArgs([mail]).yieldsAsync(); + + outbox.put(mail, function(error) { + expect(error).to.not.exist; + + expect(mail.publicKeysArmored.length).to.equal(2); + expect(mail.unregisteredUsers.length).to.equal(1); + + done(); + }); + }); + }); + + describe('process outbox', function() { + it('should send to registered users and update pending mails', function(done) { + var from, member, invited, notinvited, newlyjoined, dummyMails, newlyjoinedKey; + + from = [{ + name: 'member', + address: 'member@whiteout.io' + }]; + member = { + id: '12', + from: from, + to: [{ + name: 'member', + address: 'member' + }], + publicKeysArmored: ['ARMORED KEY OF MEMBER'], + unregisteredUsers: [] }; invited = { - id: '456', + id: '34', + from: from, to: [{ name: 'invited', - address: 'invited@whiteout.io' + address: 'invited' + }], + publicKeysArmored: [], + unregisteredUsers: [{ + name: 'invited', + address: 'invited' }] }; notinvited = { - id: '789', + id: '56', + from: from, to: [{ name: 'notinvited', - address: 'notinvited@whiteout.io' + address: 'notinvited' + }], + publicKeysArmored: [], + unregisteredUsers: [{ + name: 'notinvited', + address: 'notinvited' }] }; - dummyMails = [member, invited, notinvited]; + newlyjoined = { + id: '78', + from: from, + to: [{ + name: 'newlyjoined', + address: 'newlyjoined' + }], + publicKeysArmored: [], + unregisteredUsers: [{ + name: 'newlyjoined', + address: 'newlyjoined' + }] + }; + newlyjoinedKey = { + publicKey: 'THIS IS THE NEWLY JOINED PUBLIC KEY!' + }; + + dummyMails = [member, invited, notinvited, newlyjoined]; + + devicestorageStub.listItems.yieldsAsync(null, dummyMails); + + keychainStub.getReceiverPublicKey.withArgs(invited.unregisteredUsers[0].address).yieldsAsync(); + keychainStub.getReceiverPublicKey.withArgs(notinvited.unregisteredUsers[0].address).yieldsAsync(); + keychainStub.getReceiverPublicKey.withArgs(newlyjoined.unregisteredUsers[0].address).yieldsAsync(null, newlyjoinedKey); + + invitationDaoStub.check.withArgs({ + recipient: invited.to[0].address, + sender: invited.from[0].address + }).yieldsAsync(null, InvitationDAO.INVITE_PENDING); + + invitationDaoStub.check.withArgs({ + recipient: notinvited.to[0].address, + sender: notinvited.from[0].address + }).yieldsAsync(null, InvitationDAO.INVITE_MISSING); + + invitationDaoStub.invite.withArgs({ + recipient: notinvited.to[0].address, + sender: notinvited.from[0].address + }).yieldsAsync(null, InvitationDAO.INVITE_SUCCESS); - emailDaoStub.listForOutbox.yieldsAsync(null, dummyMails); - emailDaoStub.sendEncrypted.withArgs(sinon.match(function(opts) { - return typeof opts.email !== 'undefined' && opts.email.to.address === member.to.address; - })).yieldsAsync(); emailDaoStub.sendPlaintext.yieldsAsync(); - devicestorageStub.removeList.yieldsAsync(); - invitationDaoStub.check.withArgs(sinon.match(function(o) { - return o.recipient === 'invited@whiteout.io'; - })).yieldsAsync(null, InvitationDAO.INVITE_PENDING); - invitationDaoStub.check.withArgs(sinon.match(function(o) { - return o.recipient === 'notinvited@whiteout.io'; - })).yieldsAsync(null, InvitationDAO.INVITE_MISSING); - invitationDaoStub.invite.withArgs(sinon.match(function(o) { - return o.recipient === 'notinvited@whiteout.io'; - })).yieldsAsync(null, InvitationDAO.INVITE_SUCCESS); - keychainStub.getReceiverPublicKey.withArgs(sinon.match(function(o) { - return o === 'member@whiteout.io'; - })).yieldsAsync(null, 'this is not the key you are looking for...'); - keychainStub.getReceiverPublicKey.withArgs(sinon.match(function(o) { - return o === 'invited@whiteout.io' || o === 'notinvited@whiteout.io'; - })).yieldsAsync(); - var check = _.after(dummyMails.length + 1, function() { - expect(outbox._outboxBusy).to.be.false; + emailDaoStub.reEncrypt.withArgs({ + mail: newlyjoined, + publicKeysArmored: [newlyjoinedKey.publicKey] + }).yieldsAsync(null, newlyjoined); - expect(unsentCount).to.equal(2); - expect(emailDaoStub.listForOutbox.callCount).to.equal(1); - expect(emailDaoStub.sendEncrypted.callCount).to.equal(1); - expect(emailDaoStub.sendPlaintext.callCount).to.equal(1); - expect(devicestorageStub.removeList.callCount).to.equal(1); - expect(invitationDaoStub.check.callCount).to.equal(2); - expect(invitationDaoStub.invite.callCount).to.equal(1); + emailDaoStub.sendEncrypted.withArgs({ + email: newlyjoined + }).yieldsAsync(); - expect(outbox.pendingEmails.length).to.equal(2); - expect(outbox.pendingEmails).to.contain(invited); - expect(outbox.pendingEmails).to.contain(notinvited); - done(); - }); + emailDaoStub.sendEncrypted.withArgs({ + email: member + }).yieldsAsync(); + + devicestorageStub.storeList.withArgs([newlyjoined]).yieldsAsync(); + + devicestorageStub.removeList.withArgs('email_OUTBOX_' + member.id).yieldsAsync(); + devicestorageStub.removeList.withArgs('email_OUTBOX_' + newlyjoined.id).yieldsAsync(); function onOutboxUpdate(err, count) { expect(err).to.not.exist; - expect(count).to.exist; - unsentCount = count; - check(); + expect(count).to.equal(2); + + expect(outbox._outboxBusy).to.be.false; + expect(emailDaoStub.sendEncrypted.calledTwice).to.be.true; + expect(emailDaoStub.reEncrypt.calledOnce).to.be.true; + expect(emailDaoStub.sendPlaintext.calledOnce).to.be.true; + expect(devicestorageStub.listItems.calledOnce).to.be.true; + expect(keychainStub.getReceiverPublicKey.calledThrice).to.be.true; + expect(invitationDaoStub.check.calledTwice).to.be.true; + + done(); } outbox._processOutbox(onOutboxUpdate); @@ -136,31 +217,16 @@ define(function(require) { it('should not process outbox in offline mode', function(done) { emailDaoStub._account.online = false; - emailDaoStub.listForOutbox.yieldsAsync(null, [{ - id: '123', - to: [{ - name: 'member', - address: 'member@whiteout.io' - }] - }]); + devicestorageStub.listItems.yieldsAsync(null, [{}]); outbox._processOutbox(function(err, count) { expect(err).to.not.exist; expect(count).to.equal(1); - expect(emailDaoStub.listForOutbox.callCount).to.equal(1); + expect(devicestorageStub.listItems.callCount).to.equal(1); expect(outbox._outboxBusy).to.be.false; done(); }); }); - - it('should fire notification', function(done) { - outbox.onSent = function(email) { - expect(email).to.exist; - done(); - }; - - outbox._onSent({}); - }); }); }); }); \ No newline at end of file diff --git a/test/new-unit/write-ctrl-test.js b/test/new-unit/write-ctrl-test.js index 746ffa9..f537c70 100644 --- a/test/new-unit/write-ctrl-test.js +++ b/test/new-unit/write-ctrl-test.js @@ -6,14 +6,24 @@ define(function(require) { mocks = require('angularMocks'), WriteCtrl = require('js/controller/write'), EmailDAO = require('js/dao/email-dao'), + OutboxBO = require('js/bo/outbox'), KeychainDAO = require('js/dao/keychain-dao'), appController = require('js/app-controller'); describe('Write controller unit test', function() { - var ctrl, scope, origEmailDao, emailDaoMock, keychainMock, emailAddress; + var ctrl, scope, + origEmailDao, origOutbox, + emailDaoMock, keychainMock, outboxMock, emailAddress; beforeEach(function() { + // the app controller is a singleton, we need to remember the + // outbox and email dao to restore it after the tests origEmailDao = appController._emailDao; + origOutbox = appController._outboxBo; + + outboxMock = sinon.createStubInstance(OutboxBO); + appController._outboxBo = outboxMock; + emailDaoMock = sinon.createStubInstance(EmailDAO); appController._emailDao = emailDaoMock; @@ -37,8 +47,9 @@ define(function(require) { }); afterEach(function() { - // restore the module + // restore the app controller appController._emailDao = origEmailDao; + appController._outboxBo = origOutbox; }); describe('scope variables', function() { @@ -259,127 +270,38 @@ define(function(require) { }); describe('send to outbox', function() { - it('should work when offline', function(done) { - var re = { - from: [{ - address: 'pity@dafool' - }], - subject: 'Ermahgerd!', - sentDate: new Date(), - body: 'so much body!' - }; - + it('should work', function() { + scope.from = [{ + address: 'pity@dafool' + }]; + scope.to = [{ + address: 'pity@dafool' + }]; + scope.cc = []; + scope.bcc = []; + scope.subject = 'Ermahgerd!'; + scope.body = 'wow. much body! very text!'; + scope.attachments = []; scope.state.nav = { currentFolder: 'currentFolder' }; - scope.emptyOutbox = function() {}; - scope.onError = function(err) { - expect(err).to.not.exist; - expect(scope.state.writer.open).to.be.false; - expect(emailDaoMock.storeForOutbox.calledOnce).to.be.true; - expect(emailDaoMock.sync.calledOnce).to.be.true; + scope.replyTo = {}; - done(); - }; - - emailDaoMock.storeForOutbox.yields(); - emailDaoMock.sync.yields({ - code: 42 - }); - - scope.state.writer.write(re); - scope.sendToOutbox(); - }); - - it('should work', function(done) { - var re = { - from: [{ - address: 'pity@dafool' - }], - subject: 'Ermahgerd!', - sentDate: new Date(), - body: 'so much body!' - }; - - scope.state.nav = { - currentFolder: 'currentFolder' - }; - - scope.emptyOutbox = function() {}; - scope.onError = function(err) { - expect(err).to.not.exist; - expect(scope.state.writer.open).to.be.false; - expect(emailDaoMock.storeForOutbox.calledOnce).to.be.true; - expect(emailDaoMock.sync.calledOnce).to.be.true; - - done(); - }; - - emailDaoMock.storeForOutbox.yields(); + outboxMock.put.yields(); emailDaoMock.sync.yields(); - scope.state.writer.write(re); - scope.sendToOutbox(); - }); - - it('should fail', function(done) { - var re = { - from: [{ - address: 'pity@dafool' - }], - subject: 'Ermahgerd!', - sentDate: new Date(), - body: 'so much body!' - }; - - scope.state.nav = { - currentFolder: 'currentFolder' - }; - - scope.emptyOutbox = function() {}; scope.onError = function(err) { - expect(err).to.exist; - expect(scope.state.writer.open).to.be.false; - expect(emailDaoMock.storeForOutbox.calledOnce).to.be.true; - expect(emailDaoMock.sync.calledOnce).to.be.true; - - done(); + expect(err).to.not.exist; }; - emailDaoMock.storeForOutbox.yields(); - emailDaoMock.sync.yields({}); - - scope.state.writer.write(re); scope.sendToOutbox(); - }); - it('should not work and not close the write view', function(done) { - scope.state.writer.write(); + expect(outboxMock.put.calledOnce).to.be.true; + expect(emailDaoMock.sync.calledOnce).to.be.true; - scope.to = [{ - address: 'pity@dafool.de', - key: { - publicKey: '----- PGP Stuff -----' - } - }]; - scope.body = 'asd'; - scope.subject = 'yaddablabla'; - scope.toKey = 'Public Key'; - - emailDaoMock.storeForOutbox.withArgs(sinon.match(function(mail) { - return mail.from[0].address === emailAddress && mail.to.length === 1 && mail.receiverKeys.length === 1; - })).yields({ - errMsg: 'snafu' - }); - - scope.onError = function(err) { - expect(err).to.exist; - expect(scope.state.writer.open).to.be.true; - expect(emailDaoMock.storeForOutbox.calledOnce).to.be.true; - done(); - }; - scope.sendToOutbox(); + expect(scope.state.writer.open).to.be.false; + expect(scope.replyTo.answered).to.be.true; }); }); });