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 | Axel Rauschmayer Organizer |
';
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