1
0
mirror of https://github.com/moparisthebest/mail synced 2024-10-31 15:25:01 -04:00

Merge pull request #67 from whiteout-io/dev/WO-373

refactor sync
This commit is contained in:
Tankred Hase 2014-06-03 12:32:59 +02:00
commit a730cad49d
20 changed files with 2991 additions and 4151 deletions

View File

@ -11,10 +11,10 @@
}, },
"dependencies": { "dependencies": {
"crypto-lib": "https://github.com/whiteout-io/crypto-lib/tarball/v0.1.1", "crypto-lib": "https://github.com/whiteout-io/crypto-lib/tarball/v0.1.1",
"imap-client": "https://github.com/whiteout-io/imap-client/tarball/v0.3.2", "imap-client": "https://github.com/whiteout-io/imap-client/tarball/v0.3.3",
"mailreader": "https://github.com/whiteout-io/mailreader/tarball/v0.3.2", "mailreader": "https://github.com/whiteout-io/mailreader/tarball/v0.3.3",
"pgpmailer": "https://github.com/whiteout-io/pgpmailer/tarball/v0.3.2", "pgpmailer": "https://github.com/whiteout-io/pgpmailer/tarball/v0.3.3",
"pgpbuilder": "https://github.com/whiteout-io/pgpbuilder/tarball/v0.3.2", "pgpbuilder": "https://github.com/whiteout-io/pgpbuilder/tarball/v0.3.3",
"requirejs": "2.1.10" "requirejs": "2.1.10"
}, },
"devDependencies": { "devDependencies": {

View File

@ -46,15 +46,16 @@ define(function(require) {
iconPath: '/img/icon.png', iconPath: '/img/icon.png',
verificationUrl: '/verify/', verificationUrl: '/verify/',
verificationUuidLength: 36, verificationUuidLength: 36,
dbVersion: 2, dbVersion: 3,
appVersion: appVersion appVersion: appVersion,
outboxMailboxPath: 'OUTBOX',
outboxMailboxType: 'Outbox'
}; };
/** /**
* Strings are maintained here * Strings are maintained here
*/ */
app.string = { app.string = {
subjectPrefix: '[whiteout] ',
fallbackSubject: '(no subject)', fallbackSubject: '(no subject)',
invitationSubject: 'Invitation to a private conversation', invitationSubject: 'Invitation to a private conversation',
invitationMessage: 'Hi,\n\nI use Whiteout Mail to send and receive encrypted email. I would like to exchange encrypted messages with you as well.\n\nPlease install the Whiteout Mail application. This application makes it easy to read and write messages securely with PGP encryption applied.\n\nGo to the Whiteout Networks homepage to learn more and to download the application: https://whiteout.io\n\n', invitationMessage: 'Hi,\n\nI use Whiteout Mail to send and receive encrypted email. I would like to exchange encrypted messages with you as well.\n\nPlease install the Whiteout Mail application. This application makes it easy to read and write messages securely with PGP encryption applied.\n\nGo to the Whiteout Networks homepage to learn more and to download the application: https://whiteout.io\n\n',
@ -63,7 +64,7 @@ define(function(require) {
cryptSuffix: '-----END PGP MESSAGE-----', cryptSuffix: '-----END PGP MESSAGE-----',
signature: '\n\n\n--\nSent from Whiteout Mail - Email encryption for the rest of us\nhttps://whiteout.io\n\n', signature: '\n\n\n--\nSent from Whiteout Mail - Email encryption for the rest of us\nhttps://whiteout.io\n\n',
webSite: 'http://whiteout.io', webSite: 'http://whiteout.io',
verificationSubject: 'New public key uploaded', verificationSubject: '[whiteout] New public key uploaded',
sendBtnClear: 'Send', sendBtnClear: 'Send',
sendBtnSecure: 'Send securely' sendBtnSecure: 'Send securely'
}; };

View File

@ -15,7 +15,6 @@ define(function(require) {
RestDAO = require('js/dao/rest-dao'), RestDAO = require('js/dao/rest-dao'),
EmailDAO = require('js/dao/email-dao'), EmailDAO = require('js/dao/email-dao'),
config = require('js/app-config').config, config = require('js/app-config').config,
EmailSync = require('js/dao/email-sync'),
KeychainDAO = require('js/dao/keychain-dao'), KeychainDAO = require('js/dao/keychain-dao'),
PublicKeyDAO = require('js/dao/publickey-dao'), PublicKeyDAO = require('js/dao/publickey-dao'),
LawnchairDAO = require('js/dao/lawnchair-dao'), LawnchairDAO = require('js/dao/lawnchair-dao'),
@ -43,18 +42,18 @@ define(function(require) {
function onDeviceReady() { function onDeviceReady() {
console.log('Starting app.'); console.log('Starting app.');
self.buildModules(); self.buildModules(options);
// Handle offline and online gracefully // Handle offline and online gracefully
window.addEventListener('online', self.onConnect.bind(self, options.onError)); window.addEventListener('online', self.onConnect.bind(self, options.onError));
window.addEventListener('offline', self.onDisconnect.bind(self, options.onError)); window.addEventListener('offline', self.onDisconnect.bind(self));
self._appConfigStore.init('app-config', callback); self._appConfigStore.init('app-config', callback);
} }
}; };
self.buildModules = function() { self.buildModules = function(options) {
var lawnchairDao, restDao, pubkeyDao, emailDao, emailSync, keychain, pgp, userStorage, pgpbuilder, oauth, appConfigStore; var lawnchairDao, restDao, pubkeyDao, emailDao, keychain, pgp, userStorage, pgpbuilder, oauth, appConfigStore;
// start the mailreader's worker thread // start the mailreader's worker thread
mailreader.startWorker(config.workerPath + '/../lib/mailreader-parser-worker.js'); mailreader.startWorker(config.workerPath + '/../lib/mailreader-parser-worker.js');
@ -72,18 +71,19 @@ define(function(require) {
self._keychain = keychain = new KeychainDAO(lawnchairDao, pubkeyDao); self._keychain = keychain = new KeychainDAO(lawnchairDao, pubkeyDao);
self._crypto = pgp = new PGP(); self._crypto = pgp = new PGP();
self._pgpbuilder = pgpbuilder = new PgpBuilder(); self._pgpbuilder = pgpbuilder = new PgpBuilder();
self._emailSync = emailSync = new EmailSync(keychain, userStorage, mailreader); self._emailDao = emailDao = new EmailDAO(keychain, pgp, userStorage, pgpbuilder, mailreader);
self._emailDao = emailDao = new EmailDAO(keychain, pgp, userStorage, pgpbuilder, mailreader, emailSync);
self._outboxBo = new OutboxBO(emailDao, keychain, userStorage); self._outboxBo = new OutboxBO(emailDao, keychain, userStorage);
self._updateHandler = new UpdateHandler(appConfigStore, userStorage); self._updateHandler = new UpdateHandler(appConfigStore, userStorage);
emailDao.onError = options.onError;
}; };
self.isOnline = function() { self.isOnline = function() {
return navigator.onLine; return navigator.onLine;
}; };
self.onDisconnect = function(callback) { self.onDisconnect = function() {
self._emailDao.onDisconnect(null, callback); self._emailDao.onDisconnect();
}; };
self.onConnect = function(callback) { self.onConnect = function(callback) {

View File

@ -60,7 +60,7 @@ define(function(require) {
allReaders = mail.from.concat(mail.to.concat(mail.cc.concat(mail.bcc))); // all the users that should be able to read the mail 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.publicKeysArmored = []; // gather the public keys
mail.id = util.UUID(); // the mail needs a random uuid for storage in the database mail.uid = mail.id = util.UUID(); // the mail needs a random id & uid for storage in the database
// do not encrypt mails with a bcc recipient, due to a possible privacy leak // do not encrypt mails with a bcc recipient, due to a possible privacy leak
if (mail.bcc.length > 0) { if (mail.bcc.length > 0) {
@ -216,7 +216,7 @@ define(function(require) {
// removes the mail object from disk after successfully sending it // removes the mail object from disk after successfully sending it
function removeFromStorage(mail, done) { function removeFromStorage(mail, done) {
self._devicestorage.removeList(outboxDb + '_' + mail.id, function(err) { self._devicestorage.removeList(outboxDb + '_' + mail.uid, function(err) {
if (err) { if (err) {
self._outboxBusy = false; self._outboxBusy = false;
callback(err); callback(err);

View File

@ -6,7 +6,7 @@ define(function(require) {
appController = require('js/app-controller'), appController = require('js/app-controller'),
IScroll = require('iscroll'), IScroll = require('iscroll'),
notification = require('js/util/notification'), notification = require('js/util/notification'),
emailDao, outboxBo, emailSync; emailDao, outboxBo;
var MailListCtrl = function($scope, $timeout) { var MailListCtrl = function($scope, $timeout) {
// //
@ -15,7 +15,6 @@ define(function(require) {
emailDao = appController._emailDao; emailDao = appController._emailDao;
outboxBo = appController._outboxBo; outboxBo = appController._outboxBo;
emailSync = appController._emailSync;
// //
// scope functions // scope functions
@ -23,7 +22,7 @@ define(function(require) {
$scope.getBody = function(email) { $scope.getBody = function(email) {
emailDao.getBody({ emailDao.getBody({
folder: currentFolder().path, folder: currentFolder(),
message: email message: email
}, function(err) { }, function(err) {
if (err && err.code !== 42) { if (err && err.code !== 42) {
@ -35,7 +34,7 @@ define(function(require) {
$scope.$digest(); $scope.$digest();
// automatically decrypt if it's the selected email // automatically decrypt if it's the selected email
if (email === $scope.state.mailList.selected) { if (email === currentMessage()) {
emailDao.decryptBody({ emailDao.decryptBody({
message: email message: email
}, $scope.onError); }, $scope.onError);
@ -66,49 +65,24 @@ define(function(require) {
return; return;
} }
email.unread = false; $scope.toggleUnread(email);
$scope.synchronize();
}; };
/** /**
* Mark an email as unread or read, respectively * Mark an email as unread or read, respectively
*/ */
$scope.toggleUnread = function(email) { $scope.toggleUnread = function(message) {
email.unread = !email.unread; updateStatus('Updating unread flag...');
$scope.synchronize();
};
/**
* Synchronize the selected imap folder to local storage
*/
$scope.synchronize = function(options) {
updateStatus('Syncing ...');
options = options || {};
options.folder = options.folder || currentFolder().path;
// let email dao handle sync transparently
if (currentFolder().type === 'Outbox') {
emailDao.syncOutbox({
folder: currentFolder().path
}, done);
} else {
emailDao.sync({
folder: options.folder || currentFolder().path
}, done);
}
function done(err) {
if (err && err.code === 409) {
// sync still busy
return;
}
message.unread = !message.unread;
emailDao.setFlags({
folder: currentFolder(),
message: message
}, function(err) {
if (err && err.code === 42) { if (err && err.code === 42) {
// offline // offline, restore
updateStatus('Offline mode'); message.unread = !message.unread;
$scope.$apply(); updateStatus('Unable to mark unread flag in offline mode!');
return; return;
} }
@ -118,59 +92,46 @@ define(function(require) {
return; return;
} }
// display last update updateStatus('Flag updated!');
updateStatus('Last update: ', new Date());
// do not change the selection if we just updated another folder in the background
if (currentFolder().path === options.folder) {
selectFirstMessage();
}
$scope.$apply(); $scope.$apply();
});
// fetch visible bodies at the end of a successful sync
$scope.loadVisibleBodies();
}
}; };
/** /**
* Delete an email by moving it to the trash folder or purging it. * Delete a message
*/ */
$scope.remove = function(email) { $scope.remove = function(message) {
if (!email) { if (!message) {
return; return;
} }
if (currentFolder().type === 'Outbox') { updateStatus('Deleting message...');
$scope.onError({ remove();
errMsg: 'Deleting messages from the outbox is not yet supported.'
function remove() {
emailDao.deleteMessage({
folder: currentFolder(),
message: message
}, function(err) {
if (err) {
// show errors where appropriate
if (err.code === 42) {
$scope.select(message);
updateStatus('Unable to delete message in offline mode!');
return;
}
updateStatus('Error during delete!');
$scope.onError(err);
}
updateStatus('Message deleted!');
$scope.$apply();
}); });
return;
}
removeAndShowNext();
$scope.synchronize();
function removeAndShowNext() {
var index = currentFolder().messages.indexOf(email);
// show the next mail
if (currentFolder().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),
// otherwise show the next one (i.e. the one above in the list)
$scope.select(_.last(currentFolder().messages) === email ? currentFolder().messages[index - 1] : currentFolder().messages[index + 1]);
} else {
// if we have only one email in the array, show nothing
$scope.select();
$scope.state.mailList.selected = undefined;
}
currentFolder().messages.splice(index, 1);
} }
}; };
// share local scope functions with root state // share local scope functions with root state
$scope.state.mailList = { $scope.state.mailList = {
remove: $scope.remove, remove: $scope.remove
synchronize: $scope.synchronize
}; };
// //
@ -185,81 +146,79 @@ define(function(require) {
return; return;
} }
// development... display dummy mail objects // in development, display dummy mail objects
if (!window.chrome || !chrome.identity) { if (!window.chrome || !chrome.identity) {
updateStatus('Last update: ', new Date()); updateStatus('Last update: ', new Date());
currentFolder().messages = createDummyMails(); currentFolder().messages = createDummyMails();
selectFirstMessage();
return; return;
} }
// production... in chrome packaged app
// unselect selection from old folder
$scope.select();
// display and select first // display and select first
selectFirstMessage(); openCurrentFolder();
$scope.synchronize();
}); });
$scope.$watchCollection('state.nav.currentFolder.messages', selectFirstMessage);
function selectFirstMessage(messages) {
if (!messages) {
return;
}
// Shows the next message based on the uid of the currently selected element
if (messages.indexOf(currentMessage()) === -1) {
// wait until after first $digest() so $scope.filteredMessages is set
$timeout(function() {
$scope.select($scope.filteredMessages ? $scope.filteredMessages[0] : undefined);
});
}
}
/** /**
* Sync current folder when client comes back online * Sync current folder when client comes back online
*/ */
$scope.$watch('account.online', function(isOnline) { $scope.$watch('account.online', function(isOnline) {
if (isOnline) { if (isOnline) {
$scope.synchronize(); updateStatus('Online');
openCurrentFolder();
} else { } else {
updateStatus('Offline mode'); updateStatus('Offline mode');
} }
}, true); }, true);
// //
// helper functions // Helper Functions
// //
function openCurrentFolder() {
emailDao.openFolder({
folder: currentFolder()
}, function(error) {
if (error && error.code === 42) {
return;
}
$scope.onError(error);
});
}
function updateStatus(lbl, time) { function updateStatus(lbl, time) {
$scope.lastUpdateLbl = lbl; $scope.lastUpdateLbl = lbl;
$scope.lastUpdate = (time) ? time : ''; $scope.lastUpdate = (time) ? time : '';
} }
function selectFirstMessage() {
// wait until after first $digest() so $scope.filteredMessages is set
$timeout(function() {
var emails = $scope.filteredMessages;
if (!emails || emails.length < 1) {
$scope.select();
return;
}
if (!$scope.state.mailList.selected) {
// select first message
$scope.select(emails[0]);
}
});
}
function currentFolder() { function currentFolder() {
return $scope.state.nav.currentFolder; return $scope.state.nav.currentFolder;
} }
if (!emailDao || !emailSync) { function currentMessage() {
return; // development mode return $scope.state.mailList.selected;
} }
emailDao.onNeedsSync = function(error, folder) { //
if (error) { // Notification API
$scope.onError(error); //
return;
}
$scope.synchronize({ (emailDao || {}).onIncomingMessage = function(msgs) {
folder: folder
});
};
emailSync.onIncomingMessage = function(msgs) {
var popupId, popupTitle, popupMessage, unreadMsgs; var popupId, popupTitle, popupMessage, unreadMsgs;
unreadMsgs = msgs.filter(function(msg) { unreadMsgs = msgs.filter(function(msg) {
@ -299,6 +258,81 @@ define(function(require) {
}); });
}; };
//
// Directives
//
var ngModule = angular.module('mail-list', []);
ngModule.directive('ngIscroll', function($timeout) {
return {
link: function(scope, elm, attrs) {
var model = attrs.ngIscroll,
listEl = elm[0],
myScroll;
/*
* iterates over the mails in the mail list and loads their bodies if they are visible in the viewport
*/
scope.loadVisibleBodies = function() {
var listBorder = listEl.getBoundingClientRect(),
top = listBorder.top,
bottom = listBorder.bottom,
listItems = listEl.children[0].children,
inViewport = false,
listItem, message,
isPartiallyVisibleTop, isPartiallyVisibleBottom, isVisible;
for (var i = 0, len = listItems.length; i < len; 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();
if (!scope.filteredMessages || scope.filteredMessages.length <= i) {
// stop if i get larger than the size of filtered messages
break;
}
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) {
// we are now iterating over visible elements
inViewport = true;
// load mail body of visible
scope.getBody(message);
} else if (inViewport) {
// we are leaving the viewport, so stop iterating over the items
break;
}
}
};
// activate iscroll
myScroll = new IScroll(listEl, {
mouseWheel: true,
scrollbars: true,
fadeScrollbars: true
});
myScroll.on('scrollEnd', scope.loadVisibleBodies);
// refresh iScroll when model length changes
scope.$watchCollection(model, function() {
$timeout(function() {
myScroll.refresh();
});
// load the visible message bodies, when the list is re-initialized and when scrolling stopped
scope.loadVisibleBodies();
});
}
};
});
// Helper for development mode
function createDummyMails() { function createDummyMails() {
var uid = 0; var uid = 0;
@ -428,78 +462,5 @@ define(function(require) {
return dummys; return dummys;
} }
//
// Directives
//
var ngModule = angular.module('mail-list', []);
ngModule.directive('ngIscroll', function($timeout) {
return {
link: function(scope, elm, attrs) {
var model = attrs.ngIscroll,
listEl = elm[0],
myScroll;
/*
* iterates over the mails in the mail list and loads their bodies if they are visible in the viewport
*/
scope.loadVisibleBodies = function() {
var listBorder = listEl.getBoundingClientRect(),
top = listBorder.top,
bottom = listBorder.bottom,
listItems = listEl.children[0].children,
inViewport = false,
listItem, message,
isPartiallyVisibleTop, isPartiallyVisibleBottom, isVisible;
for (var i = 0, len = listItems.length; i < len; 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();
if (!scope.filteredMessages || scope.filteredMessages.length <= i) {
// stop if i get larger than the size of filtered messages
break;
}
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) {
// we are now iterating over visible elements
inViewport = true;
// load mail body of visible
scope.getBody(message);
} else if (inViewport) {
// we are leaving the viewport, so stop iterating over the items
break;
}
}
};
// activate iscroll
myScroll = new IScroll(listEl, {
mouseWheel: true,
scrollbars: true,
fadeScrollbars: true
});
myScroll.on('scrollEnd', scope.loadVisibleBodies);
// refresh iScroll when model length changes
scope.$watchCollection(model, function() {
$timeout(function() {
myScroll.refresh();
});
// load the visible message bodies, when the list is re-initialized and when scrolling stopped
scope.loadVisibleBodies();
});
}
};
});
return MailListCtrl; return MailListCtrl;
}); });

View File

@ -2,8 +2,8 @@ define(function(require) {
'use strict'; 'use strict';
var angular = require('angular'), var angular = require('angular'),
str = require('js/app-config').string,
appController = require('js/app-controller'), appController = require('js/app-controller'),
config = require('js/app-config').config,
notification = require('js/util/notification'), notification = require('js/util/notification'),
_ = require('underscore'), _ = require('underscore'),
emailDao, outboxBo; emailDao, outboxBo;
@ -38,19 +38,16 @@ define(function(require) {
return; return;
} }
// update the outbox mail count. this should normally happen during the delta sync // update the outbox mail count
// 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, { var outbox = _.findWhere($scope.account.folders, {
type: 'Outbox' type: config.outboxMailboxType
}); });
if (outbox === $scope.state.nav.currentFolder) {
$scope.state.mailList.synchronize();
} else {
outbox.count = count; outbox.count = count;
$scope.$apply(); $scope.$apply();
}
emailDao.refreshFolder({
folder: outbox
}, $scope.onError);
}; };
// //
@ -58,7 +55,7 @@ define(function(require) {
// //
// init folders // init folders
initFolders(); initializeFolders();
// select inbox as the current folder on init // select inbox as the current folder on init
if ($scope.account.folders && $scope.account.folders.length > 0) { if ($scope.account.folders && $scope.account.folders.length > 0) {
@ -82,8 +79,13 @@ define(function(require) {
// helper functions // helper functions
// //
function initFolders() { function initializeFolders() {
if (window.chrome && chrome.identity) { // create dummy folder in dev environment only
if (!window.chrome || !chrome.identity) {
createDummyFolders();
return;
}
// get pointer to account/folder/message tree on root scope // get pointer to account/folder/message tree on root scope
$scope.$root.account = emailDao._account; $scope.$root.account = emailDao._account;
@ -92,10 +94,19 @@ define(function(require) {
// start checking outbox periodically // start checking outbox periodically
outboxBo.startChecking($scope.onOutboxUpdate); outboxBo.startChecking($scope.onOutboxUpdate);
return;
} }
function sentNotification(email) {
notification.create({
id: 'o' + email.id,
title: 'Message sent',
message: email.subject
}, function() {});
}
// attach dummy folders for development // attach dummy folders for development
function createDummyFolders() {
$scope.$root.account = {}; $scope.$root.account = {};
$scope.account.folders = [{ $scope.account.folders = [{
type: 'Inbox', type: 'Inbox',
@ -106,9 +117,9 @@ define(function(require) {
count: 0, count: 0,
path: 'SENT' path: 'SENT'
}, { }, {
type: 'Outbox', type: config.outboxMailboxType,
count: 0, count: 0,
path: 'OUTBOX' path: config.outboxMailboxPath
}, { }, {
type: 'Drafts', type: 'Drafts',
count: 0, count: 0,
@ -119,14 +130,6 @@ define(function(require) {
path: 'TRASH' path: 'TRASH'
}]; }];
} }
function sentNotification(email) {
notification.create({
id: 'o' + email.id,
title: 'Message sent',
message: email.subject.replace(str.subjectPrefix, '')
}, function() {});
}
}; };
// //
@ -165,12 +168,6 @@ define(function(require) {
scope.state.writer.write(scope.state.mailList.selected); scope.state.writer.write(scope.state.mailList.selected);
scope.$apply(); scope.$apply();
} else if (modifier && e.keyCode === 83 && scope.state.lightbox !== 'write' && scope.state.mailList.synchronize) {
// s -> sync folder
e.preventDefault();
scope.state.mailList.synchronize();
scope.$apply();
} else if (e.keyCode === 27 && scope.state.lightbox !== undefined) { } else if (e.keyCode === 27 && scope.state.lightbox !== undefined) {
// escape -> close current lightbox // escape -> close current lightbox
e.preventDefault(); e.preventDefault();

View File

@ -117,7 +117,7 @@ define(function(require) {
var email = $scope.state.mailList.selected; var email = $scope.state.mailList.selected;
emailDao.getAttachment({ emailDao.getAttachment({
folder: folder.path, folder: folder,
uid: email.uid, uid: email.uid,
attachment: attachment attachment: attachment
}, function(err) { }, function(err) {

View File

@ -326,51 +326,44 @@ define(function(require) {
return; return;
} }
// helper flag to remember if we need to sync back to imap // if we need to synchronize replyTo.answered = true to imap,
// in case the replyTo.answered changed // let's do that. otherwise, we're done
var needsSync = false; if (!$scope.replyTo || $scope.replyTo.answered) {
return;
}
// mark replyTo as answered, if necessary
if ($scope.replyTo && !$scope.replyTo.answered) {
$scope.replyTo.answered = true; $scope.replyTo.answered = true;
// update the ui emailDao.setFlags({
$scope.$apply(); folder: currentFolder(),
needsSync = true; message: $scope.replyTo
}
// if we need to synchronize replyTo.answered, let's do that.
// otherwise, we're done
if (!needsSync) {
return;
}
emailDao.sync({
folder: $scope.state.nav.currentFolder.path
}, function(err) { }, function(err) {
if (err && err.code === 42) { if (err && err.code !== 42) {
// offline $scope.onError(err);
$scope.onError();
return; return;
} }
$scope.onError(err); // offline or no error, let's apply the ui changes
$scope.$apply();
}); });
}); });
}; };
};
// //
// Helpers // Helpers
// //
function currentFolder() {
return $scope.state.nav.currentFolder;
}
/* /*
* Visitor to filter out objects without an address property, i.e. empty addresses * Visitor to filter out objects without an address property, i.e. empty addresses
*/ */
function filterEmptyAddresses(addr) { function filterEmptyAddresses(addr) {
return !!addr.address; return !!addr.address;
} }
};
// //

File diff suppressed because it is too large Load Diff

View File

@ -1,848 +0,0 @@
define(function(require) {
'use strict';
var _ = require('underscore'),
config = require('js/app-config').config,
str = require('js/app-config').string;
var EmailSync = function(keychain, devicestorage, mailreader) {
this._keychain = keychain;
this._devicestorage = devicestorage;
this._mailreader = mailreader;
};
EmailSync.prototype.init = function(options, callback) {
this._account = options.account;
callback();
};
EmailSync.prototype.onConnect = function(options, callback) {
this._imapClient = options.imapClient;
callback();
};
EmailSync.prototype.onDisconnect = function(options, callback) {
this._imapClient = undefined;
callback();
};
/**
* Syncs outbox content from disk to memory, not vice-versa
*/
EmailSync.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();
});
};
EmailSync.prototype.sync = function(options, callback) {
/*
* Here's how delta sync works:
*
* First, we sync the messages between memory and local storage, based on their uid
* delta1: storage > memory => we deleted messages, remove from remote and memory
* delta2: memory > storage => we added messages, push to remote <<< not supported yet
*
* Second, we check the delta for the flags
* deltaF2: memory > storage => we changed flags, sync them to the remote and memory
*
* Third, we go on to sync between imap and memory, again based on 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
*
* Fourth, we pull changes in the flags downstream
* deltaF4: imap > memory => we changed flags directly on the remote, sync them to the storage and memory
*/
var self = this;
// validate options
if (!options.folder) {
callback({
errMsg: 'Invalid options!'
});
return;
}
// 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
});
/*
* 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;
}
doLocalDelta();
/*
* pre-fill the memory with the messages stored on the hard disk
*/
function initFolderMessages() {
folder.messages = [];
self._localListMessages({
folder: folder.path
}, function(err, storedMessages) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
storedMessages.forEach(function(storedMessage) {
// remove the body parts to not load unnecessary data to memory
delete storedMessage.bodyParts;
folder.messages.push(storedMessage);
});
callback();
doImapDelta();
});
}
/*
* compares the messages in memory to the messages on the disk
*/
function doLocalDelta() {
self._localListMessages({
folder: folder.path
}, function(err, storedMessages) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
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() {
var inMemoryUids = _.pluck(folder.messages, 'uid'),
storedMessageUids = _.pluck(storedMessages, 'uid'),
delta1 = _.difference(storedMessageUids, inMemoryUids); // delta1 contains only uids
// if we're we are done here
if (_.isEmpty(delta1)) {
doDeltaF2();
return;
}
var after = _.after(delta1.length, function() {
doDeltaF2();
});
// delta1 contains uids of messages on the disk
delta1.forEach(function(inMemoryUid) {
var deleteMe = {
folder: folder.path,
uid: inMemoryUid
};
self._imapDeleteMessage(deleteMe, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
self._localDeleteMessage(deleteMe, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
after();
});
});
});
}
/*
* deltaF2:
* memory contains messages that have flags other than those in storage => we changed flags, sync them to the remote and memory
*/
function doDeltaF2() {
var deltaF2 = checkFlags(folder.messages, storedMessages); // deltaF2 contains the message objects, we need those to sync the flags
if (_.isEmpty(deltaF2)) {
callback();
doImapDelta();
return;
}
var after = _.after(deltaF2.length, function() {
callback();
doImapDelta();
});
// deltaF2 contains references to the in-memory messages
deltaF2.forEach(function(inMemoryMessage) {
self._imapMark({
folder: folder.path,
uid: inMemoryMessage.uid,
unread: inMemoryMessage.unread,
answered: inMemoryMessage.answered
}, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
var storedMessage = _.findWhere(storedMessages, {
uid: inMemoryMessage.uid
});
storedMessage.unread = inMemoryMessage.unread;
storedMessage.answered = inMemoryMessage.answered;
self._localStoreMessages({
folder: folder.path,
emails: [storedMessage]
}, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
after();
});
});
});
}
});
}
/*
* compare the messages on the imap server to the in memory messages
*/
function doImapDelta() {
self._imapSearch({
folder: folder.path
}, function(err, inImapUids) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
doDelta3();
/*
* 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() {
var inMemoryUids = _.pluck(folder.messages, 'uid'),
delta3 = _.difference(inMemoryUids, inImapUids);
if (_.isEmpty(delta3)) {
doDelta4();
return;
}
var after = _.after(delta3.length, function() {
doDelta4();
});
// delta3 contains uids of the in-memory messages that have been deleted from the remote
delta3.forEach(function(inMemoryUid) {
// remove from local storage
self._localDeleteMessage({
folder: folder.path,
uid: inMemoryUid
}, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
// remove from memory
var inMemoryMessage = _.findWhere(folder.messages, function(msg) {
return msg.uid === inMemoryUid;
});
folder.messages.splice(folder.messages.indexOf(inMemoryMessage), 1);
after();
});
});
}
/*
* delta4:
* imap contains messages that are not present in memory => we have new messages available, fetch downstream to memory and storage
*/
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
// 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
// uid smaller than anything we've encountered before.
if (!_.isEmpty(inMemoryUids)) {
var maxInMemoryUid = Math.max.apply(null, inMemoryUids); // apply works with separate arguments rather than an array
// eliminate everything prior to maxInMemoryUid, i.e. everything that was already synced
delta4 = _.filter(delta4, function(uid) {
return uid > maxInMemoryUid;
});
}
// no delta, we're done here
if (_.isEmpty(delta4)) {
doDeltaF4();
return;
}
// list the messages starting from the lowest new uid to the highest new uid
self._imapListMessages({
folder: folder.path,
firstUid: Math.min.apply(null, delta4),
lastUid: Math.max.apply(null, delta4)
}, function(err, messages) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
// if there are verification messages in the synced messages, handle it
var verificationMessages = _.filter(messages, function(message) {
return message.subject === (str.subjectPrefix + str.verificationSubject);
});
// if there are verification messages, continue after we've tried to verify
if (verificationMessages.length > 0) {
var after = _.after(verificationMessages.length, storeHeaders);
verificationMessages.forEach(function(verificationMessage) {
handleVerification(verificationMessage, function(err, isValid) {
// if it was NOT a valid verification mail, do nothing
if (!isValid) {
after();
return;
}
// if an error occurred and the mail was a valid verification mail, display the error, but
// keep the mail in the list so the user can see it and verify manually
if (err) {
callback(err);
after();
return;
}
// if verification worked, we remove the mail from the list.
messages.splice(messages.indexOf(verificationMessage), 1);
after();
});
});
return;
}
// no verification messages, just proceed as usual
storeHeaders();
function storeHeaders() {
// no delta, we're done here
if (_.isEmpty(messages)) {
doDeltaF4();
return;
}
// persist the encrypted message to the local storage
self._localStoreMessages({
folder: folder.path,
emails: messages
}, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
// this enables us to already show the attachment clip in the message list ui
messages.forEach(function(message) {
message.attachments = message.bodyParts.filter(function(bodyPart) {
return bodyPart.type === 'attachment';
});
});
// if persisting worked, add them to the messages array
folder.messages = folder.messages.concat(messages);
self.onIncomingMessage(messages);
doDeltaF4();
});
}
});
}
});
/**
* deltaF4: imap > memory => we changed flags directly on the remote, sync them to the storage and memory
*/
function doDeltaF4() {
var answeredUids, unreadUids,
deltaF4 = [];
getUnreadUids();
// find all the relevant unread mails
function getUnreadUids() {
self._imapSearch({
folder: folder.path,
unread: true
}, function(err, uids) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
// we're done here, let's get all the answered mails
unreadUids = uids;
getAnsweredUids();
});
}
// find all the relevant answered mails
function getAnsweredUids() {
// find all the relevant answered mails
self._imapSearch({
folder: folder.path,
answered: true
}, function(err, uids) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
// we're done here, let's update what we have in memory and persist that!
answeredUids = uids;
updateFlags();
});
}
function updateFlags() {
folder.messages.forEach(function(msg) {
// 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
var shouldBeUnread = _.contains(unreadUids, msg.uid);
if (msg.unread === shouldBeUnread) {
// everything is in order, we're good here
return;
}
msg.unread = shouldBeUnread;
deltaF4.push(msg);
});
folder.messages.forEach(function(msg) {
// if the message's uid is among the uids that should be answered,
// AND the message is not answered, we clearly have to change that
var shouldBeAnswered = _.contains(answeredUids, msg.uid);
if (msg.answered === shouldBeAnswered) {
// everything is in order, we're good here
return;
}
msg.answered = shouldBeAnswered;
deltaF4.push(msg);
});
// maybe a mail had BOTH flags wrong, so let's create
// a duplicate-free version of deltaF4
deltaF4 = _.uniq(deltaF4);
// everything up to date? fine, we're done!
if (_.isEmpty(deltaF4)) {
finishSync();
return;
}
var after = _.after(deltaF4.length, function() {
// we're doing updating everything
finishSync();
});
// alright, so let's sync the corrected messages
deltaF4.forEach(function(inMemoryMessage) {
// do a short round trip to the database to avoid re-encrypting,
// instead use the encrypted object in the storage
self._localListMessages({
folder: folder.path,
uid: inMemoryMessage.uid
}, function(err, storedMessages) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
var storedMessage = storedMessages[0];
storedMessage.unread = inMemoryMessage.unread;
storedMessage.answered = inMemoryMessage.answered;
// persist the modified object
self._localStoreMessages({
folder: folder.path,
emails: [storedMessage]
}, function(err) {
if (err) {
self._account.busy = false;
callback(err);
return;
}
// and we're done.
after();
});
});
});
}
}
}
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
*/
function checkFlags(a, b) {
var i, aI, bI,
delta = [];
// find the delta
for (i = a.length - 1; i >= 0; i--) {
aI = a[i];
bI = _.findWhere(b, {
uid: aI.uid
});
if (bI && (aI.unread !== bI.unread || aI.answered !== bI.answered)) {
delta.push(aI);
}
}
return delta;
}
function handleVerification(message, localCallback) {
self._getBodyParts({
folder: options.folder,
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) {
localCallback(error, true);
return;
}
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
if (!isValidUuid) {
localCallback(null, false);
return;
}
// there's a valid uuid in the message, so try to verify it
self._keychain.verifyPublicKey(uuid, function(err) {
if (err) {
localCallback({
errMsg: 'Verifying your public key failed: ' + err.errMsg
}, true);
return;
}
// public key has been verified, delete the message
self._imapDeleteMessage({
folder: options.folder,
uid: message.uid
}, function() {
// if we could successfully not delete the message or not doesn't matter.
// just don't show it in whiteout and keep quiet about it
localCallback(null, true);
});
});
});
}
};
//
// Internal APIs
//
// Local Storage API
EmailSync.prototype._localListMessages = function(options, callback) {
var dbType = 'email_' + options.folder;
if (typeof options.uid !== 'undefined') {
dbType = dbType + '_' + options.uid;
}
this._devicestorage.listItems(dbType, 0, null, callback);
};
EmailSync.prototype._localStoreMessages = function(options, callback) {
var dbType = 'email_' + options.folder;
this._devicestorage.storeList(options.emails, dbType, callback);
};
EmailSync.prototype._localDeleteMessage = function(options, callback) {
if (!options.folder || !options.uid) {
callback({
errMsg: 'Invalid options!'
});
return;
}
var dbType = 'email_' + options.folder + '_' + options.uid;
this._devicestorage.removeList(dbType, callback);
};
// IMAP API
/**
* Mark imap messages as un-/read or un-/answered
*/
EmailSync.prototype._imapMark = function(options, callback) {
if (!this._account.online) {
callback({
errMsg: 'Client is currently offline!',
code: 42
});
return;
}
options.path = options.folder;
this._imapClient.updateFlags(options, callback);
};
/**
* Returns the relevant messages corresponding to the search terms in the options
* @param {String} options.folder The folder's path
* @param {Boolean} options.answered (optional) Mails with or without the \Answered flag set.
* @param {Boolean} options.unread (optional) Mails with or without the \Seen flag set.
* @param {Function} callback(error, uids) invoked with the uids of messages matching the search terms, or an error object if an error occurred
*/
EmailSync.prototype._imapSearch = function(options, callback) {
if (!this._account.online) {
callback({
errMsg: 'Client is currently offline!',
code: 42
});
return;
}
options.path = options.folder;
this._imapClient.search(options, callback);
};
EmailSync.prototype._imapDeleteMessage = function(options, callback) {
if (!this._account.online) {
callback({
errMsg: 'Client is currently offline!',
code: 42
});
return;
}
var trash = _.findWhere(this._account.folders, {
type: 'Trash'
});
// there's no known trash folder to move the mail to or we're in the trash folder,
// so we can purge the message
if (!trash || options.folder === trash.path) {
this._imapClient.deleteMessage({
path: options.folder,
uid: options.uid
}, callback);
return;
}
this._imapClient.moveMessage({
path: options.folder,
destination: trash.path,
uid: options.uid
}, callback);
};
/**
* Get an email messsage without the body
* @param {String} options.folder The folder
* @param {Number} options.firstUid The lower bound of the uid (inclusive)
* @param {Number} options.lastUid The upper bound of the uid range (inclusive)
* @param {Function} callback (error, messages) The callback when the imap client is done fetching message metadata
*/
EmailSync.prototype._imapListMessages = function(options, callback) {
var self = this;
if (!this._account.online) {
callback({
errMsg: 'Client is currently offline!',
code: 42
});
return;
}
options.path = options.folder;
self._imapClient.listMessages(options, callback);
};
/**
* Stream an email messsage's body
* @param {String} options.folder The folder
* @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._getBodyParts = function(options, callback) {
var self = this;
if (!this._account.online) {
callback({
errMsg: 'Client is currently offline!',
code: 42
});
return;
}
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;
});

View File

@ -3,7 +3,8 @@ define(function(require) {
var cfg = require('js/app-config').config, 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'); updateV2 = require('js/util/update/update-v2'),
updateV3 = require('js/util/update/update-v3');
/** /**
* Handles database migration * Handles database migration
@ -11,7 +12,7 @@ define(function(require) {
var UpdateHandler = function(appConfigStorage, userStorage) { var UpdateHandler = function(appConfigStorage, userStorage) {
this._appConfigStorage = appConfigStorage; this._appConfigStorage = appConfigStorage;
this._userStorage = userStorage; this._userStorage = userStorage;
this._updateScripts = [updateV1, updateV2]; this._updateScripts = [updateV1, updateV2, updateV3];
}; };
/** /**

View File

@ -0,0 +1,28 @@
define(function() {
'use strict';
/**
* Update handler for transition database version 2 -> 3
*
* In database version 3, we introduced new flags to the messages, also
* the outbox uses artificial uids
*/
function updateV2(options, callback) {
var emailDbType = 'email_',
versionDbType = 'dbVersion',
postUpdateDbVersion = 3;
// 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;
});

View File

@ -23,7 +23,7 @@
</ul><!--/.mail-list--> </ul><!--/.mail-list-->
</div> </div>
<footer ng-class="{syncing: account.loggingIn || account.busy}" ng-click="synchronize()"> <footer ng-class="{syncing: account.loggingIn || account.busy}">
<span class="spinner"></span> <span class="spinner"></span>
<span class="text" ng-switch="account.online"> <span class="text" ng-switch="account.online">
<span ng-switch-when="false"> <span ng-switch-when="false">

View File

@ -29,7 +29,9 @@ define(function(require) {
describe('buildModules', function() { describe('buildModules', function() {
it('should work', function() { it('should work', function() {
controller.buildModules(); controller.buildModules({
onError: function() {}
});
expect(controller._appConfigStore).to.exist; expect(controller._appConfigStore).to.exist;
expect(controller._auth).to.exist; expect(controller._auth).to.exist;
expect(controller._userStorage).to.exist; expect(controller._userStorage).to.exist;
@ -55,13 +57,10 @@ define(function(require) {
}); });
describe('onDisconnect', function() { describe('onDisconnect', function() {
it('should work', function(done) { it('should work', function() {
emailDaoStub.onDisconnect.yields(); controller.onDisconnect();
controller.onDisconnect(function(err) { expect(emailDaoStub.onDisconnect.calledOnce).to.be.true;
expect(err).to.not.exist;
done();
});
}); });
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ define(function(require) {
mocks = require('angularMocks'), mocks = require('angularMocks'),
MailListCtrl = require('js/controller/mail-list'), MailListCtrl = require('js/controller/mail-list'),
EmailDAO = require('js/dao/email-dao'), EmailDAO = require('js/dao/email-dao'),
EmailSync = require('js/dao/email-sync'),
DeviceStorageDAO = require('js/dao/devicestorage-dao'), DeviceStorageDAO = require('js/dao/devicestorage-dao'),
KeychainDAO = require('js/dao/keychain-dao'), KeychainDAO = require('js/dao/keychain-dao'),
appController = require('js/app-controller'), appController = require('js/app-controller'),
@ -15,7 +14,7 @@ define(function(require) {
chai.Assertion.includeStack = true; chai.Assertion.includeStack = true;
describe('Mail List controller unit test', function() { describe('Mail List controller unit test', function() {
var scope, ctrl, origEmailDao, origEmailSync, emailDaoMock, emailSyncMock, keychainMock, deviceStorageMock, var scope, ctrl, origEmailDao, emailDaoMock, keychainMock, deviceStorageMock,
emailAddress, notificationClickedHandler, emails, emailAddress, notificationClickedHandler, emails,
hasChrome, hasSocket, hasRuntime, hasIdentity; hasChrome, hasSocket, hasRuntime, hasIdentity;
@ -54,11 +53,8 @@ define(function(require) {
}; };
origEmailDao = appController._emailDao; origEmailDao = appController._emailDao;
origEmailSync = appController._emailSync;
emailDaoMock = sinon.createStubInstance(EmailDAO); emailDaoMock = sinon.createStubInstance(EmailDAO);
emailSyncMock = sinon.createStubInstance(EmailSync);
appController._emailDao = emailDaoMock; appController._emailDao = emailDaoMock;
appController._emailSync = emailSyncMock;
emailAddress = 'fred@foo.com'; emailAddress = 'fred@foo.com';
emailDaoMock._account = { emailDaoMock._account = {
emailAddress: emailAddress, emailAddress: emailAddress,
@ -106,13 +102,11 @@ define(function(require) {
// restore the module // restore the module
appController._emailDao = origEmailDao; appController._emailDao = origEmailDao;
appController._emailSync = origEmailDao;
}); });
describe('scope variables', function() { describe('scope variables', function() {
it('should be set correctly', function() { it('should be set correctly', function() {
expect(scope.select).to.exist; expect(scope.select).to.exist;
expect(scope.synchronize).to.exist;
expect(scope.remove).to.exist; expect(scope.remove).to.exist;
expect(scope.state.mailList).to.exist; expect(scope.state.mailList).to.exist;
}); });
@ -142,7 +136,7 @@ define(function(require) {
done(); done();
}); });
emailSyncMock.onIncomingMessage([mail]); emailDaoMock.onIncomingMessage([mail]);
}); });
it('should succeed for multiple mails', function(done) { it('should succeed for multiple mails', function(done) {
@ -178,7 +172,7 @@ define(function(require) {
done(); done();
}); });
emailSyncMock.onIncomingMessage(mails); emailDaoMock.onIncomingMessage(mails);
}); });
it('should focus mail when clicked', function() { it('should focus mail when clicked', function() {
@ -216,33 +210,6 @@ define(function(require) {
}); });
}); });
describe('synchronize', function() {
it('should do imap sync and display mails', function(done) {
scope._stopWatchTask();
emailDaoMock.sync.yieldsAsync();
var currentFolder = {
type: 'Inbox',
messages: emails
};
scope.folders = [currentFolder];
scope.state.nav = {
currentFolder: currentFolder
};
var loadVisibleBodiesStub = sinon.stub(scope, 'loadVisibleBodies', function() {
expect(scope.state.nav.currentFolder.messages).to.deep.equal(emails);
expect(loadVisibleBodiesStub.calledOnce).to.be.true;
loadVisibleBodiesStub.restore();
done();
});
scope.synchronize();
});
});
describe('getBody', function() { describe('getBody', function() {
it('should get the mail content', function() { it('should get the mail content', function() {
scope.state.nav = { scope.state.nav = {
@ -257,13 +224,10 @@ define(function(require) {
}); });
describe('select', function() { describe('select', function() {
it('should decrypt, focus mark an unread mail as read', function() { it('should decrypt, focus, and mark an unread mail as read', function() {
var mail, synchronizeMock; var mail = {
mail = {
unread: true unread: true
}; };
synchronizeMock = sinon.stub(scope, 'synchronize');
scope.state = { scope.state = {
nav: { nav: {
currentFolder: { currentFolder: {
@ -279,19 +243,13 @@ define(function(require) {
scope.select(mail); scope.select(mail);
expect(emailDaoMock.decryptBody.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); expect(scope.state.mailList.selected).to.equal(mail);
scope.synchronize.restore();
}); });
it('should decrypt and focus a read mail', function() { it('should decrypt and focus a read mail', function() {
var mail, synchronizeMock; var mail = {
mail = {
unread: false unread: false
}; };
synchronizeMock = sinon.stub(scope, 'synchronize');
scope.state = { scope.state = {
mailList: {}, mailList: {},
read: { read: {
@ -307,54 +265,13 @@ define(function(require) {
scope.select(mail); scope.select(mail);
expect(emailDaoMock.decryptBody.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); 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();
expect(emailDaoMock.sync.called).to.be.false;
});
it('should not delete from the outbox', function(done) {
var currentFolder, mail;
scope._stopWatchTask();
scope.account = {};
mail = {
uid: 123,
from: [{
address: 'asd'
}],
subject: '[whiteout] asdasd',
unread: true
};
currentFolder = {
type: 'Outbox',
path: 'OUTBOX',
messages: [mail]
};
scope.emails = [mail];
scope.account.folders = [currentFolder];
scope.state.nav = {
currentFolder: currentFolder
};
scope.onError = function(err) {
expect(err).to.exist; // would normally display the notification
expect(emailDaoMock.sync.called).to.be.false;
done();
};
scope.remove(mail);
}); });
it('should delete the selected mail', function() { it('should delete the selected mail', function() {
@ -381,11 +298,11 @@ define(function(require) {
scope.state.nav = { scope.state.nav = {
currentFolder: currentFolder currentFolder: currentFolder
}; };
emailDaoMock.sync.yields(); emailDaoMock.deleteMessage.yields();
scope.remove(mail); scope.remove(mail);
expect(emailDaoMock.sync.calledOnce).to.be.true; expect(emailDaoMock.deleteMessage.calledOnce).to.be.true;
expect(scope.state.mailList.selected).to.not.exist; expect(scope.state.mailList.selected).to.not.exist;
}); });
}); });

View File

@ -31,7 +31,6 @@ function startTests() {
'test/new-unit/oauth-test', 'test/new-unit/oauth-test',
'test/new-unit/auth-test', 'test/new-unit/auth-test',
'test/new-unit/email-dao-test', 'test/new-unit/email-dao-test',
'test/new-unit/email-sync-test',
'test/new-unit/app-controller-test', 'test/new-unit/app-controller-test',
'test/new-unit/pgp-test', 'test/new-unit/pgp-test',
'test/new-unit/rest-dao-test', 'test/new-unit/rest-dao-test',

View File

@ -213,6 +213,59 @@ define(function(require) {
}); });
}); });
}); });
describe('v2 -> v3', function() {
var emailDbType = 'email_';
beforeEach(function() {
cfg.dbVersion = 3; // app requires database version 2
appConfigStorageStub.listItems.withArgs(versionDbType).yieldsAsync(null, [2]); // 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([3], 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();
});
});
});
}); });
}); });
}); });

View File

@ -338,7 +338,7 @@ define(function(require) {
return true; return true;
})).yields(); })).yields();
emailDaoMock.sync.yields(); emailDaoMock.setFlags.yields();
scope.onError = function(err) { scope.onError = function(err) {
expect(err).to.not.exist; expect(err).to.not.exist;
@ -347,7 +347,7 @@ define(function(require) {
scope.sendToOutbox(); scope.sendToOutbox();
expect(outboxMock.put.calledOnce).to.be.true; expect(outboxMock.put.calledOnce).to.be.true;
expect(emailDaoMock.sync.calledOnce).to.be.true; expect(emailDaoMock.setFlags.calledOnce).to.be.true;
expect(scope.state.lightbox).to.be.undefined; expect(scope.state.lightbox).to.be.undefined;
expect(scope.replyTo.answered).to.be.true; expect(scope.replyTo.answered).to.be.true;
}); });