Merge pull request #21 from whiteout-io/dev/stream-plaintext

Dev/stream plaintext
This commit is contained in:
Tankred Hase 2014-02-20 16:29:24 +01:00
commit 47de4ed5d0
8 changed files with 1210 additions and 833 deletions

View File

@ -58,15 +58,41 @@ define(function(require) {
// scope functions // scope functions
// //
$scope.getBody = function(email) {
// don't stream message content of outbox messages...
if (getFolder().type === 'Outbox') {
return;
}
emailDao.getBody({
folder: getFolder().path,
message: email
}, function(error) {
$scope.$apply();
$scope.onError(error);
});
};
/** /**
* Called when clicking on an email list item * Called when clicking on an email list item
*/ */
$scope.select = function(email) { $scope.select = function(email) {
// unselect an item
if (!email) { if (!email) {
$scope.state.mailList.selected = undefined; $scope.state.mailList.selected = undefined;
return; return;
} }
// if we're in the outbox, don't decrypt as usual
if (getFolder().type !== 'Outbox') {
emailDao.decryptMessageContent({
message: email
}, function(error) {
$scope.$apply();
$scope.onError(error);
});
}
$scope.state.mailList.selected = email; $scope.state.mailList.selected = email;
$scope.state.read.toggle(true); $scope.state.read.toggle(true);
@ -279,10 +305,88 @@ define(function(require) {
}]; // list of receivers }]; // list of receivers
if (attachments) { if (attachments) {
// body structure with three attachments // body structure with three attachments
this.bodystructure = {"1": {"part": "1","type": "text/plain","parameters": {"charset": "us-ascii"},"encoding": "7bit","size": 9,"lines": 2},"2": {"part": "2","type": "application/octet-stream","parameters": {"name": "a.md"},"encoding": "7bit","size": 123,"disposition": [{"type": "attachment","filename": "a.md"}]},"3": {"part": "3","type": "application/octet-stream","parameters": {"name": "b.md"},"encoding": "7bit","size": 456,"disposition": [{"type": "attachment","filename": "b.md"}]},"4": {"part": "4","type": "application/octet-stream","parameters": {"name": "c.md"},"encoding": "7bit","size": 789,"disposition": [{"type": "attachment","filename": "c.md"}]},"type": "multipart/mixed"}; this.bodystructure = {
this.attachments = [{"filename": "a.md","filesize": 123,"mimeType": "text/x-markdown","part": "2","content": null}, {"filename": "b.md","filesize": 456,"mimeType": "text/x-markdown","part": "3","content": null}, {"filename": "c.md","filesize": 789,"mimeType": "text/x-markdown","part": "4","content": null}]; "1": {
"part": "1",
"type": "text/plain",
"parameters": {
"charset": "us-ascii"
},
"encoding": "7bit",
"size": 9,
"lines": 2
},
"2": {
"part": "2",
"type": "application/octet-stream",
"parameters": {
"name": "a.md"
},
"encoding": "7bit",
"size": 123,
"disposition": [{
"type": "attachment",
"filename": "a.md"
}]
},
"3": {
"part": "3",
"type": "application/octet-stream",
"parameters": {
"name": "b.md"
},
"encoding": "7bit",
"size": 456,
"disposition": [{
"type": "attachment",
"filename": "b.md"
}]
},
"4": {
"part": "4",
"type": "application/octet-stream",
"parameters": {
"name": "c.md"
},
"encoding": "7bit",
"size": 789,
"disposition": [{
"type": "attachment",
"filename": "c.md"
}]
},
"type": "multipart/mixed"
};
this.attachments = [{
"filename": "a.md",
"filesize": 123,
"mimeType": "text/x-markdown",
"part": "2",
"content": null
}, {
"filename": "b.md",
"filesize": 456,
"mimeType": "text/x-markdown",
"part": "3",
"content": null
}, {
"filename": "c.md",
"filesize": 789,
"mimeType": "text/x-markdown",
"part": "4",
"content": null
}];
} else { } else {
this.bodystructure = {"part": "1","type": "text/plain","parameters": {"charset": "us-ascii"},"encoding": "7bit","size": 9,"lines": 2}; this.bodystructure = {
"part": "1",
"type": "text/plain",
"parameters": {
"charset": "us-ascii"
},
"encoding": "7bit",
"size": 9,
"lines": 2
};
this.attachments = []; this.attachments = [];
} }
this.unread = unread; this.unread = unread;
@ -306,14 +410,48 @@ define(function(require) {
ngModule.directive('ngIscroll', function() { ngModule.directive('ngIscroll', function() {
return { return {
link: function(scope, elm, attrs) { link: function(scope, elm, attrs) {
var model = attrs.ngIscroll; var model = attrs.ngIscroll,
listEl = elm[0];
scope.$watch(model, function() { scope.$watch(model, function() {
var myScroll; var myScroll;
// activate iscroll // activate iscroll
myScroll = new IScroll(elm[0], { myScroll = new IScroll(listEl, {
mouseWheel: true mouseWheel: true
}); });
// load the visible message bodies, when the list is re-initialized and when scrolling stopped
loadVisible();
myScroll.on('scrollEnd', loadVisible);
}, true); }, true);
/*
* iterates over the mails in the mail list and loads their bodies if they are visible in the viewport
*/
function loadVisible() {
var listBorder = listEl.getBoundingClientRect(),
top = listBorder.top,
bottom = listBorder.bottom,
listItems = listEl.children[0].children,
i = listItems.length,
listItem, message,
isPartiallyVisibleTop, isPartiallyVisibleBottom, isVisible;
while (i--) {
// the n-th list item (the dom representation of an email) corresponds to
// the n-th message model in the filteredMessages array
listItem = listItems.item(i).getBoundingClientRect();
message = scope.filteredMessages[i];
isPartiallyVisibleTop = listItem.top < top && listItem.bottom > top; // a portion of the list item is visible on the top
isPartiallyVisibleBottom = listItem.top < bottom && listItem.bottom > bottom; // a portion of the list item is visible on the bottom
isVisible = listItem.top >= top && listItem.bottom <= bottom; // the list item is visible as a whole
if (isPartiallyVisibleTop || isVisible || isPartiallyVisibleBottom) {
scope.getBody(message);
}
}
}
} }
}; };
}); });

View File

@ -207,11 +207,7 @@ define(function(require) {
*/ */
var self = this, var self = this,
folder, folder, isFolderInitialized;
delta1 /*, delta2 */ , delta3, delta4, //message
deltaF2, deltaF4,
isFolderInitialized;
// validate options // validate options
if (!options.folder) { if (!options.folder) {
@ -247,6 +243,9 @@ define(function(require) {
doLocalDelta(); doLocalDelta();
/*
* pre-fill the memory with the messages stored on the hard disk
*/
function initFolderMessages() { function initFolderMessages() {
folder.messages = []; folder.messages = [];
self._localListMessages({ self._localListMessages({
@ -258,33 +257,21 @@ define(function(require) {
return; return;
} }
if (_.isEmpty(storedMessages)) {
// if there's nothing here, we're good
callback();
doImapDelta();
return;
}
var after = _.after(storedMessages.length, function() {
callback();
doImapDelta();
});
storedMessages.forEach(function(storedMessage) { storedMessages.forEach(function(storedMessage) {
handleMessage(storedMessage, function(err, cleartextMessage) { // remove the body to not load unnecessary data to memory
if (err) { delete storedMessage.body;
self._account.busy = false;
callback(err);
return;
}
folder.messages.push(cleartextMessage); folder.messages.push(storedMessage);
after();
});
}); });
callback();
doImapDelta();
}); });
} }
/*
* compares the messages in memory to the messages on the disk
*/
function doLocalDelta() { function doLocalDelta() {
self._localListMessages({ self._localListMessages({
folder: folder.path folder: folder.path
@ -295,18 +282,17 @@ define(function(require) {
return; return;
} }
/*
* delta1: storage > memory => we deleted messages, remove from remote
* delta2: memory > storage => we added messages, push to remote
* deltaF2: memory > storage => we changed flags, sync them to the remote and memory
*/
delta1 = checkDelta(storedMessages, folder.messages);
// delta2 = checkDelta(folder.messages, storedMessages); // not supported yet
deltaF2 = checkFlags(folder.messages, storedMessages);
doDelta1(); doDelta1();
/*
* delta1:
* storage contains messages that are not present in memory => we deleted messages from the memory, so remove the messages from the remote and the disk
*/
function doDelta1() { function doDelta1() {
var inMemoryUids = _.pluck(folder.messages, 'uid'),
storedMessageUids = _.pluck(storedMessages, 'uid'),
delta1 = _.difference(storedMessageUids, inMemoryUids); // delta1 contains only uids
if (_.isEmpty(delta1)) { if (_.isEmpty(delta1)) {
doDeltaF2(); doDeltaF2();
return; return;
@ -316,11 +302,11 @@ define(function(require) {
doDeltaF2(); doDeltaF2();
}); });
// deltaF2 contains references to the in-memory messages // delta1 contains uids of messages on the disk
delta1.forEach(function(inMemoryMessage) { delta1.forEach(function(inMemoryUid) {
var deleteMe = { var deleteMe = {
folder: folder.path, folder: folder.path,
uid: inMemoryMessage.uid uid: inMemoryUid
}; };
self._imapDeleteMessage(deleteMe, function(err) { self._imapDeleteMessage(deleteMe, function(err) {
@ -343,7 +329,13 @@ define(function(require) {
}); });
} }
/*
* deltaF2:
* memory contains messages that have flags other than those in storage => we changed flags, sync them to the remote and memory
*/
function doDeltaF2() { function doDeltaF2() {
var deltaF2 = checkFlags(folder.messages, storedMessages); // deltaF2 contains the message objects, we need those to sync the flags
if (_.isEmpty(deltaF2)) { if (_.isEmpty(deltaF2)) {
callback(); callback();
doImapDelta(); doImapDelta();
@ -394,51 +386,44 @@ define(function(require) {
}); });
} }
/*
* compare the messages on the imap server to the in memory messages
*/
function doImapDelta() { function doImapDelta() {
self._imapSearch({ self._imapSearch({
folder: folder.path folder: folder.path
}, function(err, uids) { }, function(err, inImapUids) {
if (err) { if (err) {
self._account.busy = false; self._account.busy = false;
callback(err); callback(err);
return; return;
} }
// uidWrappers is just to wrap the bare uids in an object { uid: 123 } so
// the checkDelta function can treat it like something that resembles a stripped down email object...
var uidWrappers = _.map(uids, function(uid) {
return {
uid: uid
};
});
/*
* delta3: memory > imap => we deleted messages directly from the remote, remove from memory and storage
* delta4: imap > memory => we have new messages available, fetch to memory and storage
*/
delta3 = checkDelta(folder.messages, uidWrappers);
delta4 = checkDelta(uidWrappers, folder.messages);
doDelta3(); doDelta3();
// we deleted messages directly from the remote, remove from memory and storage /*
* delta3:
* memory contains messages that are not present on the imap => we deleted messages directly from the remote, remove from memory and storage
*/
function doDelta3() { function doDelta3() {
var inMemoryUids = _.pluck(folder.messages, 'uid'),
delta3 = _.difference(inMemoryUids, inImapUids);
if (_.isEmpty(delta3)) { if (_.isEmpty(delta3)) {
doDelta4(); doDelta4();
return; return;
} }
var after = _.after(delta3.length, function() { var after = _.after(delta3.length, function() {
// we're done with delta 3, so let's continue
doDelta4(); doDelta4();
}); });
// delta3 contains references to the in-memory messages that have been deleted from the remote // delta3 contains uids of the in-memory messages that have been deleted from the remote
delta3.forEach(function(inMemoryMessage) { delta3.forEach(function(inMemoryUid) {
// remove delta3 from local storage // remove from local storage
self._localDeleteMessage({ self._localDeleteMessage({
folder: folder.path, folder: folder.path,
uid: inMemoryMessage.uid uid: inMemoryUid
}, function(err) { }, function(err) {
if (err) { if (err) {
self._account.busy = false; self._account.busy = false;
@ -446,85 +431,98 @@ define(function(require) {
return; return;
} }
// remove delta3 from memory // remove from memory
var idx = folder.messages.indexOf(inMemoryMessage); var inMemoryMessage = _.findWhere(folder.messages, function(msg) {
folder.messages.splice(idx, 1); return msg.uid === inMemoryUid;
});
folder.messages.splice(folder.messages.indexOf(inMemoryMessage), 1);
after(); after();
}); });
}); });
} }
// we have new messages available, fetch to memory and storage /*
// (downstream sync) * delta4:
* imap contains messages that are not present in memory => we have new messages available, fetch downstream to memory and storage
*/
function doDelta4() { function doDelta4() {
var inMemoryUids = _.pluck(folder.messages, 'uid'),
delta4 = _.difference(inImapUids, inMemoryUids);
// eliminate uids smaller than the biggest local uid, i.e. just fetch everything // eliminate uids smaller than the biggest local uid, i.e. just fetch everything
// that came in AFTER the most recent email we have in memory. Keep in mind that // that came in AFTER the most recent email we have in memory. Keep in mind that
// uids are strictly ascending, so there can't be a NEW mail in the mailbox with a // uids are strictly ascending, so there can't be a NEW mail in the mailbox with a
// uid smaller than anything we've encountered before. // uid smaller than anything we've encountered before.
if (!_.isEmpty(folder.messages)) { if (!_.isEmpty(inMemoryUids)) {
var localUids = _.pluck(folder.messages, 'uid'), var maxInMemoryUid = Math.max.apply(null, inMemoryUids); // apply works with separate arguments rather than an array
maxLocalUid = Math.max.apply(null, localUids);
// eliminate everything prior to maxLocalUid // eliminate everything prior to maxInMemoryUid, i.e. everything that was already synced
delta4 = _.filter(delta4, function(uidWrapper) { delta4 = _.filter(delta4, function(uid) {
return uidWrapper.uid > maxLocalUid; return uid > maxInMemoryUid;
}); });
} }
// sync in the uids in ascending order, to not leave the local database in a corrupted state: // no delta, we're done here
// when the 5, 3, 1 should be synced and the client would fail at 3, but 5 was successfully synced, if (_.isEmpty(delta4)) {
// any subsequent syncs would never fetch 1 and 3. simple solution: sync in ascending order doDeltaF4();
delta4 = _.sortBy(delta4, function(uidWrapper) { return;
return uidWrapper.uid; }
});
syncNextItem(); self._imapListMessages({
folder: folder.path,
function syncNextItem() { firstUid: Math.min.apply(null, delta4),
// no delta, we're done here lastUid: Math.max.apply(null, delta4)
if (_.isEmpty(delta4)) { }, function(err, messages) {
doDeltaF4(); if (err) {
self._account.busy = false;
callback(err);
return; return;
} }
// delta4 contains the headers that are newly available on the remote // if there is a verification message in the synced messages, handle it
var nextUidWrapper = delta4.shift(); var verificationMessage = _.findWhere(messages, {
subject: str.subjectPrefix + str.verificationSubject
});
// get the whole message if (verificationMessage) {
self._imapGetMessage({ handleVerification(verificationMessage, function(err) {
folder: folder.path, // TODO: show usable error when the verification failed
uid: nextUidWrapper.uid if (err) {
}, function(err, message) { self._account.busy = false;
if (err) { callback(err);
self._account.busy = false; return;
callback(err); }
storeHeaders();
});
return;
}
storeHeaders();
function storeHeaders() {
// eliminate non-whiteout mails
messages = _.filter(messages, function(message) {
// we don't want to display "[whiteout] "-prefixed mails for now
return message.subject.indexOf(str.subjectPrefix) === 0 && message.subject !== (str.subjectPrefix + str.verificationSubject);
});
// no delta, we're done here
if (_.isEmpty(messages)) {
doDeltaF4();
return; return;
} }
// imap filtering is insufficient, since google ignores non-alphabetical characters // filter out the "[whiteout] " prefix
if (message.subject.indexOf(str.subjectPrefix) === -1) { messages.forEach(function(messages) {
syncNextItem(); messages.subject = messages.subject.replace(/^\[whiteout\] /, '');
return; });
}
if (isVerificationMail(message)) { // persist the encrypted message to the local storage
verify(message, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
syncNextItem();
});
return;
}
// add the encrypted message to the local storage
self._localStoreMessages({ self._localStoreMessages({
folder: folder.path, folder: folder.path,
emails: [message] emails: messages
}, function(err) { }, function(err) {
if (err) { if (err) {
self._account.busy = false; self._account.busy = false;
@ -532,25 +530,21 @@ define(function(require) {
return; return;
} }
// decrypt and add to folder in memory // if persisting worked, add them to the messages array
handleMessage(message, function(err, cleartextMessage) { folder.messages = folder.messages.concat(messages);
if (err) { doDeltaF4();
self._account.busy = false;
callback(err);
return;
}
folder.messages.push(cleartextMessage);
syncNextItem();
});
}); });
}); }
} });
} }
}); });
/**
* deltaF4: imap > memory => we changed flags directly on the remote, sync them to the storage and memory
*/
function doDeltaF4() { function doDeltaF4() {
var answeredUids, unreadUids; var answeredUids, unreadUids,
deltaF4 = [];
getUnreadUids(); getUnreadUids();
@ -593,9 +587,6 @@ define(function(require) {
} }
function updateFlags() { function updateFlags() {
// deltaF4: imap > memory => we changed flags directly on the remote, sync them to the storage and memory
deltaF4 = [];
folder.messages.forEach(function(msg) { folder.messages.forEach(function(msg) {
// if the message's uid is among the uids that should be unread, // if the message's uid is among the uids that should be unread,
// AND the message is not unread, we clearly have to change that // AND the message is not unread, we clearly have to change that
@ -686,27 +677,6 @@ define(function(require) {
} }
} }
/*
* Checks which messages are included in a, but not in b
*/
function checkDelta(a, b) {
var i, msg, exists,
delta = [];
// find the delta
for (i = a.length - 1; i >= 0; i--) {
msg = a[i];
exists = _.findWhere(b, {
uid: msg.uid
});
if (!exists) {
delta.push(msg);
}
}
return delta;
}
/* /*
* checks if there are some flags that have changed in a and b * checks if there are some flags that have changed in a and b
*/ */
@ -728,144 +698,226 @@ define(function(require) {
return delta; return delta;
} }
function isVerificationMail(email) { function handleVerification(message, localCallback) {
return email.subject === str.subjectPrefix + str.verificationSubject; self._imapStreamText({
} folder: options.folder,
message: message
}, function(error) {
var verificationUrlPrefix = config.cloudUrl + config.verificationUrl,
uuid, isValidUuid, index;
function verify(email, localCallback) { if (error) {
var uuid, isValidUuid, index, verifyUrlPrefix = config.cloudUrl + config.verificationUrl; localCallback(error);
if (!email.unread) {
// don't bother if the email was already marked as read
localCallback();
return;
}
index = email.body.indexOf(verifyUrlPrefix);
if (index === -1) {
// there's no url in the email, so forget about that.
localCallback();
return;
}
uuid = email.body.substr(index + verifyUrlPrefix.length, 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);
if (!isValidUuid) {
// there's no valid uuid in the email, so forget about that, too.
localCallback();
return;
}
self._keychain.verifyPublicKey(uuid, function(err) {
if (err) {
localCallback({
errMsg: 'Verifying your public key failed: ' + err.errMsg
});
return; return;
} }
// public key has been verified, mark the message as read, delete it, and ignore it in the future index = message.body.indexOf(verificationUrlPrefix);
self._imapMark({ if (index === -1) {
folder: options.folder, // there's no url in the message, so forget about that.
uid: email.uid, localCallback();
unread: false return;
}, function(err) { }
uuid = message.body.substr(index + verificationUrlPrefix.length, 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);
if (!isValidUuid) {
// there's no valid uuid in the message, so forget about that, too.
localCallback();
return;
}
self._keychain.verifyPublicKey(uuid, function(err) {
if (err) { if (err) {
localCallback(err); localCallback({
errMsg: 'Verifying your public key failed: ' + err.errMsg
});
return; return;
} }
// public key has been verified, delete the message
self._imapDeleteMessage({ self._imapDeleteMessage({
folder: options.folder, folder: options.folder,
uid: email.uid uid: message.uid
}, localCallback); }, localCallback);
}); });
}); });
} }
};
function handleMessage(message, localCallback) { /**
message.subject = message.subject.split(str.subjectPrefix)[1]; * Streams message content
* @param {Object} options.message The message for which to retrieve the body
* @param {Object} options.folder The IMAP folder
* @param {Function} callback(error, message) Invoked when the message is streamed, or provides information if an error occurred
*/
EmailDAO.prototype.getBody = function(options, callback) {
var self = this,
message = options.message,
folder = options.folder;
if (containsArmoredCiphertext(message)) { if (message.loadingBody) {
decrypt(message, localCallback); return;
return;
}
// cleartext mail
localCallback(null, message);
} }
function containsArmoredCiphertext(email) { // the message already has a body, so no need to become active here
return typeof email.body === 'string' && email.body.indexOf(str.cryptPrefix) !== -1 && email.body.indexOf(str.cryptSuffix) !== -1; if (message.body) {
return;
} }
function decrypt(email, localCallback) { message.loadingBody = true;
var sender;
extractArmoredContent(email); // the mail does not have its content in memory
readFromDevice();
// if possible, read the message body from the device
function readFromDevice() {
self._localListMessages({
folder: folder,
uid: message.uid
}, function(err, localMessages) {
var localMessage;
// fetch public key required to verify signatures
sender = email.from[0].address;
self._keychain.getReceiverPublicKey(sender, function(err, senderPubkey) {
if (err) { if (err) {
localCallback(err); message.loadingBody = false;
callback(err);
return; return;
} }
if (!senderPubkey) { localMessage = localMessages[0];
// this should only happen if a mail from another channel is in the inbox
email.body = 'Public key for sender not found!'; if (!localMessage.body) {
localCallback(null, email); streamFromImap();
return; return;
} }
// decrypt and verfiy signatures // attach the body to the mail object
self._crypto.decrypt(email.body, senderPubkey.publicKey, function(err, decrypted) { message.body = localMessage.body;
if (err) { handleEncryptedContent();
decrypted = err.errMsg; });
} }
// set encrypted flag // if reading the message body from the device was unsuccessful,
email.encrypted = true; // stream the message from the imap server
function streamFromImap() {
self._imapStreamText({
folder: folder,
message: message
}, function(error) {
if (error) {
message.loadingBody = false;
callback(error);
return;
}
// does our message block even need to be parsed? message.loadingBody = false;
// this is a very primitive detection if we have a mime node or plain text
// taking this out breaks compatibility to clients < 0.5 self._localStoreMessages({
if (decrypted.indexOf('Content-Transfer-Encoding:') === -1 && folder: folder,
decrypted.indexOf('Content-Type:') === -1) { emails: [message]
// decrypted message is plain text and not a well-formed email }, function(error) {
email.body = decrypted; if (error) {
localCallback(null, email); callback(error);
return; return;
} }
// parse decrypted message handleEncryptedContent();
self._imapParseMessageBlock({
message: email,
block: decrypted
}, function(error, parsedMessage) {
if (!parsedMessage) {
localCallback(error);
return;
}
// remove the pgp-signature from the attachments
parsedMessage.attachments = _.reject(parsedMessage.attachments, function(attmt) {
return attmt.mimeType === "application/pgp-signature";
});
localCallback(error, parsedMessage);
});
}); });
}); });
function extractArmoredContent(email) {
var start = email.body.indexOf(str.cryptPrefix),
end = email.body.indexOf(str.cryptSuffix) + str.cryptSuffix.length;
// parse email body for encrypted message block
email.body = email.body.substring(start, end);
}
} }
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
if (message.encrypted) {
message.decrypted = false;
extractCiphertext();
}
message.loadingBody = false;
callback(null, 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.decryptMessageContent = function(options, callback) {
var self = this,
message = options.message;
// the message has no body, is not encrypted or has already been decrypted
if (message.decryptingBody || !message.body || !message.encrypted || message.decrypted) {
return;
}
message.decryptingBody = true;
// 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);
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);
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 (decrypted.indexOf('Content-Transfer-Encoding:') === -1 && decrypted.indexOf('Content-Type:') === -1) {
message.body = decrypted;
message.decrypted = true;
message.decryptingBody = false;
callback(null, message);
return;
}
// parse the decrypted MIME message
self._imapParseMessageBlock({
message: message,
block: decrypted
}, function(error) {
if (error) {
message.decryptingBody = false;
callback(error);
return;
}
message.decrypted = true;
// remove the pgp-signature from the attachments
message.attachments = _.reject(message.attachments, function(attmt) {
return attmt.mimeType === "application/pgp-signature";
});
// we're done here!
message.decryptingBody = false;
callback(null, message);
});
});
});
}; };
EmailDAO.prototype._imapMark = function(options, callback) { EmailDAO.prototype._imapMark = function(options, callback) {
@ -1099,10 +1151,13 @@ define(function(require) {
}; };
/** /**
* Get an email messsage including the email body from imap * Get an email messsage without the body
* @param {String} options.messageId The * @param {String} options.folder The folder
* @param {Number} options.firstUid The lower bound of the uid (inclusive)
* @param {Number} options.lastUid The upper bound of the uid range (inclusive)
* @param {Function} callback (error, messages) The callback when the imap client is done fetching message metadata
*/ */
EmailDAO.prototype._imapGetMessage = function(options, callback) { EmailDAO.prototype._imapListMessages = function(options, callback) {
var self = this; var self = this;
if (!this._account.online) { if (!this._account.online) {
@ -1113,17 +1168,34 @@ define(function(require) {
return; return;
} }
self._imapClient.getMessage({ self._imapClient.listMessagesByUid({
path: options.folder, path: options.folder,
uid: options.uid firstUid: options.firstUid,
}, function(err, message) { lastUid: options.lastUid
if (err) { }, callback);
callback(err); };
return;
}
callback(null, message); /**
}); * Stream an email messsage's body
* @param {String} options.folder The folder
* @param {Object} options.message The message, as retrieved by _imapListMessages
* @param {Function} callback (error, message) The callback when the imap client is done streaming message text content
*/
EmailDAO.prototype._imapStreamText = function(options, callback) {
var self = this;
if (!this._account.online) {
callback({
errMsg: 'Client is currently offline!',
code: 42
});
return;
}
self._imapClient.getBody({
path: options.folder,
message: options.message
}, callback);
}; };
/** /**

File diff suppressed because one or more lines are too long

View File

@ -85,7 +85,7 @@
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
div { .line {
word-wrap: break-word; word-wrap: break-word;
&.empty-line { &.empty-line {

View File

@ -10,7 +10,7 @@
<div class="list-wrapper" ng-iscroll="state.nav.currentFolder.messages.length"> <div class="list-wrapper" ng-iscroll="state.nav.currentFolder.messages.length">
<ul class="mail-list"> <ul class="mail-list">
<li ng-class="{'mail-list-active': email === state.mailList.selected, 'mail-list-attachment': email.attachments !== undefined && email.attachments.length > 0, 'mail-list-unread': email.unread, 'mail-list-replied': !email.unread && email.answered}" ng-click="select(email)" ng-repeat="email in state.nav.currentFolder.messages | orderBy:'uid':true | filter:searchText"> <li ng-class="{'mail-list-active': email === state.mailList.selected, 'mail-list-attachment': email.attachments !== undefined && email.attachments.length > 0, 'mail-list-unread': email.unread, 'mail-list-replied': !email.unread && email.answered}" ng-click="select(email)" ng-repeat="email in (filteredMessages = (state.nav.currentFolder.messages | orderBy:'uid':true | filter:searchText))">
<h3>{{email.from[0].name || email.from[0].address}}</h3> <h3>{{email.from[0].name || email.from[0].address}}</h3>
<div class="head"> <div class="head">
<div class="flag" data-icon="{{(!email.unread && email.answered) ? '&#xe002;' : ''}}" ng-click="toggleUnread(email); $event.stopPropagation()"></div> <div class="flag" data-icon="{{(!email.unread && email.answered) ? '&#xe002;' : ''}}" ng-click="toggleUnread(email); $event.stopPropagation()"></div>

View File

@ -36,10 +36,13 @@
</div><!--/.ng-switch--> </div><!--/.ng-switch-->
<div class="body"> <div class="body" ng-switch="state.mailList.selected.encrypted === true && state.mailList.selected.decrypted === false">
<!-- Render lines of a text-email in divs for easier styling --> <div class="line">
<div ng-repeat="line in state.mailList.selected.body.split('\n') track by $index" ng-class="{'empty-line': lineEmpty(line)}"> <div ng-switch-when="true">This message contains encrypted content.</div>
{{line}}<br> <div ng-switch-default>
<!-- Render lines of a text-email in divs for easier styling -->
<div ng-repeat="line in state.mailList.selected.body.split('\n') track by $index" ng-class="{'empty-line': lineEmpty(line)}">{{line}}<br></div>
</div>
</div> </div>
</div><!--/.body--> </div><!--/.body-->

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,8 @@ define(function(require) {
KeychainDAO = require('js/dao/keychain-dao'), KeychainDAO = require('js/dao/keychain-dao'),
appController = require('js/app-controller'); appController = require('js/app-controller');
chai.Assertion.includeStack = true;
describe('Mail List controller unit test', function() { describe('Mail List controller unit test', function() {
var scope, ctrl, origEmailDao, emailDaoMock, keychainMock, deviceStorageMock, var scope, ctrl, origEmailDao, emailDaoMock, keychainMock, deviceStorageMock,
emailAddress, notificationClickedHandler, emails, emailAddress, notificationClickedHandler, emails,
@ -223,6 +225,77 @@ define(function(require) {
}); });
}); });
describe('getBody', function() {
it('should get the mail content', function() {
scope.state.nav = {
currentFolder: {
type: 'asd',
}
};
scope.getBody();
expect(emailDaoMock.getBody.calledOnce).to.be.true;
});
});
describe('select', function() {
it('should decrypt, focus mark an unread mail as read', function() {
var mail, synchronizeMock;
mail = {
unread: true
};
synchronizeMock = sinon.stub(scope, 'synchronize');
scope.state = {
nav: {
currentFolder: {
type: 'asd'
}
},
mailList: {},
read: {
toggle: function() {}
}
};
scope.select(mail);
expect(emailDaoMock.decryptMessageContent.calledOnce).to.be.true;
expect(synchronizeMock.calledOnce).to.be.true;
expect(scope.state.mailList.selected).to.equal(mail);
scope.synchronize.restore();
});
it('should decrypt and focus a read mail', function() {
var mail, synchronizeMock;
mail = {
unread: false
};
synchronizeMock = sinon.stub(scope, 'synchronize');
scope.state = {
mailList: {},
read: {
toggle: function() {}
},
nav: {
currentFolder: {
type: 'asd'
}
}
};
scope.select(mail);
expect(emailDaoMock.decryptMessageContent.calledOnce).to.be.true;
expect(synchronizeMock.called).to.be.false;
expect(scope.state.mailList.selected).to.equal(mail);
scope.synchronize.restore();
});
});
describe('remove', function() { describe('remove', function() {
it('should not delete without a selected mail', function() { it('should not delete without a selected mail', function() {
scope.remove(); scope.remove();