diff --git a/package.json b/package.json index 2026351..e14de62 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ }, "dependencies": { "crypto-lib": "https://github.com/whiteout-io/crypto-lib/tarball/v0.1.1", - "imap-client": "https://github.com/whiteout-io/imap-client/tarball/v0.2.6", - "mailreader": "https://github.com/whiteout-io/mailreader/tarball/v0.2.2", - "pgpmailer": "https://github.com/whiteout-io/pgpmailer/tarball/v0.2.2", - "pgpbuilder": "https://github.com/whiteout-io/pgpbuilder/tarball/v0.2.3", + "imap-client": "https://github.com/whiteout-io/imap-client/tarball/v0.3.0", + "mailreader": "https://github.com/whiteout-io/mailreader/tarball/v0.3.0", + "pgpmailer": "https://github.com/whiteout-io/pgpmailer/tarball/v0.3.0", + "pgpbuilder": "https://github.com/whiteout-io/pgpbuilder/tarball/v0.3.0", "requirejs": "2.1.10" }, "devDependencies": { @@ -36,4 +36,4 @@ "grunt-contrib-compress": "~0.5.2", "grunt-node-webkit-builder": "~0.1.17" } -} +} \ No newline at end of file diff --git a/src/js/app-config.js b/src/js/app-config.js index 511fae7..cc0f818 100644 --- a/src/js/app-config.js +++ b/src/js/app-config.js @@ -46,7 +46,7 @@ define(function(require) { iconPath: '/img/icon.png', verificationUrl: '/verify/', verificationUuidLength: 36, - dbVersion: 1, + dbVersion: 2, appVersion: appVersion }; diff --git a/src/js/app-controller.js b/src/js/app-controller.js index 844af7c..f00c318 100644 --- a/src/js/app-controller.js +++ b/src/js/app-controller.js @@ -72,7 +72,7 @@ define(function(require) { self._keychain = keychain = new KeychainDAO(lawnchairDao, pubkeyDao); self._crypto = pgp = new PGP(); self._pgpbuilder = pgpbuilder = new PgpBuilder(); - self._emailSync = emailSync = new EmailSync(keychain, userStorage); + self._emailSync = emailSync = new EmailSync(keychain, userStorage, mailreader); self._emailDao = emailDao = new EmailDAO(keychain, pgp, userStorage, pgpbuilder, mailreader, emailSync); self._outboxBo = new OutboxBO(emailDao, keychain, userStorage); self._updateHandler = new UpdateHandler(appConfigStore, userStorage); @@ -129,7 +129,7 @@ define(function(require) { }; pgpMailer = new PgpMailer(smtpOptions, self._pgpbuilder); - imapClient = new ImapClient(imapOptions, mailreader); + imapClient = new ImapClient(imapOptions); imapClient.onError = onImapError; // connect to clients diff --git a/src/js/controller/mail-list.js b/src/js/controller/mail-list.js index b386e9e..08ce610 100644 --- a/src/js/controller/mail-list.js +++ b/src/js/controller/mail-list.js @@ -418,7 +418,7 @@ define(function(require) { '>> from 0.7.0.1\n' + '>>\n' + '>> God speed!'; // plaintext body - this.html = '

HTML content

'; + this.html = '


---------- Forwarded message ----------
From: MunichJS User Group <info@meetup.com>
Date: Thu, May 8, 2014 at 11:10 PM
Subject: Stay in touch!
To: mail@john.com


Meetup
Axel Rauschmayer
Axel Rauschmayer
Organizer
Good to see you
Béla Varga
Béla Varga
Co-Organizer
Good to see you
Alexander Schmidt
Alexander Schmidt
Good to see you
Amer Alimanovic
Amer Alimanovic
Good to see you
Ankit Bahuguna
Ankit Bahuguna
Good to see you
See all 91 people
Couldn't go? Click here.

Unsubscribe from similar emails from this Meetup Group

Add info@meetup.com to your address book to receive all Meetup emails

Meetup, POB 4668 #37895 NY NY USA 10163

Meetup HQ in NYC is hiring!meetup.com/jobs


'; this.encrypted = true; this.decrypted = true; }; diff --git a/src/js/controller/read.js b/src/js/controller/read.js index 6dc3258..5f6f88e 100644 --- a/src/js/controller/read.js +++ b/src/js/controller/read.js @@ -70,8 +70,13 @@ define(function(require) { $scope.node = undefined; }); $scope.$watch('state.mailList.selected.body', function(body) { - if (!body || (body && $scope.state.mailList.selected.decrypted === false)) { - $scope.node = undefined; + $scope.node = undefined; // reset model + if (!body) { + return; + } + + var selected = $scope.state.mailList.selected; + if (selected.encrypted && !selected.decrypted) { return; } @@ -336,7 +341,10 @@ define(function(require) { scope.html = undefined; if (value) { $timeout(function() { - scope.html = $sce.trustAsHtml(value); + // wrap in html doc with scrollable html tag, since chrome apps does not scroll by default + var prefix = ''; + var suffix = ''; + scope.html = $sce.trustAsHtml(prefix + value + suffix); }); } }); @@ -353,4 +361,4 @@ define(function(require) { }); return ReadCtrl; -}); +}); \ No newline at end of file diff --git a/src/js/dao/email-dao.js b/src/js/dao/email-dao.js index 981c26c..2ee8152 100644 --- a/src/js/dao/email-dao.js +++ b/src/js/dao/email-dao.js @@ -274,123 +274,121 @@ define(function(require) { message = options.message, folder = options.folder; - if (message.loadingBody) { - return; - } - - // the message already has a body, so no need to become active here - if (message.body) { + // 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') { return; } message.loadingBody = true; - // the mail does not have its content in memory - readFromDevice(); + /* + * 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. + */ - // if possible, read the message body from the device - function readFromDevice() { + retrieveContent(); + + function retrieveContent() { + // load the local message from memory self._emailSync._localListMessages({ folder: folder, uid: message.uid }, function(err, localMessages) { - var localMessage; - - if (err) { - message.loadingBody = false; - callback(err); + if (err || localMessages.length === 0) { + done(err); return; } - localMessage = localMessages[0]; + var localMessage = localMessages[0]; - if (!localMessage.body) { - streamFromImap(); + // do we need to fetch content from the imap server? + var needsFetch = false; + localMessage.bodyParts.forEach(function(part) { + needsFetch = (typeof part.content === 'undefined'); + }); + + if (!needsFetch) { + // if we have all the content we need, + // we can extract the content + message.bodyParts = localMessage.bodyParts; + extractContent(); return; } - // attach the body to the mail object - message.body = localMessage.body; - handleEncryptedContent(); - }); - } - - // if reading the message body from the device was unsuccessful, - // stream the message from the imap server - function streamFromImap() { - self._emailSync._imapStreamText({ - folder: folder, - message: message - }, function(error) { - if (error) { - message.loadingBody = false; - callback(error); - return; - } - - message.loadingBody = false; - - // do not write the object from the object used by angular to the disk, instead - // do a short round trip and write back the unpolluted object - self._emailSync._localListMessages({ + // get the raw content from the imap server + self._emailSync._getBodyParts({ folder: folder, - uid: message.uid - }, function(error, storedMessages) { - if (error) { - callback(error); + uid: localMessage.uid, + bodyParts: localMessage.bodyParts + }, function(err, parsedBodyParts) { + if (err) { + done(err); return; } - storedMessages[0].body = message.body; + message.bodyParts = parsedBodyParts; + localMessage.bodyParts = parsedBodyParts; + // persist it to disk self._emailSync._localStoreMessages({ folder: folder, - emails: storedMessages + emails: [localMessage] }, function(error) { if (error) { - callback(error); + done(error); return; } - handleEncryptedContent(); + // extract the content + extractContent(); }); }); }); } - function handleEncryptedContent() { - // normally, the imap-client should already have set the message.encrypted flag. problem: if we have pgp/inline, - // we can't reliably determine if the message is encrypted before we have inspected the payload... - message.encrypted = containsArmoredCiphertext(message); - - // cleans the message body from everything but the ciphertext + function extractContent() { if (message.encrypted) { - message.decrypted = false; - extractCiphertext(); + // show the encrypted message + message.body = self._emailSync.filterBodyParts(message.bodyParts, 'encrypted')[0].content; + done(); + return; } + + // for unencrypted messages, this is the array where the body parts are located + var root = message.bodyParts; + + if (message.signed) { + var signedPart = self._emailSync.filterBodyParts(message.bodyParts, 'signed')[0]; + message.message = signedPart.message; + message.signature = signedPart.signature; + // TODO check integrity + // in case of a signed message, you only want to show the signed content and ignore the rest + root = signedPart.content; + } + + message.attachments = self._emailSync.filterBodyParts(root, 'attachment'); + message.body = _.pluck(self._emailSync.filterBodyParts(root, 'text'), 'content').join('\n'); + message.html = _.pluck(self._emailSync.filterBodyParts(root, 'html'), 'content').join('\n'); + + done(); + } + + function done(err) { message.loadingBody = false; - callback(null, message); + callback(err, err ? undefined : message); } - - function containsArmoredCiphertext() { - return message.body.indexOf(str.cryptPrefix) !== -1 && message.body.indexOf(str.cryptSuffix) !== -1; - } - - function extractCiphertext() { - var start = message.body.indexOf(str.cryptPrefix), - end = message.body.indexOf(str.cryptSuffix) + str.cryptSuffix.length; - - // parse message body for encrypted message block - message.body = message.body.substring(start, end); - } - }; EmailDAO.prototype.decryptBody = function(options, callback) { var self = this, message = options.message; - // the message has no body, is not encrypted or has already been decrypted + // the message is decrypting has no body, is not encrypted or has already been decrypted if (message.decryptingBody || !message.body || !message.encrypted || message.decrypted) { return; } @@ -400,69 +398,65 @@ define(function(require) { // get the sender's public key for signature checking self._keychain.getReceiverPublicKey(message.from[0].address, function(err, senderPublicKey) { if (err) { - message.decryptingBody = false; - callback(err); + done(err); return; } if (!senderPublicKey) { // this should only happen if a mail from another channel is in the inbox - message.body = 'Public key for sender not found!'; - message.decryptingBody = false; - callback(null, message); + showError('Public key for sender not found!'); return; } // get the receiver's public key to check the message signature - self._crypto.decrypt(message.body, senderPublicKey.publicKey, function(err, decrypted) { - // if an error occurs during decryption, display the error message as the message content - decrypted = decrypted || err.errMsg || 'Error occurred during decryption'; - - // this is a very primitive detection if we have PGP/MIME or PGP/INLINE - if (!self._mailreader.isRfc(decrypted)) { - message.body = decrypted; - message.decrypted = true; - message.decryptingBody = false; - callback(null, message); + var encryptedNode = self._emailSync.filterBodyParts(message.bodyParts, 'encrypted')[0]; + self._crypto.decrypt(encryptedNode.content, senderPublicKey.publicKey, function(err, decrypted) { + if (err || !decrypted) { + showError(err.errMsg || err.message); return; } - // parse the decrypted MIME message - self._imapParseMessageBlock({ - message: message, - raw: decrypted - }, function(error) { - if (error) { - message.decryptingBody = false; - callback(error); + // the mailparser works on the .raw property + encryptedNode.raw = decrypted; + + // parse the decrpyted raw content in the mailparser + self._mailreader.parse({ + bodyParts: [encryptedNode] + }, function(err, parsedBodyParts) { + if (err) { + showError(err.errMsg || err.message); return; } - message.decrypted = true; + // we have successfully interpreted the descrypted message, + // so let's update the views on the message parts - // remove the pgp-signature from the attachments - message.attachments = _.reject(message.attachments, function(attmt) { + message.body = _.pluck(self._emailSync.filterBodyParts(parsedBodyParts, 'text'), 'content').join('\n'); + message.html = _.pluck(self._emailSync.filterBodyParts(parsedBodyParts, 'html'), 'content').join('\n'); + message.attachments = _.reject(self._emailSync.filterBodyParts(parsedBodyParts, 'attachment'), function(attmt) { + // remove the pgp-signature from the attachments return attmt.mimeType === "application/pgp-signature"; }); + message.decrypted = true; + + // we're done here! - message.decryptingBody = false; - callback(null, message); + done(); }); }); }); - }; - EmailDAO.prototype.getAttachment = function(options, callback) { - if (!this._account.online) { - callback({ - errMsg: 'Client is currently offline!', - code: 42 - }); - return; + function showError(msg) { + message.body = msg; + message.decrypted = true; // display error msh in body + done(); } - this._imapClient.getAttachment(options, callback); + function done(err) { + message.decryptingBody = false; + callback(err, err ? undefined : message); + } }; EmailDAO.prototype.sendEncrypted = function(options, callback) { @@ -541,10 +535,6 @@ define(function(require) { this._imapClient.logout(callback); }; - EmailDAO.prototype._imapParseMessageBlock = function(options, callback) { - this._mailreader.parseRfc(options, callback); - }; - /** * List the folders in the user's IMAP mailbox. */ diff --git a/src/js/dao/email-sync.js b/src/js/dao/email-sync.js index 374c730..f5eb2ca 100644 --- a/src/js/dao/email-sync.js +++ b/src/js/dao/email-sync.js @@ -5,9 +5,10 @@ define(function(require) { config = require('js/app-config').config, str = require('js/app-config').string; - var EmailSync = function(keychain, devicestorage) { + var EmailSync = function(keychain, devicestorage, mailreader) { this._keychain = keychain; this._devicestorage = devicestorage; + this._mailreader = mailreader; }; EmailSync.prototype.init = function(options, callback) { @@ -168,8 +169,8 @@ define(function(require) { } storedMessages.forEach(function(storedMessage) { - // remove the body to not load unnecessary data to memory - delete storedMessage.body; + // remove the body parts to not load unnecessary data to memory + delete storedMessage.bodyParts; folder.messages.push(storedMessage); }); @@ -619,10 +620,11 @@ define(function(require) { } function handleVerification(message, localCallback) { - self._imapStreamText({ + self._getBodyParts({ folder: options.folder, - message: message - }, function(error) { + uid: message.uid, + bodyParts: message.bodyParts + }, function(error, parsedBodyParts) { // we could not stream the text to determine if the verification was valid or not // so handle it as if it were valid if (error) { @@ -630,8 +632,9 @@ define(function(require) { return; } - var verificationUrlPrefix = config.cloudUrl + config.verificationUrl, - uuid = message.body.split(verificationUrlPrefix).pop().substr(0, config.verificationUuidLength), + var body = _.pluck(self.filterBodyParts(parsedBodyParts, 'text'), 'content').join('\n'), + verificationUrlPrefix = config.cloudUrl + config.verificationUrl, + uuid = body.split(verificationUrlPrefix).pop().substr(0, config.verificationUuidLength), isValidUuid = new RegExp('[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}').test(uuid); // there's no valid uuid in the message, so forget about it @@ -707,12 +710,8 @@ define(function(require) { return; } - this._imapClient.updateFlags({ - path: options.folder, - uid: options.uid, - unread: options.unread, - answered: options.answered - }, callback); + options.path = options.folder; + this._imapClient.updateFlags(options, callback); }; /** @@ -731,18 +730,8 @@ define(function(require) { return; } - var o = { - path: options.folder - }; - - if (typeof options.answered !== 'undefined') { - o.answered = options.answered; - } - if (typeof options.unread !== 'undefined') { - o.unread = options.unread; - } - - this._imapClient.search(o, callback); + options.path = options.folder; + this._imapClient.search(options, callback); }; EmailSync.prototype._imapDeleteMessage = function(options, callback) { @@ -754,10 +743,8 @@ define(function(require) { return; } - this._imapClient.deleteMessage({ - path: options.folder, - uid: options.uid - }, callback); + options.path = options.folder; + this._imapClient.deleteMessage(options, callback); }; /** @@ -778,20 +765,18 @@ define(function(require) { return; } - self._imapClient.listMessagesByUid({ - path: options.folder, - firstUid: options.firstUid, - lastUid: options.lastUid - }, callback); + options.path = options.folder; + self._imapClient.listMessages(options, callback); }; /** * Stream an email messsage's body * @param {String} options.folder The folder - * @param {Object} options.message The message, as retrieved by _imapListMessages + * @param {String} options.uid the message's uid + * @param {Object} options.bodyParts The message, as retrieved by _imapListMessages * @param {Function} callback (error, message) The callback when the imap client is done streaming message text content */ - EmailSync.prototype._imapStreamText = function(options, callback) { + EmailSync.prototype._getBodyParts = function(options, callback) { var self = this; if (!this._account.online) { @@ -802,11 +787,37 @@ define(function(require) { return; } - self._imapClient.getBody({ - path: options.folder, - message: options.message - }, callback); + options.path = options.folder; + self._imapClient.getBodyParts(options, function(err) { + if (err) { + callback(err); + return; + } + // interpret the raw content of the email + self._mailreader.parse(options, callback); + }); }; + /** + * 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 {undefined} result Leave undefined, only used for recursion + */ + EmailSync.prototype.filterBodyParts = function(bodyParts, type, result) { + var self = this; + + result = result || []; + bodyParts.forEach(function(part) { + if (part.type === type) { + result.push(part); + } else if (Array.isArray(part.content)) { + self.filterBodyParts(part.content, type, result); + } + }); + return result; + }; + + return EmailSync; }); \ No newline at end of file diff --git a/src/js/util/update/update-handler.js b/src/js/util/update/update-handler.js index f8c6388..8836c93 100644 --- a/src/js/util/update/update-handler.js +++ b/src/js/util/update/update-handler.js @@ -2,7 +2,8 @@ define(function(require) { 'use strict'; var cfg = require('js/app-config').config, - updateV1 = require('js/util/update/update-v1'); + updateV1 = require('js/util/update/update-v1'), + updateV2 = require('js/util/update/update-v2'); /** * Handles database migration @@ -10,7 +11,7 @@ define(function(require) { var UpdateHandler = function(appConfigStorage, userStorage) { this._appConfigStorage = appConfigStorage; this._userStorage = userStorage; - this._updateScripts = [updateV1]; + this._updateScripts = [updateV1, updateV2]; }; /** diff --git a/src/js/util/update/update-v2.js b/src/js/util/update/update-v2.js new file mode 100644 index 0000000..03fdf50 --- /dev/null +++ b/src/js/util/update/update-v2.js @@ -0,0 +1,28 @@ +define(function() { + 'use strict'; + + /** + * Update handler for transition database version 1 -> 2 + * + * In database version 2, the stored email objects have to be purged, because the + * new data model stores information about the email structure in the property 'bodyParts'. + */ + function updateV2(options, callback) { + var emailDbType = 'email_', + versionDbType = 'dbVersion', + postUpdateDbVersion = 2; + + // remove the emails + options.userStorage.removeList(emailDbType, function(err) { + if (err) { + callback(err); + return; + } + + // update the database version to postUpdateDbVersion + options.appConfigStorage.storeList([postUpdateDbVersion], versionDbType, callback); + }); + } + + return updateV2; +}); \ No newline at end of file diff --git a/src/tpl/read.html b/src/tpl/read.html index f3d7862..1a9f4e8 100644 --- a/src/tpl/read.html +++ b/src/tpl/read.html @@ -60,7 +60,7 @@
+ ng-if="!html && (state.mailList.selected === undefined || (!state.mailList.selected.encrypted && state.mailList.selected.body !== undefined) || (state.mailList.selected.encrypted === true && state.mailList.selected.decrypted === true))">
v2', function() { + var emailDbType = 'email_'; + + beforeEach(function() { + cfg.dbVersion = 2; // app requires database version 2 + appConfigStorageStub.listItems.withArgs(versionDbType).yieldsAsync(null, [1]); // database version is 0 + }); + + afterEach(function() { + // database version is only queried for version checking prior to the update script + // so no need to check this in case-specific tests + expect(appConfigStorageStub.listItems.calledOnce).to.be.true; + }); + + it('should work', function(done) { + userStorageStub.removeList.withArgs(emailDbType).yieldsAsync(); + appConfigStorageStub.storeList.withArgs([2], versionDbType).yieldsAsync(); + + updateHandler.update(function(error) { + expect(error).to.not.exist; + expect(userStorageStub.removeList.calledOnce).to.be.true; + expect(appConfigStorageStub.storeList.calledOnce).to.be.true; + + done(); + }); + }); + + it('should fail when persisting database version fails', function(done) { + userStorageStub.removeList.yieldsAsync(); + appConfigStorageStub.storeList.yieldsAsync({}); + + updateHandler.update(function(error) { + expect(error).to.exist; + expect(userStorageStub.removeList.calledOnce).to.be.true; + expect(appConfigStorageStub.storeList.calledOnce).to.be.true; + + done(); + }); + }); + + it('should fail when wiping emails from database fails', function(done) { + userStorageStub.removeList.yieldsAsync({}); + + updateHandler.update(function(error) { + expect(error).to.exist; + expect(userStorageStub.removeList.calledOnce).to.be.true; + expect(appConfigStorageStub.storeList.called).to.be.false; + + done(); + }); + }); + }); }); }); }); \ No newline at end of file