diff --git a/appinfo/routes.php b/appinfo/routes.php index 81e9852a13..7b8f720498 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -20,54 +20,77 @@ * along with this program. If not, see * */ - $app = new \OCA\Mail\AppInfo\Application(); $app->registerRoutes($this, [ - 'routes' => [ - ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], - ['name' => 'page#compose', 'url' => '/compose', 'verb' => 'GET'], - ['name' => 'accounts#send', 'url' => '/accounts/{accountId}/send', 'verb' => 'POST'], - ['name' => 'accounts#draft', 'url' => '/accounts/{accountId}/draft', 'verb' => 'POST'], - [ - 'name' => 'messages#downloadAttachment', - 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/attachment/{attachmentId}', - 'verb' => 'GET'], - [ - 'name' => 'messages#saveAttachment', - 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/attachment/{attachmentId}', - 'verb' => 'POST'], - [ - 'name' => 'messages#getHtmlBody', - 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/html', - 'verb' => 'GET'], - [ - 'name' => 'messages#setFlags', - 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/flags', - 'verb' => 'PUT'], - [ - 'name' => 'messages#move', - 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{id}/move', - 'verb' => 'POST'], - [ - 'name' => 'proxy#redirect', - 'url' => '/redirect', - 'verb' => 'GET'], - [ - 'name' => 'proxy#proxy', - 'url' => '/proxy', - 'verb' => 'GET'], - [ - 'name' => 'folders#detectChanges', - 'url' => '/accounts/{accountId}/folders/detectChanges', - 'verb' => 'POST'], + 'routes' => [ + [ + 'name' => 'page#index', + 'url' => '/', + 'verb' => 'GET' + ], + [ + 'name' => 'page#compose', + 'url' => '/compose', + 'verb' => 'GET' + ], + [ + 'name' => 'accounts#send', + 'url' => '/accounts/{accountId}/send', + 'verb' => 'POST' + ], + [ + 'name' => 'accounts#draft', + 'url' => '/accounts/{accountId}/draft', + 'verb' => 'POST' + ], + [ + 'name' => 'folders#sync', + 'url' => '/accounts/{accountId}/folders/{folderId}/sync', + 'verb' => 'GET' + ], + [ + 'name' => 'messages#downloadAttachment', + 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/attachment/{attachmentId}', + 'verb' => 'GET' + ], + [ + 'name' => 'messages#saveAttachment', + 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/attachment/{attachmentId}', + 'verb' => 'POST' + ], + [ + 'name' => 'messages#getHtmlBody', + 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/html', + 'verb' => 'GET' + ], + [ + 'name' => 'messages#setFlags', + 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{messageId}/flags', + 'verb' => 'PUT' + ], + [ + 'name' => 'messages#move', + 'url' => '/accounts/{accountId}/folders/{folderId}/messages/{id}/move', + 'verb' => 'POST' + ], + [ + 'name' => 'proxy#redirect', + 'url' => '/redirect', + 'verb' => 'GET' + ], + [ + 'name' => 'proxy#proxy', + 'url' => '/proxy', + 'verb' => 'GET' ], - 'resources' => [ - 'autoComplete' => ['url' => '/autoComplete'], + ], + 'resources' => [ + 'autoComplete' => ['url' => '/autoComplete'], 'localAttachments' => ['url' => '/attachments'], - 'accounts' => ['url' => '/accounts'], - 'folders' => ['url' => '/accounts/{accountId}/folders'], - 'messages' => ['url' => '/accounts/{accountId}/folders/{folderId}/messages'], - 'aliases' => ['url' => '/accounts/{accountId}/aliases'], - ] - ]); + 'accounts' => ['url' => '/accounts'], + 'folders' => ['url' => '/accounts/{accountId}/folders'], + 'messages' => ['url' => '/accounts/{accountId}/folders/{folderId}/messages'], + 'aliases' => ['url' => '/accounts/{accountId}/aliases'], + ] +]); diff --git a/js/app.js b/js/app.js index 20b947f9c5..e037b9a8ab 100644 --- a/js/app.js +++ b/js/app.js @@ -43,6 +43,7 @@ define(function(require) { require('service/attachmentservice'); require('service/davservice'); require('service/folderservice'); + require('service/foldersyncservice'); require('service/messageservice'); require('service/aliasesservice'); require('util/notificationhandler'); diff --git a/js/background.js b/js/background.js deleted file mode 100644 index 2d83c2bbfb..0000000000 --- a/js/background.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Mail - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. - * - * @author Christoph Wurst - * @copyright Christoph Wurst 2015, 2016 - */ - -define(function(require) { - 'use strict'; - - var _ = require('underscore'); - var $ = require('jquery'); - var OC = require('OC'); - var Radio = require('radio'); - var State = require('state'); - var MessageCollection = require('models/messagecollection'); - - function checkForNotifications(accounts) { - accounts.each(function(account) { - var folders = account.folders; - - var url = OC.generateUrl('apps/mail/accounts/{id}/folders/detectChanges', - { - id: account.get('accountId') - }); - $.ajax(url, { - data: JSON.stringify({folders: folders.toJSON()}), - contentType: 'application/json; charset=utf-8', - dataType: 'json', - type: 'POST', - success: function(jsondata) { - _.each(jsondata, function(changes) { - // send notification - if (changes.newUnReadCounter > 0) { - Radio.notification.trigger( - 'favicon:change', - OC.filePath( - 'mail', - 'img', - 'favicon-notification.png')); - // only show one notification - if (State.accounts.length === 1 || account.get('accountId') === -1) { - Radio.ui.trigger('notification:mail:show', account.get('email'), changes); - } - } - - // update folder status - var changedAccount = accounts.get(changes.accountId); - var changedFolder = changedAccount.getFolderById(changes.id); - var localFolder = folders.get(changes.id); - localFolder.set('uidvalidity', changes.uidvalidity); - localFolder.set('uidnext', changes.uidnext); - localFolder.set('unseen', changes.unseen); - localFolder.set('total', changes.total); - - // reload if current selected folder has changed - if (State.currentAccount === changedAccount && - State.currentFolder.get('id') === changes.id) { - State.currentFolder.addMessages(changes.messages); - var messages = new MessageCollection(changes.messages).slice(0); - Radio.message.trigger('fetch:bodies', changedAccount, changedFolder, messages); - } - - Radio.ui.trigger('title:update'); - }); - } - }); - }); - } - - return { - checkForNotifications: checkForNotifications - }; -}); diff --git a/js/controller/accountcontroller.js b/js/controller/accountcontroller.js index a725818306..40e90e4572 100644 --- a/js/controller/accountcontroller.js +++ b/js/controller/accountcontroller.js @@ -15,13 +15,6 @@ define(function(require) { var FolderController = require('controller/foldercontroller'); var Radio = require('radio'); - var UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes - - function startBackgroundChecks(accounts) { - setInterval(function() { - require('background').checkForNotifications(accounts); - }, UPDATE_INTERVAL); - } /** * Load all accounts @@ -44,18 +37,15 @@ define(function(require) { })).then(function() { return accounts; }); - }).then(function(accounts) { - startBackgroundChecks(accounts); - return accounts; - }, function(e) { - console.error(e); - Radio.ui.trigger('error:show', t('mail', 'Error while loading the accounts.')); }).then(function(accounts) { // Show accounts regardless of the result of // loading the folders Radio.ui.trigger('sidebar:accounts'); return accounts; + }, function(e) { + console.error(e); + Radio.ui.trigger('error:show', t('mail', 'Error while loading the accounts.')); }); } diff --git a/js/controller/foldercontroller.js b/js/controller/foldercontroller.js index a83cbfd278..c3e8ef3270 100644 --- a/js/controller/foldercontroller.js +++ b/js/controller/foldercontroller.js @@ -26,6 +26,7 @@ define(function(require) { var ErrorMessageFactory = require('util/errormessagefactory'); Radio.message.on('fetch:bodies', fetchBodies); + Radio.folder.reply('message:delete', deleteMessage); /** * @param {Account} account @@ -76,10 +77,11 @@ define(function(require) { if (messages.length > 0) { // Fetch first 10 messages in background Radio.message.trigger('fetch:bodies', account, folder, messages.slice(0, 10)); - - Radio.message.trigger('load', account, folder, messages.first()); + var message = messages.first(); + Radio.message.trigger('load', message.folder.account, message.folder, message); } - }, function() { + }, function(error) { + console.error('error while loading messages: ', error); var icon; if (folder.get('specialRole')) { icon = 'icon-' + folder.get('specialRole'); @@ -89,7 +91,7 @@ define(function(require) { // Set the old folder as being active var oldFolder = require('state').currentFolder; Radio.folder.trigger('setactive', account, oldFolder); - }); + }).catch(console.error.bind(this)); } } @@ -105,12 +107,14 @@ define(function(require) { Radio.ui.trigger('content:loading', t('mail', 'Loading {folder}', { folder: folder.get('name') })); - loadFolderMessages(account, folder); + _.defer(function() { + loadFolderMessages(account, folder); - // Save current folder - Radio.folder.trigger('setactive', account, folder); - require('state').currentAccount = account; - require('state').currentFolder = folder; + // Save current folder + Radio.folder.trigger('setactive', account, folder); + require('state').currentAccount = account; + require('state').currentFolder = folder; + }); } /** @@ -127,7 +131,9 @@ define(function(require) { Radio.ui.trigger('content:loading', t('mail', 'Searching for {query}', { query: query })); - loadFolderMessagesDebounced(account, folder, query); + _.defer(function() { + loadFolderMessagesDebounced(account, folder, query); + }); } /** @@ -147,9 +153,103 @@ define(function(require) { }); Radio.message.request('bodies', account, folder, ids). then(function(messages) { - require('cache').addMessages(account, folder, messages); - }, console.error.bind(this)); + require('cache').addMessages(account, folder, messages); + }, console.error.bind(this)); + } + } + + /** + * @param {Folder} folder + * @param {Folder} currentFolder + * @returns {Array} array of two folders, the first one is the individual + */ + function getSpecificAndUnifiedFolder(folder, currentFolder) { + // Case 1: we're currently in a unified folder + if (currentFolder.account.get('accountId') === -1) { + return [folder, currentFolder]; + } + + // Locate unified folder if existent + var unifiedAccount = require('state').accounts.get(-1); + var unifiedFolder = unifiedAccount ? unifiedAccount.folders.first() : null; + + // Case 2: we're in a specific folder and a unified one is available too + if (currentFolder.get('specialRole') === 'inbox' && unifiedFolder) { + return [folder, unifiedFolder]; + } + + // Case 3: we're in a specific folder, but there's no unified one + return [folder, null]; + } + + /** + * Call supplied function with folder as first parameter, if + * the folder is not undefined + * + * @param {Array} folders + * @param {Function} fn + * @returns {mixed} + */ + function applyOnFolders(folders, fn) { + folders.forEach(function(folder) { + if (!folder) { + // ignore + return; + } + + return fn(folder); + }); + } + + /** + * @param {Message} message + * @param {Folder} currentFolder + * @returns {Promise} + */ + function deleteMessage(message, currentFolder) { + var folders = getSpecificAndUnifiedFolder(message.folder, currentFolder); + + applyOnFolders(folders, function(folder) { + // Update total counter and prevent negative values + folder.set('total', Math.max(0, folder.get('total'))); + }); + + var searchCollection = currentFolder.messages; + var index = searchCollection.indexOf(message); + // Select previous or first + if (index === 0) { + index = 1; + } else { + index = index - 1; } + var nextMessage = searchCollection.at(index); + + // Remove message + applyOnFolders(folders, function(folder) { + folder.messages.remove(message); + }); + + if (require('state').currentMessage && require('state').currentMessage.get('id') === message.id) { + if (nextMessage) { + Radio.message.trigger('load', message.folder.account, message.folder, nextMessage); + } + } + + return Radio.message.request('delete', message) + .catch(function(err) { + console.error(err); + + Radio.ui.trigger('error:show', t('mail', 'Error while deleting message.')); + + applyOnFolders(folders, function(folder) { + // Restore counter + + folder.set('total', folder.previousAttributes.total); + + // Add the message to the collection again + folder.addMessage(message); + }); + }); } return { diff --git a/js/controller/messagecontroller.js b/js/controller/messagecontroller.js index 33e7cb1199..4871127548 100644 --- a/js/controller/messagecontroller.js +++ b/js/controller/messagecontroller.js @@ -26,21 +26,19 @@ define(function(require) { var Radio = require('radio'); var ErrorMessageFactory = require('util/errormessagefactory'); - Radio.message.on('load', function(account, folder, message, options) { - //FIXME: don't rely on global state vars - load(account, message, options); - }); + Radio.message.on('load', load); Radio.message.on('forward', openForwardComposer); Radio.message.on('flag', flagMessage); Radio.message.on('move', moveMessage); /** * @param {Account} account + * @param {Folder} folder * @param {Message} message * @param {object} options * @returns {undefined} */ - function load(account, message, options) { + function load(account, folder, message, options) { options = options || {}; var defaultOptions = { force: false @@ -81,18 +79,13 @@ define(function(require) { // Fade out the message composer $('#mail_new_message').prop('disabled', false); - Radio.message.request('entity', - require('state').currentAccount, - require('state').currentFolder, - message.get('id')).then(function(message) { + Radio.message.request('entity', account, folder, message.get('id')).then(function(messageBody) { if (draft) { - Radio.ui.trigger('composer:show', message); + Radio.ui.trigger('composer:show', messageBody); } else { // TODO: ideally this should be handled in messageservice.js - require('cache').addMessage(require('state').currentAccount, - require('state').currentFolder, - message); - Radio.ui.trigger('message:show', message); + require('cache').addMessage(account, folder, messageBody); + Radio.ui.trigger('message:show', message, messageBody); } }, function() { Radio.ui.trigger('message:error', ErrorMessageFactory.getRandomMessageErrorMessage()); @@ -154,7 +147,9 @@ define(function(require) { }); } - function flagMessage(account, folder, message, flag, value) { + function flagMessage(message, flag, value) { + var folder = message.folder; + var account = folder.account; var prevUnseen = folder.get('unseen'); if (message.get('flags').get(flag) === value) { diff --git a/js/models/account.js b/js/models/account.js index 9268fdf9d7..5a93627cb9 100644 --- a/js/models/account.js +++ b/js/models/account.js @@ -23,7 +23,8 @@ define(function(require) { var Account = Backbone.Model.extend({ defaults: { aliases: [], - specialFolders: [] + specialFolders: [], + isUnified: false }, idAttribute: 'accountId', url: function() { diff --git a/js/models/folder.js b/js/models/folder.js index e501511c8c..c6e10de347 100644 --- a/js/models/folder.js +++ b/js/models/folder.js @@ -27,9 +27,11 @@ define(function(require) { folders: [], messagesLoaded: false }, + initialize: function() { var FolderCollection = require('models/foldercollection'); var MessageCollection = require('models/messagecollection'); + var UnifiedMessageCollection = require('models/unifiedmessagecollection'); this.account = this.get('account'); this.unset('account'); this.folders = new FolderCollection(this.get('folders') || []); @@ -37,37 +39,52 @@ define(function(require) { folder.account = this.account; }, this)); this.unset('folders'); - this.messages = new MessageCollection(); + if (this.account && this.account.get('isUnified') === true) { + this.messages = new UnifiedMessageCollection(); + } else { + this.messages = new MessageCollection(); + } }, + toggleOpen: function() { this.set({open: !this.get('open')}); }, + /** * @param {Message} message * @returns {undefined} */ addMessage: function(message) { - message.folder = this; - this.messages.add(message); + if (this.account.id !== -1) { + // Non-unified folder messages should keep their source folder + message.folder = this; + } + message = this.messages.add(message); + if (this.account.id === -1) { + message.set('unifiedId', this.messages.getUnifiedId(message)); + } + return message; }, + /** - * @param {Array} message + * @param {Array} messages * @returns {undefined} */ addMessages: function(messages) { var _this = this; - _.each(messages, function(message) { - _this.addMessage(message); - }); + return _.map(messages, _this.addMessage, this); }, + /** * @param {Folder} folder * @returns {undefined} */ + addFolder: function(folder) { folder = this.folders.add(folder); folder.account = this.account; }, + toJSON: function() { var data = Backbone.Model.prototype.toJSON.call(this); if (!data.id) { diff --git a/js/models/unifiedmessagecollection.js b/js/models/unifiedmessagecollection.js new file mode 100644 index 0000000000..6f19d9c8b6 --- /dev/null +++ b/js/models/unifiedmessagecollection.js @@ -0,0 +1,32 @@ +/** + * Mail + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Christoph Wurst + * @copyright Christoph Wurst 2017 + */ + +define(function(require) { + 'use strict'; + + var MessageCollection = require('models/messagecollection'); + + /** + * @class UnifiedMessageCollection + */ + var UnifiedMessageCollection = MessageCollection.extend({ + + modelId: function(attrs) { + return attrs.unifiedId; + }, + + getUnifiedId: function(message) { + return message.id + '-' + message.folder.id + '-' + message.folder.account.id; + } + + }); + + return UnifiedMessageCollection; +}); diff --git a/js/radio.js b/js/radio.js index 00733dc7de..c38151fa3e 100644 --- a/js/radio.js +++ b/js/radio.js @@ -31,7 +31,8 @@ define(function(require) { var channels = {}; _.each(channelNames, function(channelName) { channels[channelName] = Radio.channel(channelName); - Radio.tuneIn(channelName); + // Uncomment the following line for debugging + // Radio.tuneIn(channelName); }); return channels; diff --git a/js/service/accountservice.js b/js/service/accountservice.js index fcd051bc20..46daeba26a 100644 --- a/js/service/accountservice.js +++ b/js/service/accountservice.js @@ -22,18 +22,17 @@ define(function(require) { Radio.account.reply('delete', deleteAccount); function createAccount(config) { + var url = OC.generateUrl('apps/mail/accounts'); return new Promise(function(resolve, reject) { - $.ajax(OC.generateUrl('apps/mail/accounts'), { + $.ajax(url, { data: config, type: 'POST', - success: function() { - resolve(); - }, + success: resolve, error: function(jqXHR, textStatus, errorThrown) { switch (jqXHR.status) { case 400: var response = JSON.parse(jqXHR.responseText); - reject(response.message); + reject(t('mail', 'Error while creating the account: ' + response.message)); break; default: var error = errorThrown || textStatus || t('mail', 'Unknown error'); @@ -77,13 +76,17 @@ define(function(require) { }); } + /** + * @returns {Promise} + */ function getAccountEntities() { return loadAccountData().then(function(accounts) { require('cache').cleanUp(accounts); if (accounts.length > 1) { accounts.add({ - accountId: -1 + accountId: -1, + isUnified: true }, { at: 0 }); diff --git a/js/service/foldersyncservice.js b/js/service/foldersyncservice.js new file mode 100644 index 0000000000..927c73f705 --- /dev/null +++ b/js/service/foldersyncservice.js @@ -0,0 +1,129 @@ +/* global Promise */ + +/** + * @author Christoph Wurst + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +define(function(require) { + 'use strict'; + + var _ = require('underscore'); + var $ = require('jquery'); + var OC = require('OC'); + var Radio = require('radio'); + + Radio.message.reply('sync', syncFolder); + + /** + * @private + * @param {Folder} folder + * @returns {Promise} + */ + function syncSingleFolder(folder, unifiedFolder) { + var url = OC.generateUrl('/apps/mail/accounts/{accountId}/folders/{folderId}/sync', { + accountId: folder.account.get('accountId'), + folderId: folder.get('id') + }); + + return Promise.resolve($.ajax(url, { + data: { + syncToken: folder.get('syncToken'), + uids: folder.messages.pluck('id') + } + })).then(function(syncResp) { + folder.set('syncToken', syncResp.token); + + var newMessages = folder.addMessages(syncResp.newMessages); + if (unifiedFolder) { + unifiedFolder.addMessages(newMessages); + } + _.each(syncResp.changedMessages, function(msg) { + var existing = folder.messages.get(msg.id); + if (existing) { + var flags = {}; + if (msg.flags && _.isObject(msg.flags)) { + flags = msg.flags; + delete msg.flags; + } + existing.set(msg); + existing.get('flags').set(flags); + } else { + // TODO: remove once we're confident this + // condition never occurs + throw new Error('non-existing message while syncing'); + } + + if (unifiedFolder) { + var id = unifiedFolder.messages.getUnifiedId(folder.messages.get(msg.id)); + var message = unifiedFolder.messages.get(id); + if (!message) { + console.info('Changed message missing in unified inbox'); + } else { + message.set(msg); + } + } + }); + _.each(syncResp.vanishedMessages, function(id) { + if (unifiedFolder) { + var unifiedInboxId = unifiedFolder.messages.getUnifiedId(folder.messages.get(id)); + unifiedFolder.messages.remove(unifiedInboxId); + } + + folder.messages.remove(id); + }); + }); + } + + /** + * @param {Folder} folder + * @returns {Promise} + */ + function syncFolder(folder) { + var allAccounts = require('state').accounts; + + if (folder.account.get('isUnified')) { + var unifiedFolder = folder; + // Sync other accounts + return Promise.all(allAccounts.filter(function(acc) { + // Select other accounts + return acc.id !== folder.account.id; + }).map(function(acc) { + // Select its inboxes + return acc.folders.filter(function(f) { + return f.get('specialRole') === 'inbox'; + }); + }).reduce(function(acc, f) { + // Flatten nested array + return acc.concat(f); + }, []).map(function(folder) { + return syncSingleFolder(folder, unifiedFolder); + })); + } else { + var unifiedAccount = allAccounts.get(-1); + if (unifiedAccount) { + var unifiedFolder = unifiedAccount.folders.first(); + return syncSingleFolder(folder, unifiedFolder); + } + return syncSingleFolder(folder); + } + } + + return { + syncFolder: syncFolder + }; +}); diff --git a/js/service/messageservice.js b/js/service/messageservice.js index 4945d649cb..781ca943ab 100644 --- a/js/service/messageservice.js +++ b/js/service/messageservice.js @@ -1,4 +1,4 @@ -/* global Promise */ +/* global Promise, Infinity */ /** * @author Christoph Wurst @@ -28,6 +28,7 @@ define(function(require) { var Radio = require('radio'); Radio.message.reply('entities', getMessageEntities); + Radio.message.reply('next-page', getNextMessagePage); Radio.message.reply('entity', getMessageEntity); Radio.message.reply('bodies', fetchMessageBodies); Radio.message.reply('flag', flagMessage); @@ -36,18 +37,9 @@ define(function(require) { Radio.message.reply('draft', saveDraft); Radio.message.reply('delete', deleteMessage); - /** - * @param {Account} account - * @param {Folder} folder - * @param {object} options - * @returns {Promise} - */ - function getMessageEntities(account, folder, options) { - options = options || {}; + function getFolderMessages(folder, options) { var defaults = { cache: false, - replace: false, // Replace cached folder list - force: false, filter: '' }; _.defaults(options, defaults); @@ -56,41 +48,194 @@ define(function(require) { if (options.filter !== '') { options.cache = false; } + if (options.cache && folder.get('messagesLoaded')) { + return Promise.resolve(folder.messages, true); + } - return new Promise(function(resolve, reject) { - if (options.cache && folder.get('messagesLoaded')) { - resolve(folder.messages, true); - return; + var url = OC.generateUrl('apps/mail/accounts/{accountId}/folders/{folderId}/messages', { + accountId: folder.account.get('accountId'), + folderId: folder.get('id') + }); + + return Promise.resolve($.ajax(url, { + data: { + filter: options.filter + }, + error: function(error, status) { + if (status !== 'abort') { + console.error('error loading messages', error); + throw new Error(error); + } } + })).then(function(messages) { + var isSearching = options.filter !== ''; + var collection = folder.messages; + + if (isSearching) { + // Get rid of other messages + collection.reset(); + folder.set('messagesLoaded', false); + } else { + folder.set('messagesLoaded', true); + } + + _.forEach(messages, function(msg) { + msg.accountMail = folder.account.get('email'); + }); + folder.addMessages(messages); + + return collection; + }); + } + + function getUnifiedFolderMessages(folder, options) { + var allAccounts = require('state').accounts; + // Fetch and merge other accounts + return Promise.all(allAccounts.filter(function(acc) { + // Select other accounts + return acc.id !== folder.account.id; + }).map(function(acc) { + // Select its inboxes + return acc.folders.filter(function(f) { + return f.get('specialRole') === 'inbox'; + }); + }).reduce(function(acc, f) { + // Flatten nested array + return acc.concat(f); + }, []).map(function(otherInbox) { + return getFolderMessages(otherInbox, options) + .then(function(messages) { + folder.addMessages(messages.models); + }); + })).then(function() { + // Truncate after 20 messages + // TODO: there might be a more efficient/convenient + // Backbone.Collection or underscore helper function + var top20 = folder.messages.slice(0, 20); + folder.messages.reset(); + folder.addMessages(top20); + return folder.messages; + }); + } + + /** + * @param {Account} account + * @param {Folder} folder + * @param {object} options + * @returns {Promise} + */ + function getMessageEntities(account, folder, options) { + options = options || {}; + + if (account.get('isUnified')) { + return getUnifiedFolderMessages(folder, options); + } else { + return getFolderMessages(folder, options); + } + } + + function getNextUnifiedMessagePage(unifiedFolder, options) { + var allAccounts = require('state').accounts; + var cursor = Infinity; + if (!unifiedFolder.messages.isEmpty()) { + cursor = unifiedFolder.messages.last().get('dateInt'); + } + + var individualAccounts = allAccounts.filter(function(account) { + // Only non-unified accounts + return !account.get('isUnified'); + }); + // Load data from folders where we do not have enough data + return Promise.all(individualAccounts.map(function(account) { + return Promise.all(account.folders.filter(function(folder) { + // Only consider inboxes + // TODO: generalize for other combined mailboxes + return folder.get('specialRole') === 'inbox'; + }).filter(function(folder) { + // Only fetch mailboxes that do not have enough data + return folder.messages.filter(function(message) { + return message.get('dateInt') < cursor; + }).length < 21; + }).map(function(folder) { + return getNextMessagePage(folder.account, folder, options); + })); + })).then(function() { + var allMessagesPage = individualAccounts.map(function(account) { + return account.folders.filter(function(folder) { + // Only consider inboxes + // TODO: generalize for other combined mailboxes + return folder.get('specialRole') === 'inbox'; + }).map(function(folder) { + var messages = folder.messages.filter(function(message) { + return message.get('dateInt') < cursor; + }); + // Take all but the last message (acts as cursor) + return messages.slice(0, messages.length - 2); + }).reduce(function(all, messages) { + return all.concat(messages); + }, []); + }).reduce(function(all, messages) { + return all.concat(messages); + }, []); + + var nextPage = allMessagesPage.sort(function(message) { + return message.get('dateInt') * -1; + }).slice(0, 20); + + nextPage.forEach(function(msg) { + msg.set('unifiedId', unifiedFolder.messages.getUnifiedId(msg)); + }); + + unifiedFolder.addMessages(nextPage, unifiedFolder); + }); + } + + /** + * @param {Account} account + * @param {Folder} folder + * @param {object} options + * @returns {Promise} + */ + function getNextMessagePage(account, folder, options) { + options = options || {}; + var defaults = { + filter: '' + }; + _.defaults(options, defaults); + + if (account.get('isUnified')) { + return getNextUnifiedMessagePage(folder, options); + } else { var url = OC.generateUrl('apps/mail/accounts/{accountId}/folders/{folderId}/messages', { accountId: account.get('accountId'), folderId: folder.get('id') }); + var cursor = null; + if (!folder.messages.isEmpty()) { + cursor = folder.messages.last().get('dateInt'); + } - // TODO: folder.messages.fetch() - return Promise.resolve($.ajax(url, { - data: { - from: options.from, - to: options.to, - filter: options.filter - }, - success: function(messages) { - var collection = folder.messages; - if (options.replace) { - collection.reset(); + return new Promise(function(resolve, reject) { + $.ajax(url, { + method: 'GET', + data: { + filter: options.filter, + cursor: cursor + }, + success: resolve, + error: function(error, status) { + if (status !== 'abort') { + reject(error); + } } - folder.addMessages(messages); - folder.set('messagesLoaded', true); - resolve(collection, false); - }, - error: function(error, status) { - if (status !== 'abort') { - reject(error); - } - } - })); - }); + }); + }).then(function(messages) { + var collection = folder.messages; + folder.addMessages(messages); + return collection; + }); + } } /** @@ -102,34 +247,29 @@ define(function(require) { */ function getMessageEntity(account, folder, messageId, options) { options = options || {}; - var defaults = { - backgroundMode: false - }; - _.defaults(options, defaults); + var url = OC.generateUrl('apps/mail/accounts/{accountId}/folders/{folderId}/messages/{messageId}', { accountId: account.get('accountId'), folderId: folder.get('id'), messageId: messageId }); - return new Promise(function(resolve, reject) { - // Load cached version if available - var message = require('cache').getMessage(account, - folder, - messageId); - if (message) { - resolve(message); - return; - } + // Load cached version if available + var message = require('cache').getMessage(account, + folder, + messageId); + if (message) { + return Promise.resolve(message); + } + return new Promise(function(resolve, reject) { $.ajax(url, { type: 'GET', - success: function(message) { - resolve(message); - }, + success: resolve, error: function(jqXHR, textStatus) { + console.error('error loading message', jqXHR); if (textStatus !== 'abort') { - reject(); + reject(jqXHR); } } }); @@ -317,19 +457,21 @@ define(function(require) { } /** - * @param {Account} account - * @param {Folder} folder * @param {Message} message * @returns {Promise} */ - function deleteMessage(account, folder, message) { + function deleteMessage(message) { var url = OC.generateUrl('apps/mail/accounts/{accountId}/folders/{folderId}/messages/{messageId}', { - accountId: account.get('accountId'), - folderId: folder.get('id'), + accountId: message.folder.account.get('accountId'), + folderId: message.folder.get('id'), messageId: message.get('id') }); return Promise.resolve($.ajax(url, { type: 'DELETE' })); } + + return { + getNextMessagePage: getNextMessagePage + }; }); diff --git a/js/templates/message-list-item.html b/js/templates/message-list-item.html index 06727c3670..63f9c7d561 100644 --- a/js/templates/message-list-item.html +++ b/js/templates/message-list-item.html @@ -1,5 +1,5 @@
- {{#if accountMail}} + {{#if isUnified}} {{/if}}
diff --git a/js/tests/models/account_spec.js b/js/tests/models/account_spec.js index cee8d5ede5..87b511f023 100644 --- a/js/tests/models/account_spec.js +++ b/js/tests/models/account_spec.js @@ -1,3 +1,5 @@ +/* global spyOn */ + /** * @author Christoph Wurst * diff --git a/js/tests/models/folder_spec.js b/js/tests/models/folder_spec.js index 79b5d7381e..f700a9d595 100644 --- a/js/tests/models/folder_spec.js +++ b/js/tests/models/folder_spec.js @@ -1,3 +1,5 @@ +/* global expect */ + /** * @author Christoph Wurst * @@ -19,14 +21,18 @@ define([ 'models/folder', + 'models/account', 'models/messagecollection', 'models/message' -], function(Folder, MessageCollection, Message) { +], function(Folder, Account, MessageCollection, Message) { describe('Folder', function() { + var account; var folder; beforeEach(function() { + account = new Account(); folder = new Folder(); + account.addFolder(folder); }); it('has messages', function() { diff --git a/js/tests/service/foldersyncservice_spec.js b/js/tests/service/foldersyncservice_spec.js new file mode 100644 index 0000000000..f4606d577a --- /dev/null +++ b/js/tests/service/foldersyncservice_spec.js @@ -0,0 +1,496 @@ +/* global sinon, expect */ + +/** + * @author Christoph Wurst + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +define([ + 'service/foldersyncservice', + 'models/account', + 'models/accountcollection', + 'models/folder', + 'models/message', + 'state' +], function(FolderSyncService, Account, AccountCollection, Folder, Message, + State) { + + describe('FolderSyncService', function() { + var server; + + beforeEach(function() { + State.accounts = new AccountCollection(); + server = sinon.fakeServer.create(); + }); + + afterEach(function() { + State.accounts = new AccountCollection(); + server.restore(); + }); + + it('syncs the sync token of a single folder', function(done) { + var account = new Account({ + accountId: 15 + }); + var folder = new Folder({ + id: 'SU5CT1g=', + syncToken: 'oldToken', + account: account + }); + folder.addMessage(new Message({ + id: 123 + })); + folder.addMessage(new Message({ + id: 124 + })); + + var syncing = FolderSyncService.syncFolder(folder); + + expect(server.requests.length).toBe(1); + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [ + { + id: 125 + } + ], + changedMessages: [], + vanishedMessages: [] + }) + ); + + syncing.then(function() { + expect(folder.messages.pluck('id')).toEqual([123, 124, 125]); + done(); + }).catch(function(e) { + console.error(e); + done.fail(e); + }); + }); + + it('syncs new messages in a single folder', function(done) { + var account = new Account({ + accountId: 15 + }); + var folder = new Folder({ + id: 'SU5CT1g=', + syncToken: 'oldToken', + account: account + }); + folder.addMessage(new Message({ + id: 123 + })); + folder.addMessage(new Message({ + id: 124 + })); + + var syncing = FolderSyncService.syncFolder(folder); + + expect(server.requests.length).toBe(1); + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [ + { + id: 125 + } + ], + changedMessages: [], + vanishedMessages: [] + }) + ); + + syncing.then(function() { + expect(folder.messages.pluck('id')).toEqual([123, 124, 125]); + done(); + }).catch(function(e) { + console.error(e); + done.fail(e); + }); + }); + + it('syncs changed messages in a single folder', function(done) { + var account = new Account({ + accountId: 15 + }); + var folder = new Folder({ + id: 'SU5CT1g=', + syncToken: 'oldToken', + account: account + }); + folder.addMessage(new Message({ + id: 123, + subject: 'old subject' + })); + folder.addMessage(new Message({ + id: 124 + })); + + var syncing = FolderSyncService.syncFolder(folder); + + expect(server.requests.length).toBe(1); + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [ + { + id: 123, + subject: 'new subject' + } + ], + vanishedMessages: [] + }) + ); + + syncing.then(function() { + expect(folder.messages.get(123). + get('subject')).toEqual('new subject'); + done(); + }).catch(function(e) { + console.error(e); + done.fail(e); + }); + }); + + it('syncs vanished messages in a single folder', function(done) { + var account = new Account({ + accountId: 15 + }); + var folder = new Folder({ + id: 'SU5CT1g=', + syncToken: 'oldToken', + account: account + }); + folder.addMessage(new Message({ + id: 123, + subject: 'old subject' + })); + folder.addMessage(new Message({ + id: 124 + })); + + var syncing = FolderSyncService.syncFolder(folder); + + expect(server.requests.length).toBe(1); + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [], + vanishedMessages: [ + 123 + ] + }) + ); + + syncing.then(function() { + expect(folder.messages.pluck('id')).toEqual([124]); + done(); + }).catch(function(e) { + console.error(e); + done.fail(e); + }); + }); + + it('syncs the unified inbox, even if no accounts are configured', function( + done) { + var account = new Account({ + accountId: -1, + isUnified: true + }); + var folder = new Folder({ + account: account + }); + + var syncing = FolderSyncService.syncFolder(folder); + expect(server.requests.length).toBe(0); + + syncing.then(done).catch(done.fail); + }); + + describe('unified inbox with two accounts and three inboxes', function() { + var account, acc1, acc2; + var folder, folder11, folder12, folder21, folder22; + + beforeEach(function() { + account = new Account({ + accountId: -1, + isUnified: true + }); + folder = new Folder({ + account: account + }); + acc1 = new Account({ + accountId: 1 + }); + folder11 = new Folder({ + id: 'inbox11', + specialRole: 'inbox' + }); + folder12 = new Folder({ + specialRole: 'sent' + }); + acc2 = new Account({ + accountId: 2 + }); + folder21 = new Folder({ + id: 'inbox21', + specialRole: 'inbox' + }); + folder22 = new Folder({ + id: 'inbox22', + specialRole: 'inbox' + }); + account.addFolder(folder); + acc1.addFolder(folder11); + acc1.addFolder(folder12); + acc2.addFolder(folder21); + acc2.addFolder(folder22); + State.accounts.add(account); + State.accounts.add(acc1); + State.accounts.add(acc2); + }); + + it('syncs all changes', function(done) { + // Add some messages + var message211 = new Message({ + id: 234, + subject: 'old sub', + account: acc2 + }); + folder21.addMessage(message211); + folder.addMessage(folder21.messages.get(234)); + var message221 = new Message({ + id: 345, + account: acc2 + }); + folder22.addMessage(message221); + folder.addMessage(folder22.messages.get(345)); + + var syncing = FolderSyncService.syncFolder(folder); + expect(server.requests.length).toBe(3); + + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [ + { + id: 123 + } + ], + changedMessages: [], + vanishedMessages: [] + }) + ); + server.requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [ + { + id: 234, + subject: 'new sub' + } + ], + vanishedMessages: [] + }) + ); + server.requests[2].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [], + vanishedMessages: [ + 345 + ] + }) + ); + + syncing.then(function() { + // New message saved to first inbox + expect(folder11.messages.pluck('id')).toEqual([123]); + // Update applied in second inbox + expect(folder21.messages.get(234).get('subject')).toEqual('new sub'); + // Vanished message in third inbox is removed + expect(folder22.messages.pluck('id')).toEqual([]); + + done(); + }).catch(done.fail); + }); + + it('removes vanished messages', function(done) { + // Add some messages + var message211 = new Message({ + id: 234, + subject: 'Message 1', + account: acc2 + }); + folder21.addMessage(message211); + folder.addMessage(message211); + + // Check initial state + expect(folder21.messages.pluck('id')).toEqual([234]); + expect(folder.messages.pluck('id')).toEqual([234]); + + var syncing = FolderSyncService.syncFolder(folder); + expect(server.requests.length).toBe(3); + + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [], + vanishedMessages: [] + })); + server.requests[1].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [], + vanishedMessages: [ + 234 + ] + })); + server.requests[2].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [], + changedMessages: [], + vanishedMessages: [] + })); + + syncing.then(function() { + // Vanished message in third inbox is removed + expect(folder22.messages.pluck('id')).toEqual([]); + + // Message was also removed from the unified folder + expect(folder.messages.pluck('id')).toEqual([]); + + done(); + }).catch(done.fail); + }); + }); + + it('syncs the unified inbox when an individual one changes', function(done) { + var account = new Account({ + accountId: -1, + isUnified: true + }); + var folder = new Folder({ + account: account + }); + var acc1 = new Account({ + accountId: 1 + }); + var folder11 = new Folder({ + specialRole: 'inbox' + }); + var folder12 = new Folder({ + specialRole: 'sent' + }); + var acc2 = new Account({ + accountId: 2 + }); + var folder21 = new Folder({ + specialRole: 'inbox' + }); + var folder22 = new Folder({ + specialRole: 'inbox' + }); + account.addFolder(folder); + acc1.addFolder(folder11); + acc1.addFolder(folder12); + acc2.addFolder(folder21); + acc2.addFolder(folder22); + State.accounts.add(account); + State.accounts.add(acc1); + State.accounts.add(acc2); + + var syncing = FolderSyncService.syncFolder(folder11); + expect(server.requests.length).toBe(1); + + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify({ + token: 'newToken', + newMessages: [ + { + id: 123 + } + ], + changedMessages: [], + vanishedMessages: [] + }) + ); + + syncing.then(function() { + // New message saved to first inbox + expect(folder11.messages.pluck('id')).toEqual([123]); + // Unified inbox was updated too + expect(folder.messages.pluck('id')).toEqual([123]); + + done(); + }).catch(done.fail); + }); + }); + +}); \ No newline at end of file diff --git a/js/tests/service/messageservice_spec.js b/js/tests/service/messageservice_spec.js new file mode 100644 index 0000000000..ee6b2897fd --- /dev/null +++ b/js/tests/service/messageservice_spec.js @@ -0,0 +1,198 @@ +/* global sinon */ + +/** + * @author Christoph Wurst + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +define([ + 'underscore', + 'service/messageservice', + 'models/account', + 'models/accountcollection', + 'models/folder', + 'models/message', + 'state' +], function(_, MessageService, Account, AccountCollection, Folder, Message, + State) { + 'use strict'; + + describe('MessageService', function() { + var server; + + beforeEach(function() { + State.accounts = new AccountCollection(); + server = sinon.fakeServer.create(); + }); + + afterEach(function() { + State.accounts = new AccountCollection(); + server.restore(); + }); + + function getTestAccounts(nrAccounts) { + var unifiedAccount = new Account({ + accountId: -1, + isUnified: true + }); + var unifiedInbox = new Folder({ + id: 'inbox', + specialRole: 'inbox', + account: unifiedAccount + }); + unifiedAccount.addFolder(unifiedInbox); + State.accounts.add(unifiedAccount); + + return _.range(1, nrAccounts + 1).map(function(id) { + var account = new Account({ + accountId: id + }); + var inbox = new Folder({ + id: 'inbox', + specialRole: 'inbox', + account: account + }); + var otherFolder = new Folder({ + id: 'something', + account: account + }); + account.addFolder(inbox); + account.addFolder(otherFolder); + State.accounts.add(account); + return account; + }); + } + + function createMessages(count) { + return _.range(count).map(function(id) { + return new Message({ + id: id * 100, + dateInt: id * 1000 + }); + }); + } + + it('fetches the next page of an individual folder', function(done) { + var account = new Account(); + var folder = new Folder({ + id: 'XYZ' + }); + account.addFolder(folder); + var fetching = MessageService.getNextMessagePage(account, folder); + + expect(server.requests.length).toBe(1); + + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify([ + { + id: 123, + subject: 'hello' + } + ]) + ); + + expect(server.requests.length).toBe(1); + + fetching.then(function(messages) { + expect(messages.length).toBe(1); + done(); + }).catch(done.fail); + }); + + it('propagates fetch errors', function(done) { + var account = new Account(); + var folder = new Folder({ + id: 'XYZ', + account: account + }); + account.addFolder(folder); + var fetching = MessageService.getNextMessagePage(account, folder); + + expect(server.requests.length).toBe(1); + + server.requests[0].respond( + 500, + { + 'Content-Type': 'application/json' + }); + + fetching.then(done.catch).catch(done); + }); + + it('loads unified inbox page and uses local data if enough data is available', function( + done) { + var testAccounts = getTestAccounts(2); + var unifiedAccount = State.accounts.get(-1); + var unifiedInbox = unifiedAccount.folders.first(); + var inbox1 = testAccounts[0].folders.first(); + var inbox2 = testAccounts[1].folders.first(); + + inbox1.addMessages(createMessages(25)); + inbox2.addMessages(createMessages(25)); + + var fetching = MessageService.getNextMessagePage(unifiedAccount, unifiedInbox); + + expect(server.requests.length).toBe(0); + + fetching.then(function() { + expect(unifiedInbox.messages.length).toBe(20); + done(); + }).catch(done.fail); + }); + + it('loads unified inbox page and fetches pages where necessary', function( + done) { + var testAccounts = getTestAccounts(2); + var unifiedAccount = State.accounts.get(-1); + var unifiedInbox = unifiedAccount.folders.first(); + var inbox1 = testAccounts[0].folders.first(); + var inbox2 = testAccounts[1].folders.first(); + + inbox1.addMessages(createMessages(25)); + inbox2.addMessages(createMessages(5)); + + var fetching = MessageService.getNextMessagePage(unifiedAccount, unifiedInbox); + + expect(server.requests.length).toBe(1); + + server.requests[0].respond( + 200, + { + 'Content-Type': 'application/json' + }, + JSON.stringify([ + { + id: 123, + subject: 'hello', + dateInt: 26000 + } + ]) + ); + + fetching.then(function() { + expect(unifiedInbox.messages.length).toBe(20); + done(); + }).catch(done.fail); + }); + + }); + +}); \ No newline at end of file diff --git a/js/tests/service_accountservice_spec.js b/js/tests/service_accountservice_spec.js index 50cb6693c6..a0032ff413 100644 --- a/js/tests/service_accountservice_spec.js +++ b/js/tests/service_accountservice_spec.js @@ -20,8 +20,7 @@ define([ 'service/accountservice', 'OC', - 'radio', -], function(AccountService, OC, Radio) { +], function(AccountService, OC) { describe('AccountService', function() { @@ -54,18 +53,17 @@ define([ }); }); - it('handle account creation errors correctly', function() { + it('handle account creation errors correctly', function(done) { spyOn(OC, 'generateUrl').and.returnValue('index.php/apps/mail/accounts'); - var promise = AccountService.createAccount({ + var creating = AccountService.createAccount({ email: 'email@example.com', password: '12345' }); expect(OC.generateUrl).toHaveBeenCalledWith('apps/mail/accounts'); - expect(jasmine.Ajax.requests.count()) - .toBe(1); + expect(jasmine.Ajax.requests.count()).toBe(1); expect(jasmine.Ajax.requests.mostRecent().url) .toBe('index.php/apps/mail/accounts'); jasmine.Ajax.requests.mostRecent().respondWith({ @@ -73,6 +71,8 @@ define([ 'contentType': 'application/json', 'responseText': '{}' }); + + creating.catch(done); }); }); }); diff --git a/js/tests/test-main.js b/js/tests/test-main.js index e46e860151..8d62b88897 100644 --- a/js/tests/test-main.js +++ b/js/tests/test-main.js @@ -19,7 +19,7 @@ window.t = function(app, text) { throw 'wrong app used to for translation'; } return text; -} +}; OC = { @@ -28,8 +28,15 @@ OC = { } }, - generateUrl: function(url) { - return url; + generateUrl: function(url, params) { + var props = []; + for (var prop in params) { + props.push(prop); + } + return '/base/' + props.reduce(function(url, paramName) { + var param = params[paramName]; + return url.replace('{' + paramName + '}', param); + }, url); }, linkToRemote: function() { @@ -61,6 +68,14 @@ $.fn.droppable = function() { }; +formatDate = function(arg) { + return arg; +}; + +relative_modified_date = function(arg) { + return arg; +}; + require.config({ // Karma serves files under /base, which is the basePath from your config file baseUrl: '/base/js', diff --git a/js/tests/views/foldercontent_spec.js b/js/tests/views/foldercontent_spec.js index 1cb3d306ca..c3cefc2789 100644 --- a/js/tests/views/foldercontent_spec.js +++ b/js/tests/views/foldercontent_spec.js @@ -36,6 +36,7 @@ define(['views/foldercontent', account: account }); message = new Message(); + folder.addMessage(message); view = new FolderContent({ account: account, folder: folder, @@ -57,9 +58,9 @@ define(['views/foldercontent', }); it ('should not mark first email as read on folder view', function() { - spyOn(Radio.ui, 'trigger'); + spyOn(Radio.message, 'trigger'); view.markMessageAsRead(message); - expect(Radio.ui.trigger).not.toHaveBeenCalled(); + expect(Radio.message.trigger).not.toHaveBeenCalled(); }); }); @@ -77,10 +78,10 @@ define(['views/foldercontent', }); it ('should mark first email as read on folder view', function() { - spyOn(Radio.ui, 'trigger'); + spyOn(Radio.message, 'trigger'); view.markMessageAsRead(message); - expect(Radio.ui.trigger).toHaveBeenCalledWith( - 'messagesview:messageflag:set', message.id, 'unseen', false + expect(Radio.message.trigger).toHaveBeenCalledWith( + 'flag', message, 'unseen', false ); }); }); diff --git a/js/tests/views/messagesitem_spec.js b/js/tests/views/messagesitem_spec.js index 8dae7e9f0b..61c5d7bb60 100644 --- a/js/tests/views/messagesitem_spec.js +++ b/js/tests/views/messagesitem_spec.js @@ -31,10 +31,11 @@ define(['views/messagesitem', var model; beforeEach(function () { - // on local attachment, we use the LocalAttachment model model = new Message({ id: 22 }); + model.folder = new Folder(); + model.folder.account = new Account(); view = new MessagesItem({ model: model }); @@ -56,8 +57,8 @@ define(['views/messagesitem', expect(event.stopPropagation).toHaveBeenCalled(); // check message is marked as read - expect(Radio.ui.trigger).toHaveBeenCalledWith( - 'messagesview:messageflag:set', model.id, 'unseen', false + expect(Radio.message.trigger).toHaveBeenCalledWith( + 'flag', model, 'unseen', false ); // check message has been opened expect(Radio.message.trigger).toHaveBeenCalled(); @@ -81,8 +82,8 @@ define(['views/messagesitem', expect(event.stopPropagation).toHaveBeenCalled(); // check message is marked as read - expect(Radio.ui.trigger).toHaveBeenCalledWith( - 'messagesview:messageflag:set', model.id, 'unseen', false + expect(Radio.message.trigger).toHaveBeenCalledWith( + 'flag', model, 'unseen', false ); // check message has been opened expect(Radio.message.trigger).toHaveBeenCalled(); diff --git a/js/views/appview.js b/js/views/appview.js index d5f3311e50..0f89641282 100644 --- a/js/views/appview.js +++ b/js/views/appview.js @@ -196,7 +196,7 @@ define(function(require) { var activeFolder = require('state').currentFolder; var name = activeFolder.name || activeFolder.get('name'); var count = 0; - // TODO: use specialUse instead, otherwise this won't work with localized drafts folders + // TODO: use specialRole instead, otherwise this won't work with localized drafts folders if (name === 'Drafts') { count = activeFolder.total || activeFolder.get('total'); } else { diff --git a/js/views/composerview.js b/js/views/composerview.js index 2b1f8ad970..619795e600 100644 --- a/js/views/composerview.js +++ b/js/views/composerview.js @@ -327,8 +327,8 @@ define(function(require) { if (!!options.repliedMessage) { // Reply -> flag message as replied - Radio.ui.trigger('messagesview:messageflag:set', - options.repliedMessage.get('id'), + Radio.message.trigger('flag', + options.repliedMessage, 'answered', true); } diff --git a/js/views/foldercontent.js b/js/views/foldercontent.js index 6e8225b43d..b061c59ffd 100644 --- a/js/views/foldercontent.js +++ b/js/views/foldercontent.js @@ -78,14 +78,15 @@ define(function(require) { searchQuery: this.searchQuery })); }, - onShowMessage: function(message) { + onShowMessage: function(message, body) { // Temporarily disable new-message composer events Radio.ui.trigger('composer:events:undelegate'); - var messageModel = new Backbone.Model(message); + var messageModel = new Backbone.Model(body); this.showChildView('message', new MessageView({ account: this.account, folder: this.folder, + message: message, model: messageModel })); this.detailView = DetailView.MESSAGE; @@ -96,7 +97,7 @@ define(function(require) { // on mobiles then, we shall not mark the email as read until the user opened it var isMobile = $(window).width() < 768; if (isMobile === false) { - Radio.ui.trigger('messagesview:messageflag:set', message.id, 'unseen', false); + Radio.message.trigger('flag', message, 'unseen', false); } }, onShowError: function(errorMessage) { @@ -195,7 +196,7 @@ define(function(require) { message = require('state').currentMessage; if (message) { state = message.get('flags').get('flagged'); - Radio.message.trigger('flag', this.account, this.folder, message, 'flagged', !state); + Radio.message.trigger('flag', message, 'flagged', !state); } break; case 85: @@ -204,7 +205,7 @@ define(function(require) { message = require('state').currentMessage; if (message) { state = message.get('flags').get('unseen'); - Radio.message.trigger('flag', this.account, this.folder, message, 'unseen', !state); + Radio.message.trigger('flag', message, 'unseen', !state); } break; } diff --git a/js/views/messagesitem.js b/js/views/messagesitem.js index d37b1bf6b0..dc79f2ce84 100644 --- a/js/views/messagesitem.js +++ b/js/views/messagesitem.js @@ -5,7 +5,7 @@ * later. See the COPYING file. * * @author Christoph Wurst - * @copyright Christoph Wurst 2015, 2016 + * @copyright Christoph Wurst 2017 */ define(function(require) { @@ -33,7 +33,9 @@ define(function(require) { change: 'render' }, serializeModel: function() { - return this.model.toJSON(); + var json = this.model.toJSON(); + json.isUnified = require('state').currentAccount.get('isUnified'); + return json; }, onRender: function() { // Get rid of that pesky wrapping-div. @@ -60,7 +62,7 @@ define(function(require) { scope: dragScope, helper: function() { var el = $('
'); - el.data('folderId', require('state').currentFolder.get('id')); + el.data('folderId', _this.model.folder.get('id')); el.data('messageId', _this.model.get('id')); return el; }, @@ -81,62 +83,37 @@ define(function(require) { // directly change star state in the interface for quick feedback if (starred) { this.getUI('star') - .removeClass('icon-starred') - .addClass('icon-star'); + .removeClass('icon-starred') + .addClass('icon-star'); } else { this.getUI('star') - .removeClass('icon-star') - .addClass('icon-starred'); + .removeClass('icon-star') + .addClass('icon-starred'); } - // TODO: globals are bad :-/ - var account = require('state').currentAccount; - var folder = require('state').currentFolder; - - Radio.message.trigger('flag', account, folder, this.model, 'flagged', !starred); + Radio.message.trigger('flag', this.model, 'flagged', !starred); }, openMessage: function(event) { event.stopPropagation(); $('#mail-message').removeClass('hidden-mobile'); // make sure message is marked as read when clicked on it - Radio.ui.trigger('messagesview:messageflag:set', this.model.id, 'unseen', false); - var account = require('state').currentAccount; - var folder = require('state').currentFolder; - Radio.message.trigger('load', account, folder, this.model, { + Radio.message.trigger('flag', this.model, 'unseen', false); + Radio.message.trigger('load', this.model.folder.account, this.model.folder, this.model, { force: true }); }, deleteMessage: function(event) { event.stopPropagation(); - var thisModel = this.model; + var message = this.model; + this.getUI('iconDelete').removeClass('icon-delete').addClass('icon-loading-small'); $('.tooltip').remove(); - thisModel.get('flags').set('unseen', false); - var folder = require('state').currentFolder; - var count = folder.get('total'); - folder.set('total', count - 1); - - var thisModelCollection = thisModel.collection; - var index = thisModelCollection.indexOf(thisModel); - // Select previous or first - if (index === 0) { - index = 1; - } else { - index = index - 1; - } - var nextMessage = thisModelCollection.at(index); - if (require('state').currentMessage && require('state').currentMessage.get('id') === thisModel.id) { - if (nextMessage) { - var nextAccount = require('state').currentAccount; - var nextFolder = require('state').currentFolder; - Radio.message.trigger('load', nextAccount, nextFolder, nextMessage); - } - } - this.$el.addClass('transparency').slideUp(function() { $('.tooltip').remove(); - thisModelCollection.remove(thisModel); + + // really delete the message + Radio.folder.request('message:delete', message, require('state').currentFolder); // manually trigger mouseover event for current mouse position // in order to create a tooltip for the next message if needed @@ -144,20 +121,6 @@ define(function(require) { $(document.elementFromPoint(event.clientX, event.clientY)).trigger('mouseover'); } }); - - // really delete the message - var account = require('state').currentAccount; - Radio.message.request('delete', account, folder, this.model).catch(function() { - // TODO: move to controller - Radio.ui.trigger('error:show', t('mail', 'Error while deleting message.')); - - // Restore counter - count = folder.get('total'); - folder.set('total', count + 1); - - // Add the message to the collection again - folder.addMessage(this.model); - }); } }); }); diff --git a/js/views/messagesview.js b/js/views/messagesview.js index 24311f01bf..e20b2db548 100644 --- a/js/views/messagesview.js +++ b/js/views/messagesview.js @@ -39,7 +39,13 @@ define(function(require) { currentMessage: null, searchQuery: null, loadingMore: false, - reloaded: false, + + /** + * @private + * @type {bool} + */ + _reloaded: false, + events: { DOMMouseScroll: 'onWheel', mousewheel: 'onWheel' @@ -53,7 +59,6 @@ define(function(require) { return _this.collection; }); this.listenTo(Radio.ui, 'messagesview:messages:update', this.refresh); - this.listenTo(Radio.ui, 'messagesview:messageflag:set', this.setMessageFlag); this.listenTo(Radio.ui, 'messagesview:filter', this.filterCurrentMailbox); this.listenTo(Radio.ui, 'messagesview:message:setactive', this.setActiveMessage); this.listenTo(Radio.message, 'messagesview:message:next', this.selectNextMessage); @@ -75,37 +80,19 @@ define(function(require) { searchQuery: this.searchQuery }; }, - setMessageFlag: function(messageId, flag, val) { - var message = this.collection.get(messageId); - if (message) { - // TODO: globals are bad :-/ - var account = require('state').currentAccount; - var folder = require('state').currentFolder; - - Radio.message.trigger('flag', account, folder, message, flag, val); - } - }, /** * Set active class for current message and remove it from old one * * @param {Message} message */ setActiveMessage: function(message) { - var oldMessage = null; if (this.currentMessage !== null) { - // TODO: make sure objects exist only once and compare references instead - oldMessage = this.collection.get(this.currentMessage.get('id')); - if (oldMessage) { - oldMessage.set('active', false); - } + this.currentMessage.set('active', false); } this.currentMessage = message; if (message !== null) { - message = this.collection.get(this.currentMessage); - if (message) { - message.set('active', true); - } + message.set('active', true); } require('state').currentMessage = message; @@ -128,8 +115,8 @@ define(function(require) { var nextMessage = this.collection.at(this.collection.indexOf(message) + 1); if (nextMessage) { - var account = require('state').currentAccount; - var folder = require('state').currentFolder; + var folder = nextMessage.folder; + var account = folder.account; Radio.message.trigger('load', account, folder, nextMessage, { force: true }); @@ -152,8 +139,8 @@ define(function(require) { var previousMessage = this.collection.at(this.collection.indexOf(message) - 1); if (previousMessage) { - var account = require('state').currentAccount; - var folder = require('state').currentFolder; + var folder = previousMessage.folder; + var account = folder.account; Radio.message.trigger('load', account, folder, previousMessage, { force: true }); @@ -166,14 +153,11 @@ define(function(require) { if (!require('state').currentFolder) { return; } - this.loadMessages(true); - }, - loadMore: function() { - this.loadMessages(false); + this._syncMessages(); }, onScroll: function() { - if (this.reloaded) { - this.reloaded = false; + if (this._reloaded) { + this._reloaded = false; return; } if (this.loadingMore === true) { @@ -183,13 +167,13 @@ define(function(require) { if (this.$scrollContainer.scrollTop() === 0) { // Scrolled to top -> refresh this.loadingMore = true; - this.loadMessages(true); + this._syncMessages(); return; } if ((this.$scrollContainer.scrollTop() + this.$scrollContainer.height()) > (this.$el.height() - 150)) { // Scrolled all the way down -> load more this.loadingMore = true; - this.loadMessages(false); + this._loadNextMessages(); return; } }, @@ -206,44 +190,55 @@ define(function(require) { this.filterCriteria = { text: query }; - this.loadMessages(true); + this._syncMessages(); }, - loadMessages: function(reload) { - reload = reload || false; - var from = this.collection.size(); - if (reload) { - from = 0; - } + + /** + * @private + * @returns {Promise} + */ + _loadNextMessages: function() { // Add loading feedback - if (reload) { - $('#mail-message-list-loading').css('opacity', 0) - .slideDown('slow') - .animate( - {opacity: 1}, - {queue: false, duration: 'slow'} - ); - } else { - this.$('#load-more-mail-messages').addClass('icon-loading-small'); - } + this.$('#load-more-mail-messages').addClass('icon-loading-small'); - var _this = this; var account = require('state').currentAccount; var folder = require('state').currentFolder; - Radio.message.request('entities', account, folder, { - from: from, - to: from + 20, - force: true, - filter: this.searchQuery || '', - // Replace cached message list on reload - replace: reload + return Radio.message.request('next-page', account, folder, { + filter: this.searchQuery || '' }).then(function() { Radio.ui.trigger('messagesview:message:setactive', require('state').currentMessage); }, function() { Radio.ui.trigger('error:show', t('mail', 'Error while loading messages.')); }).then(function() { // Remove loading feedback again - _this.$('#load-more-mail-messages').removeClass('icon-loading-small'); - if (reload) { + this.$('#load-more-mail-messages').removeClass('icon-loading-small'); + this.loadingMore = false; + // Reload scrolls the list to the top, hence a unwanted + // scroll event is fired, which we want to ignore + this._reloaded = false; + }.bind(this), console.error.bind(this)); + }, + + /** + * @private + * @returns {Promise} + */ + _syncMessages: function() { + // Loading feedback + $('#mail-message-list-loading').css('opacity', 0) + .slideDown('slow') + .animate( + {opacity: 1}, + {queue: false, duration: 'slow'} + ); + + var folder = require('state').currentFolder; + return Radio.message.request('sync', folder) + .catch(function(e) { + console.error(e); + Radio.ui.trigger('error:show', t('mail', 'Error while refreshing messages.')); + }) + .then(function() { $('#mail-message-list-loading').css('opacity', 1) .slideUp('slow') .animate( @@ -254,17 +249,13 @@ define(function(require) { queue: false, duration: 'slow', complete: function() { - _this.loadingMore = false; - }, + this.loadingMore = false; + }.bind(this) }); - } else { - _this.loadingMore = false; - } - // Reload scrolls the list to the top, hence a unwanted - // scroll event is fired, which we want to ignore - _this.reloaded = reload; - }); + this._reloaded = true; + }.bind(this), console.error.bind(this)); }, + onBeforeRender: function() { // FF jump scrolls when we load more mesages. This stores the scroll // position before the element is re-rendered and restores it afterwards diff --git a/js/views/messageview.js b/js/views/messageview.js index 7284d6a4be..251faf0611 100644 --- a/js/views/messageview.js +++ b/js/views/messageview.js @@ -27,6 +27,7 @@ define(function(require) { template: Handlebars.compile(MessageTemplate), className: 'mail-message-container', message: null, + messageBody: null, reply: null, account: null, folder: null, @@ -40,39 +41,40 @@ define(function(require) { initialize: function(options) { this.account = options.account; this.folder = options.folder; - this.message = options.model; + this.message = options.message; + this.messageBody = options.model; this.reply = { - replyToList: this.message.get('replyToList'), - replyCc: this.message.get('replyCc'), - toEmail: this.message.get('toEmail'), - replyCcList: this.message.get('replyCcList'), + replyToList: this.messageBody.get('replyToList'), + replyCc: this.messageBody.get('replyCc'), + toEmail: this.messageBody.get('toEmail'), + replyCcList: this.messageBody.get('replyCcList'), body: '' }; // Add body content to inline reply (text mails) - if (!this.message.get('hasHtmlBody')) { - var date = new Date(this.message.get('dateIso')); + if (!this.messageBody.get('hasHtmlBody')) { + var date = new Date(this.messageBody.get('dateIso')); var minutes = date.getMinutes(); - var text = HtmlHelper.htmlToText(this.message.get('body')); + var text = HtmlHelper.htmlToText(this.messageBody.get('body')); this.reply.body = '\n\n\n\n' + - this.message.get('from') + ' – ' + + this.messageBody.get('from') + ' – ' + $.datepicker.formatDate('D, d. MM yy ', date) + date.getHours() + ':' + (minutes < 10 ? '0' : '') + minutes + '\n> ' + text.replace(/\n/g, '\n> '); } // Save current messages's content for later use (forward) - if (!this.message.get('hasHtmlBody')) { - require('state').currentMessageBody = this.message.get('body'); + if (!this.messageBody.get('hasHtmlBody')) { + require('state').currentMessageBody = this.messageBody.get('body'); } - require('state').currentMessageSubject = this.message.get('subject'); + require('state').currentMessageSubject = this.messageBody.get('subject'); // Render the message body adjustControlsWidth(); // Hide forward button until the message has finished loading - if (this.message.get('hasHtmlBody')) { + if (this.messageBody.get('hasHtmlBody')) { $('#forward-button').hide(); } }, @@ -123,8 +125,8 @@ define(function(require) { // Add body content to inline reply (html mails) var text = this.getUI('messageIframe').contents().find('body').html(); text = HtmlHelper.htmlToText(text); - var date = new Date(this.message.get('dateIso')); - this.getChildView('replyComposer').setReplyBody(this.message.get('from'), date, text); + var date = new Date(this.messageBody.get('dateIso')); + this.getChildView('replyComposer').setReplyBody(this.messageBody.get('from'), date, text); // Safe current mesages's content for later use (forward) require('state').currentMessageBody = text; @@ -136,7 +138,7 @@ define(function(require) { this.getUI('messageIframe').on('load', _.bind(this.onIframeLoad, this)); this.showChildView('attachments', new MessageAttachmentsView({ - collection: new Attachments(this.message.get('attachments')), + collection: new Attachments(this.messageBody.get('attachments')), message: this.model })); diff --git a/karma.conf.js b/karma.conf.js index a295a3c940..7e771e9c30 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -5,7 +5,7 @@ module.exports = function(config) { config.set({ // frameworks to use // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['jasmine-ajax', 'jasmine', 'requirejs'], + frameworks: ['jasmine-ajax', 'jasmine', 'requirejs', 'sinon'], // list of files / patterns to load in the browser files: [ diff --git a/lib/Contracts/IMailManager.php b/lib/Contracts/IMailManager.php index d7ee53837b..27124b246f 100644 --- a/lib/Contracts/IMailManager.php +++ b/lib/Contracts/IMailManager.php @@ -22,8 +22,22 @@ namespace OCA\Mail\Contracts; use OCA\Mail\Account; +use OCA\Mail\Folder; +use OCA\Mail\IMAP\Sync\Request as SyncRequest; +use OCA\Mail\IMAP\Sync\Response as SyncResponse; interface IMailManager { + /** + * @param Account $account + * @return Folder[] + */ public function getFolders(Account $account); + + /** + * @param Account + * @param SyncRequest $syncRequest + * @return SyncResponse + */ + public function syncMessages(Account $account, SyncRequest $syncRequest); } diff --git a/lib/Controller/FoldersController.php b/lib/Controller/FoldersController.php index 3cc3c384c2..e5dd439d22 100644 --- a/lib/Controller/FoldersController.php +++ b/lib/Controller/FoldersController.php @@ -25,6 +25,8 @@ use Horde_Imap_Client_Exception; use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\IMAP\Sync\Request as SyncRequest; +use OCA\Mail\IMAP\Sync\Response as SyncResponse; use OCA\Mail\Service\AccountService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -44,7 +46,7 @@ class FoldersController extends Controller { private $mailManager; /** - * @param type $appName + * @param string $appName * @param IRequest $request * @param AccountService $accountService * @param string $UserId @@ -76,6 +78,25 @@ public function index($accountId) { ]; } + /** + * @NoAdminRequired + * @NoCSRFRequired + * @param int $accountId + * @param string $folderId + * @param string $syncToken + * @param int[] $uids + * @return SyncResponse + */ + public function sync($accountId, $folderId, $syncToken, $uids = []) { + $account = $this->accountService->find($this->currentUserId, $accountId); + + if (empty($accountId) || empty($folderId) || empty($syncToken) || !is_array($uids)) { + return new JSONResponse(null, Http::STATUS_BAD_REQUEST); + } + + return $this->mailManager->syncMessages($account, new SyncRequest(base64_decode($folderId), $syncToken, $uids)); + } + /** * @NoAdminRequired * @NoCSRFRequired @@ -140,37 +161,4 @@ public function create($accountId, $mailbox) { } } - /** - * @NoAdminRequired - * @param $accountId - * @param $folders - * @return JSONResponse - */ - public function detectChanges($accountId, $folders) { - try { - $query = []; - foreach ($folders as $folder) { - $folderId = base64_decode($folder['id']); - $parts = explode('/', $folderId); - if (count($parts) > 1 && $parts[1] === 'FLAGGED') { - continue; - } - if (isset($folder['error'])) { - continue; - } - $query[$folderId] = $folder; - } - $account = $this->accountService->find($this->currentUserId, $accountId); - $mailBoxes = $account->getChangedMailboxes($query); - - return new JSONResponse($mailBoxes); - } catch (Horde_Imap_Client_Exception $e) { - $response = new JSONResponse(); - $response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); - return $response; - } catch (DoesNotExistException $e) { - return new JSONResponse(); - } - } - } diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index 13990b9944..6cbad6911b 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -119,13 +119,12 @@ public function __construct($appName, * * @param int $accountId * @param string $folderId - * @param int $from - * @param int $to + * @param int $cursor * @param string $filter * @param array $ids * @return JSONResponse */ - public function index($accountId, $folderId, $from=0, $to=20, $filter=null, $ids=null) { + public function index($accountId, $folderId, $cursor = null, $filter=null, $ids=null) { if (!is_null($ids)) { $ids = explode(',', $ids); @@ -133,9 +132,12 @@ public function index($accountId, $folderId, $from=0, $to=20, $filter=null, $ids } $mailBox = $this->getFolder($accountId, $folderId); - $this->logger->debug("loading messages $from to $to of folder <$folderId>"); + $this->logger->debug("loading messages of folder <$folderId>"); - $json = $mailBox->getMessages($from, $to-$from+1, $filter); + if ($cursor === '') { + $cursor = null; + } + $messages = $mailBox->getMessages($filter, $cursor); $ci = $this->contactsIntegration; $json = array_map(function($j) use ($ci, $mailBox) { @@ -153,7 +155,7 @@ public function index($accountId, $folderId, $from=0, $to=20, $filter=null, $ids $j['senderImage'] = $ci->getPhoto($j['fromEmail']); return $j; - }, $json); + }, $messages); return new JSONResponse($json); } @@ -188,7 +190,6 @@ private function loadMessage($accountId, $folderId, $id) { /** * @NoAdminRequired - * @NoCSRFRequired * * @param int $accountId * @param string $folderId @@ -206,7 +207,6 @@ public function show($accountId, $folderId, $id) { /** * @NoAdminRequired - * @NoCSRFRequired * * @param int $accountId * @param string $folderId diff --git a/lib/Folder.php b/lib/Folder.php index 046daf1b4f..d190e1ba77 100644 --- a/lib/Folder.php +++ b/lib/Folder.php @@ -50,14 +50,16 @@ class Folder implements JsonSerializable { /** @var string */ private $displayName; + /** @var string */ + private $syncToken; + /** * @param Account $account * @param Horde_Imap_Client_Mailbox $mailbox * @param array $attributes * @param string $delimiter */ - public function __construct(Account $account, - Horde_Imap_Client_Mailbox $mailbox, array $attributes, $delimiter) { + public function __construct(Account $account, Horde_Imap_Client_Mailbox $mailbox, array $attributes, $delimiter) { $this->account = $account; $this->mailbox = $mailbox; $this->attributes = $attributes; @@ -145,6 +147,13 @@ public function isSearchable() { return !in_array('\noselect', $this->getAttributes()); } + /** + * @param string $syncToken + */ + public function setSyncToken($syncToken) { + $this->syncToken = $syncToken; + } + /** * @return array */ @@ -166,6 +175,7 @@ public function jsonSerialize() { 'delimiter' => $this->delimiter, 'folders' => array_values($folders), 'specialRole' => empty($this->specialUse) ? null : $this->specialUse[0], + 'syncToken' => $this->syncToken, ]; } diff --git a/lib/Service/IMAP/IMAPClientFactory.php b/lib/IMAP/IMAPClientFactory.php similarity index 98% rename from lib/Service/IMAP/IMAPClientFactory.php rename to lib/IMAP/IMAPClientFactory.php index 6eef448a75..975e7db984 100644 --- a/lib/Service/IMAP/IMAPClientFactory.php +++ b/lib/IMAP/IMAPClientFactory.php @@ -19,7 +19,7 @@ * */ -namespace OCA\Mail\Service\IMAP; +namespace OCA\Mail\IMAP; use Horde_Imap_Client_Socket; use OCA\Mail\Account; diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php new file mode 100644 index 0000000000..51c7bdf449 --- /dev/null +++ b/lib/IMAP/MessageMapper.php @@ -0,0 +1,67 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Fetch; +use Horde_Imap_Client_Fetch_Query; +use Horde_Imap_Client_Ids; +use OCA\Mail\Folder; +use OCA\Mail\Model\IMAPMessage; + +class MessageMapper { + + /** + * @param Horde_Imap_Client_Base $imapClient + * @param Folder $mailbox + * @param array $ids + * @return IMAPMessage[] + */ + public function findByIds(Horde_Imap_Client_Base $imapClient, $mailbox, + array $ids) { + $query = new Horde_Imap_Client_Fetch_Query(); + $query->envelope(); + $query->flags(); + $query->size(); + $query->uid(); + $query->imapDate(); + $query->structure(); + $query->headers('imp', [ + 'importance', + 'list-post', + 'x-priority', + 'content-type', + ], [ + 'cache' => true, + 'peek' => true, + ]); + + $fetchResults = iterator_to_array($imapClient->fetch($mailbox, $query, [ + 'ids' => new Horde_Imap_Client_Ids($ids), + ]), false); + + return array_map(function(Horde_Imap_Client_Data_Fetch $fetchResult) use ($imapClient, $mailbox) { + return new IMAPMessage($imapClient, $mailbox, $fetchResult->getUid(), $fetchResult); + }, $fetchResults); + } + +} diff --git a/lib/IMAP/Sync/FavouritesMailboxSync.php b/lib/IMAP/Sync/FavouritesMailboxSync.php new file mode 100644 index 0000000000..63eba0a5af --- /dev/null +++ b/lib/IMAP/Sync/FavouritesMailboxSync.php @@ -0,0 +1,40 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP\Sync; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Sync; +use OCA\Mail\Model\IMAPMessage; + +class FavouritesMailboxSync extends SimpleMailboxSync { + + public function getNewMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync) { + $newMessages = parent::getNewMessages($imapClient, $syncRequest, $hordeSync); + + return array_filter($newMessages, function(IMAPMessage $message) { + $flags = $message->getFlags(); + return isset($flags['flagged']) && $flags['flagged']; + }); + } + +} diff --git a/lib/IMAP/Sync/ISyncStrategy.php b/lib/IMAP/Sync/ISyncStrategy.php new file mode 100644 index 0000000000..b3e2db8453 --- /dev/null +++ b/lib/IMAP/Sync/ISyncStrategy.php @@ -0,0 +1,61 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP\Sync; + +use Horde_Imap_Client; +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Sync; +use OCA\Mail\Model\IMAPMessage; + +/** + * Encapsulates the algorithm of syncing a mailbox (select new, changed, deleted + * messages) + */ +interface ISyncStrategy { + + /** + * @param Horde_Imap_Client $imapClient + * @param Request $syncRequest + * @param Horde_Imap_Client_Data_Sync $hordeSync + * @return IMAPMessage[] + */ + public function getNewMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync); + + /** + * @param Horde_Imap_Client $imapClient + * @param Request $syncRequest + * @param Horde_Imap_Client_Data_Sync $hordeSync + * @return IMAPMessage[] + */ + public function getChangedMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync); + + /** + * @param Horde_Imap_Client $imapClient + * @param Request $syncRequest + * @param Horde_Imap_Client_Data_Sync $hordeSync + * @return int[] + */ + public function getVanishedMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync); +} diff --git a/lib/IMAP/Sync/Request.php b/lib/IMAP/Sync/Request.php new file mode 100644 index 0000000000..28c859d1d6 --- /dev/null +++ b/lib/IMAP/Sync/Request.php @@ -0,0 +1,84 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP\Sync; + +class Request { + + /** @var string */ + private $mailbox; + + /** @var string */ + private $syncToken; + + /** @var array */ + private $uids; + + /** + * @param string $mailbox + * @param string $syncToken + * @param int $uids + */ + public function __construct($mailbox, $syncToken, array $uids) { + $this->mailbox = $mailbox; + $this->syncToken = $syncToken; + $this->uids = $uids; + } + + /** + * Get the mailbox name + * + * @return string + */ + public function getMailbox() { + // TODO: this is kinda hacky + $parts = explode('/', $this->mailbox); + if (count($parts) > 1 && $parts[count($parts) - 1] === 'FLAGGED') { + return implode('/', array_slice($parts, 0, count($parts) - 1)); + } + return $this->mailbox; + } + + /** + * @return bool + */ + public function isFlaggedMailbox() { + // TODO: this is kinda hacky + return $this->mailbox !== $this->getMailbox(); + } + + /** + * @return string the Horde sync token + */ + public function getToken() { + return $this->syncToken; + } + + /** + * Get an array of known uids on the client-side + * + * @return int[] + */ + public function getUids() { + return $this->uids; + } + +} diff --git a/lib/IMAP/Sync/Response.php b/lib/IMAP/Sync/Response.php new file mode 100644 index 0000000000..ac7aa9e1a7 --- /dev/null +++ b/lib/IMAP/Sync/Response.php @@ -0,0 +1,67 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP\Sync; + +use JsonSerializable; +use OCA\Mail\Model\IMAPMessage; + +class Response implements JsonSerializable { + + /** @var string */ + private $syncToken; + + /** @var IMAPMessage[] */ + private $newMessages; + + /** @var IMAPMessage[] */ + private $changedMessages; + + /** @var array */ + private $vanishedMessages; + + /** + * @param string $syncToken + * @param IMAPMessage[] $newMessages + * @param IMAPMessage[] $changedMessages + * @param array $vanishedMessages + */ + public function __construct($syncToken, array $newMessages = [], array $changedMessages = [], + array $vanishedMessages = []) { + $this->syncToken = $syncToken; + $this->newMessages = $newMessages; + $this->changedMessages = $changedMessages; + $this->vanishedMessages = $vanishedMessages; + } + + /** + * @return array + */ + public function jsonSerialize() { + return [ + 'newMessages' => $this->newMessages, + 'changedMessages' => $this->changedMessages, + 'vanishedMessages' => $this->vanishedMessages, + 'token' => $this->syncToken, + ]; + } + +} diff --git a/lib/IMAP/Sync/SimpleMailboxSync.php b/lib/IMAP/Sync/SimpleMailboxSync.php new file mode 100644 index 0000000000..09af9a78d1 --- /dev/null +++ b/lib/IMAP/Sync/SimpleMailboxSync.php @@ -0,0 +1,75 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP\Sync; + +use Horde_Imap_Client; +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Sync; +use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Model\IMAPMessage; + +class SimpleMailboxSync implements ISyncStrategy { + + /** @var MessageMapper */ + private $messageMapper; + + /** + * @param MessageMapper $messageMapper + */ + public function __construct(MessageMapper $messageMapper) { + $this->messageMapper = $messageMapper; + } + + /** + * @param Horde_Imap_Client $imapClient + * @param Request $syncRequest + * @param Horde_Imap_Client_Data_Sync $hordeSync + * @return IMAPMessage[] + */ + public function getNewMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync) { + return $this->messageMapper->findByIds($imapClient, $syncRequest->getMailbox(), $hordeSync->newmsgsuids->ids); + } + + /** + * @param Horde_Imap_Client $imapClient + * @param Request $syncRequest + * @param Horde_Imap_Client_Data_Sync $hordeSync + * @return IMAPMessage[] + */ + public function getChangedMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync) { + return $this->messageMapper->findByIds($imapClient, $syncRequest->getMailbox(), $hordeSync->flagsuids->ids); + } + + /** + * @param Horde_Imap_Client $imapClient + * @param Request $syncRequest + * @param Horde_Imap_Client_Data_Sync $hordeSync + * @return IMAPMessage[] + */ + public function getVanishedMessages(Horde_Imap_Client_Base $imapClient, + Request $syncRequest, Horde_Imap_Client_Data_Sync $hordeSync) { + return $hordeSync->vanisheduids->ids; + } + +} diff --git a/lib/IMAP/Sync/Synchronizer.php b/lib/IMAP/Sync/Synchronizer.php new file mode 100644 index 0000000000..e3f0f5d484 --- /dev/null +++ b/lib/IMAP/Sync/Synchronizer.php @@ -0,0 +1,81 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\IMAP\Sync; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Ids; +use Horde_Imap_Client_Mailbox; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\IMAP\Sync\Response; + +class Synchronizer { + + /** @var ISyncStrategy */ + private $simpleSync; + + /** @var ISyncStrategy */ + private $favSync; + + /** + * @param SimpleMailboxSync $simpleSync + * @param FavouritesMailboxSync $favSync + */ + public function __construct(SimpleMailboxSync $simpleSync, + FavouritesMailboxSync $favSync) { + $this->simpleSync = $simpleSync; + $this->favSync = $favSync; + } + + /** + * @param Horde_Imap_Client_Base $imapClient + * @param Request $request + * @return Response + */ + public function sync(Horde_Imap_Client_Base $imapClient, Request $request) { + $mailbox = new Horde_Imap_Client_Mailbox($request->getMailbox()); + $ids = new Horde_Imap_Client_Ids($request->getUids()); + $hordeSync = $imapClient->sync($mailbox, $request->getToken(), [ + 'ids' => $ids + ]); + + $syncStrategy = $this->getSyncStrategy($request); + $newMessages = $syncStrategy->getNewMessages($imapClient, $request, $hordeSync); + $changedMessages = $syncStrategy->getChangedMessages($imapClient, $request, $hordeSync); + $vanishedMessages = $syncStrategy->getVanishedMessages($imapClient, $request, $hordeSync); + + $newSyncToken = $imapClient->getSyncToken($request->getMailbox()); + return new Response($newSyncToken, $newMessages, $changedMessages, $vanishedMessages); + } + + /** + * @param Request $request + * @return ISyncStrategy + */ + private function getSyncStrategy(Request $request) { + if ($request->isFlaggedMailbox()) { + return $this->favSync; + } else { + return $this->simpleSync; + } + } + +} diff --git a/lib/Mailbox.php b/lib/Mailbox.php index cf80360838..3c8d0f5637 100644 --- a/lib/Mailbox.php +++ b/lib/Mailbox.php @@ -31,6 +31,7 @@ namespace OCA\Mail; +use DateTime; use Horde_Imap_Client; use Horde_Imap_Client_Exception; use Horde_Imap_Client_Fetch_Query; @@ -79,7 +80,7 @@ class Mailbox implements IMailBox { * @param array $attributes * @param string $delimiter */ - public function __construct($conn, $mailBox, $attributes, $delimiter='/') { + public function __construct($conn, $mailBox, $attributes, $delimiter = '/') { $this->conn = $conn; $this->mailBox = $mailBox; $this->attributes = $attributes; @@ -92,11 +93,10 @@ public function __construct($conn, $mailBox, $attributes, $delimiter='/') { } /** - * @param integer $from - * @param integer $count - * @param string $filter + * @param string|Horde_Imap_Client_Search_Query $filter + * @param int $cursor */ - private function getSearchIds($from, $count, $filter) { + private function getSearchIds($filter, $cursor = null) { if ($filter instanceof Horde_Imap_Client_Search_Query) { $query = $filter; } else { @@ -108,28 +108,31 @@ private function getSearchIds($from, $count, $filter) { if ($this->getSpecialRole() !== 'trash') { $query->flag(Horde_Imap_Client::FLAG_DELETED, false); } + if (!is_null($cursor)) { + $query->dateSearch(DateTime::createFromFormat("U", $cursor), Horde_Imap_Client_Search_Query::DATE_BEFORE); + } try { - $result = $this->conn->search($this->mailBox, $query, ['sort' => [Horde_Imap_Client::SORT_DATE]]); + $result = $this->conn->search($this->mailBox, $query, [ + 'sort' => [ + Horde_Imap_Client::SORT_DATE + ], + ]); } catch (Horde_Imap_Client_Exception $e) { // maybe the server's advertisment of SORT was a fake // see https://github.com/nextcloud/mail/issues/50 // try again without SORT - return $this->getFetchIds($from, $count); + return $this->getFetchIds($cursor); } - $ids = array_reverse($result['match']->ids); - if ($from >= 0 && $count >= 0) { - $ids = array_slice($ids, $from, $count); - } - return new \Horde_Imap_Client_Ids($ids, false); + return array_reverse($result['match']->ids); } /** - * @param integer $from - * @param integer $count + * @param int $cursor + * @return type */ - private function getFetchIds($from, $count) { + private function getFetchIds($cursor = null) { $q = new Horde_Imap_Client_Fetch_Query(); $q->uid(); $q->imapDate(); @@ -137,56 +140,68 @@ private function getFetchIds($from, $count) { $result = $this->conn->fetch($this->mailBox, $q); $uidMap = []; foreach ($result as $r) { - $uidMap[$r->getUid()] = $r->getImapDate()->getTimeStamp(); + $ts = $r->getImapDate()->getTimeStamp(); + if (is_null($cursor) || $ts < $cursor) { + $uidMap[$r->getUid()] = $ts; + } } // sort by time uasort($uidMap, function($a, $b) { return $a < $b; }); - if ($from >= 0 && $count >= 0) { - $uidMap = array_slice($uidMap, $from, $count, true); - } - return new \Horde_Imap_Client_Ids(array_keys($uidMap), false); + return array_keys($uidMap); } - public function getMessages($from = 0, $count = 2, $filter = '') { + /** + * Get message page + * + * Build the list of UIDs for the current page on the client side + * + * This is done by fetching a list of *all* UIDs and their data, sorting them + * respectively and selecting the appropriate page. The page starts once UID after + * the cursorId, if given. The size of the page is limited to 20. + * + * @param string|Horde_Imap_Client_Search_Query $filter + * @param int $cursor time stamp of the oldest message on the client + * @return array + */ + public function getMessages($filter = null, $cursor = null) { if (!$this->conn->capability->query('SORT') && (is_null($filter) || $filter === '')) { - $ids = $this->getFetchIds($from, $count); + $ids = $this->getFetchIds($cursor); } else { - $ids = $this->getSearchIds($from, $count, $filter); + $ids = $this->getSearchIds($filter, $cursor); } + $page = new Horde_Imap_Client_Ids(array_slice($ids, 0, 20, true)); - $headers = []; - - $fetch_query = new Horde_Imap_Client_Fetch_Query(); - $fetch_query->envelope(); - $fetch_query->flags(); - $fetch_query->size(); - $fetch_query->uid(); - $fetch_query->imapDate(); - $fetch_query->structure(); + $fetchQuery = new Horde_Imap_Client_Fetch_Query(); + $fetchQuery->envelope(); + $fetchQuery->flags(); + $fetchQuery->size(); + $fetchQuery->uid(); + $fetchQuery->imapDate(); + $fetchQuery->structure(); - $headers = array_merge($headers, [ + $headers = [ 'importance', 'list-post', - 'x-priority' - ]); - $headers[] = 'content-type'; + 'x-priority', + 'content-type', + ]; - $fetch_query->headers('imp', $headers, [ + $fetchQuery->headers('imp', $headers, [ 'cache' => true, - 'peek' => true + 'peek' => true ]); - $options = ['ids' => $ids]; + $options = ['ids' => $page]; // $list is an array of Horde_Imap_Client_Data_Fetch objects. - $headers = $this->conn->fetch($this->mailBox, $fetch_query, $options); + $fetchResult = $this->conn->fetch($this->mailBox, $fetchQuery, $options); ob_start(); // fix for Horde warnings $messages = []; - foreach ($headers->ids() as $message_id) { - $header = $headers[$message_id]; - $message = new IMAPMessage($this->conn, $this->mailBox, $message_id, $header); + foreach ($fetchResult->ids() as $messageId) { + $header = $fetchResult[$messageId]; + $message = new IMAPMessage($this->conn, $this->mailBox, $messageId, $header); $messages[] = $message->jsonSerialize(); } ob_get_clean(); @@ -330,6 +345,7 @@ public function serialize($accountId, $status = null) { ]; } } + /** * Get the special use role of the mailbox * @@ -362,13 +378,12 @@ protected function getSpecialRoleFromAttributes() { return strtolower($n); }, $this->attributes); - foreach ($specialUseAttributes as $attr) { + foreach ($specialUseAttributes as $attr) { if (in_array($attr, $attributes)) { $result = ltrim($attr, '\\'); break; } } - } $this->specialRole = $result; @@ -380,12 +395,12 @@ protected function getSpecialRoleFromAttributes() { protected function guessSpecialRole() { $specialFoldersDict = [ - 'inbox' => ['inbox'], - 'sent' => ['sent', 'sent items', 'sent messages', 'sent-mail', 'sentmail'], - 'drafts' => ['draft', 'drafts'], + 'inbox' => ['inbox'], + 'sent' => ['sent', 'sent items', 'sent messages', 'sent-mail', 'sentmail'], + 'drafts' => ['draft', 'drafts'], 'archive' => ['archive', 'archives'], - 'trash' => ['deleted messages', 'trash'], - 'junk' => ['junk', 'spam', 'bulk mail'], + 'trash' => ['deleted messages', 'trash'], + 'junk' => ['junk', 'spam', 'bulk mail'], ]; $lowercaseExplode = explode($this->delimiter, $this->getFolderId(), 2); @@ -432,7 +447,8 @@ public function saveMessage($rawBody, $flags = []) { */ public function saveDraft($rawBody) { - $uids = $this->conn->append($this->mailBox, [ + $uids = $this->conn->append($this->mailBox, + [ [ 'data' => $rawBody, 'flags' => [ diff --git a/lib/SearchMailbox.php b/lib/SearchMailbox.php index 1f78a837bf..d108e0d3e9 100644 --- a/lib/SearchMailbox.php +++ b/lib/SearchMailbox.php @@ -43,19 +43,18 @@ public function __construct($conn, $mailBox, $attributes, $delimiter = '/') { } /** - * @param int $from - * @param int $count - * @param string $filter - * @return mixed + * @param string|Horde_Imap_Client_Search_Query $filter + * @param int $cursor time stamp of the oldest message on the client + * @return array */ - public function getMessages($from = 0, $count = 2, $filter = '') { + public function getMessages($filter = null, $cursor = null) { $query = new Horde_Imap_Client_Search_Query(); $query->flag('FLAGGED'); if ($filter) { $query->text($filter, false); } - return parent::getMessages($from, $count, $query); + return parent::getMessages($query, $cursor); } /** diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index c6636fc7d6..6b0436c6dd 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -50,6 +50,7 @@ class AccountService { /** * @param MailAccountMapper $mapper + * @param IL10N $l10n */ public function __construct(MailAccountMapper $mapper, IL10N $l10n, Manager $defaultAccountManager) { @@ -139,10 +140,10 @@ public function delete($currentUserId, $accountId) { } /** - * @param $newAccount + * @param MailAccount $newAccount * @return MailAccount */ - public function save($newAccount) { + public function save(MailAccount $newAccount) { return $this->mapper->save($newAccount); } diff --git a/lib/Service/FolderMapper.php b/lib/Service/FolderMapper.php index 737ae1366f..e018a4d1d0 100644 --- a/lib/Service/FolderMapper.php +++ b/lib/Service/FolderMapper.php @@ -35,7 +35,8 @@ class FolderMapper { * @param string $pattern * @return Folder */ - public function getFolders(Account $account, Horde_Imap_Client_Socket $client, $pattern = '*') { + public function getFolders(Account $account, Horde_Imap_Client_Socket $client, + $pattern = '*') { $mailboxes = $client->listMailboxes($pattern, Horde_Imap_Client::MBOX_ALL, [ 'delimiter' => true, 'attributes' => true, @@ -44,9 +45,19 @@ public function getFolders(Account $account, Horde_Imap_Client_Socket $client, $ $folders = []; foreach ($mailboxes as $mailbox) { - $folders[] = new Folder($account, $mailbox['mailbox'], $mailbox['attributes'], $mailbox['delimiter']); + $folder = new Folder($account, $mailbox['mailbox'], $mailbox['attributes'], $mailbox['delimiter']); + + if ($folder->isSearchable()) { + $folder->setSyncToken($client->getSyncToken($folder->getMailbox())); + } + + $folders[] = $folder; if ($mailbox['mailbox']->utf8 === 'INBOX') { - $folders[] = new SearchFolder($account, $mailbox['mailbox'], $mailbox['attributes'], $mailbox['delimiter']); + $searchFolder = new SearchFolder($account, $mailbox['mailbox'], $mailbox['attributes'], $mailbox['delimiter']); + if ($searchFolder->isSearchable()) { + $searchFolder->setSyncToken($client->getSyncToken($folder->getMailbox())); + } + $folders[] = $searchFolder; } } return $folders; @@ -97,7 +108,8 @@ private function getParentId(Folder $folder) { * @param Folder[] $folders * @param Horde_Imap_Client_Socket $client */ - public function getFoldersStatus(array $folders, Horde_Imap_Client_Socket $client) { + public function getFoldersStatus(array $folders, + Horde_Imap_Client_Socket $client) { $mailboxes = array_map(function(Folder $folder) { return $folder->getMailbox(); }, array_filter($folders, function(Folder $folder) { diff --git a/lib/Service/IMailBox.php b/lib/Service/IMailBox.php index 638a0a7a9e..9c0966f2a3 100644 --- a/lib/Service/IMailBox.php +++ b/lib/Service/IMailBox.php @@ -33,12 +33,11 @@ interface IMailBox { public function getFolderId(); /** - * @param int $from - * @param int $count * @param string|Horde_Imap_Client_Search_Query $filter + * @param int $cursorId last known ID on the client * @return array */ - public function getMessages($from, $count, $filter); + public function getMessages($filter = null, $cursorId = null); /** * @return string diff --git a/lib/Service/MailManager.php b/lib/Service/MailManager.php index f04022074d..906bae0bfd 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -1,5 +1,6 @@ * @@ -24,7 +25,12 @@ use OCA\Mail\Account; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Folder; -use OCA\Mail\Service\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\IMAP\Sync\Response; +use OCA\Mail\IMAP\Sync\Synchronizer; +use OCA\Mail\Service\FolderMapper; +use OCA\Mail\Service\FolderNameTranslator; class MailManager implements IMailManager { @@ -37,16 +43,21 @@ class MailManager implements IMailManager { /** @var FolderNameTranslator */ private $folderNameTranslator; + /** @var Synchronizer */ + private $synchronizer; + /** * @param IMAPClientFactory $imapClientFactory * @param FolderMapper $folderMapper - * @param FolderNameTranslator + * @param FolderNameTranslator $folderNameTranslator + * @param Synchronizer $synchronizer */ - public function __construct(IMAPClientFactory $imapClientFactory, - FolderMapper $folderMapper, FolderNameTranslator $folderNameTranslator) { + public function __construct(IMAPClientFactory $imapClientFactory, FolderMapper $folderMapper, + FolderNameTranslator $folderNameTranslator, Synchronizer $synchronizer) { $this->imapClientFactory = $imapClientFactory; $this->folderMapper = $folderMapper; $this->folderNameTranslator = $folderNameTranslator; + $this->synchronizer = $synchronizer; } /** @@ -64,4 +75,15 @@ public function getFolders(Account $account) { return $this->folderMapper->buildFolderHierarchy($folders); } + /** + * @param Account $account + * @param Request $syncRequest + * @return Response + */ + public function syncMessages(Account $account, Request $syncRequest) { + $client = $this->imapClientFactory->getClient($account); + + return $this->synchronizer->sync($client, $syncRequest); + } + } diff --git a/lib/Service/UnifiedAccount.php b/lib/Service/UnifiedAccount.php index 56076097d0..d6353ca874 100644 --- a/lib/Service/UnifiedAccount.php +++ b/lib/Service/UnifiedAccount.php @@ -190,7 +190,6 @@ public function getChangedMailboxes($query) { foreach ($changes[$inboxName]['messages'] as &$message) { $id = base64_encode(json_encode([$account->getId(), $message['id']])); $message['id'] = $id; - $message['accountMail'] = $account->getEmail(); } $changedBoxes[self::INBOX_ID]['messages'] = array_merge($changedBoxes[self::INBOX_ID]['messages'], $changes[$inboxName]['messages']); $changedBoxes[self::INBOX_ID]['newUnReadCounter'] += $changes[$inboxName]['newUnReadCounter']; diff --git a/lib/Service/UnifiedMailbox.php b/lib/Service/UnifiedMailbox.php index 0016378d7c..931af4d24c 100644 --- a/lib/Service/UnifiedMailbox.php +++ b/lib/Service/UnifiedMailbox.php @@ -48,14 +48,13 @@ public function getFolderId() { } /** - * @param int $from - * @param int $count - * @param string|\Horde_Imap_Client_Search_Query $filter + * @param string|Horde_Imap_Client_Search_Query $filter + * @param int $cursor time stamp of the oldest message on the client * @return array */ - public function getMessages($from, $count, $filter) { + public function getMessages($filter = null, $cursor = null) { $allAccounts = $this->accountService->findByUserId($this->userId); - $allMessages = array_map(function($account) use ($from, $count, $filter) { + $allMessages = array_map(function($account) use ($cursor, $filter) { /** @var IAccount $account */ if ($account->getId() === UnifiedAccount::ID) { return []; @@ -65,17 +64,16 @@ public function getMessages($from, $count, $filter) { return []; } - $messages = $inbox->getMessages($from, $count, $filter); + $messages = $inbox->getMessages($cursor, $filter); $messages = array_map(function($message) use ($account) { $message['id'] = base64_encode(json_encode([$account->getId(), $message['id']])); - $message['accountMail'] = $account->getEmail(); return $message; }, $messages); return $messages; }, $allAccounts); - $allMessages = array_reduce($allMessages, function($a, $b) { + return array_reduce($allMessages, function($a, $b) { if (is_null($a)) { return $b; } @@ -84,17 +82,6 @@ public function getMessages($from, $count, $filter) { } return array_merge($a, $b); }); - - // sort by time - usort($allMessages, function($a, $b) { - return $a['dateInt'] < $b['dateInt']; - }); - - if ($count >= 0) { - $allMessages = array_slice($allMessages, 0, $count); - } - - return $allMessages; } /** diff --git a/package.json b/package.json index 1680fcbbca..bcd54cb02e 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,12 @@ "karma-jasmine-ajax": "^0.1.13", "karma-phantomjs-launcher": "^1.0.4", "karma-requirejs": "^1.1.0", - "phantomjs-prebuilt": "^2.1.12" + "karma-sinon": "^1.0.5", + "phantomjs-prebuilt": "^2.1.12", + "sinon": "^2.1.0" }, "dependencies": { "bower": "^1.5.2", - "grunt": "^1.0.1", "requirejs": "^2.1.20" } } diff --git a/tests/Controller/AccountsControllerTest.php b/tests/Controller/AccountsControllerTest.php index 6419dba0d0..f830e2e9a8 100644 --- a/tests/Controller/AccountsControllerTest.php +++ b/tests/Controller/AccountsControllerTest.php @@ -22,9 +22,11 @@ namespace OCA\Mail\Tests\Controller; +use Horde_Exception; use OCA\Mail\Account; use OCA\Mail\Contracts\IMailTransmission; use OCA\Mail\Controller\AccountsController; +use OCA\Mail\Db\MailAccount; use OCA\Mail\Model\Message; use OCA\Mail\Model\NewMessageData; use OCA\Mail\Model\RepliedMessageData; @@ -192,16 +194,18 @@ public function testCreateAutoDetectSuccess() { $password = '123456'; $accountName = 'John Doe'; - $this->account->expects($this->exactly(2)) - ->method('getId') + $account = $this->createMock(MailAccount::class); + $account->expects($this->exactly(2)) + ->method('__call') + ->with('getId') ->will($this->returnValue(135)); $this->autoConfig->expects($this->once()) ->method('createAutoDetected') ->with($this->equalTo($email), $this->equalTo($password), $this->equalTo($accountName)) - ->will($this->returnValue($this->account)); + ->will($this->returnValue($account)); $this->accountService->expects($this->once()) ->method('save') - ->with($this->equalTo($this->account)); + ->with($this->equalTo($account)); $response = $this->controller->create($accountName, $email, $password, null, null, null, null, null, null, null, null, null, null, true); @@ -263,7 +267,7 @@ public function testSendingError() { $this->transmission->expects($this->once()) ->method('sendMessage') ->with($this->userId, $messageData, $replyData, null, null) - ->willThrowException(new \Horde_Exception('error')); + ->willThrowException(new Horde_Exception('error')); $expected = new JSONResponse(['message' => 'error'], 500); $resp = $this->controller->send(13, null, 'sub', 'bod', 'to@d.com', '', '', null, null, [], null); diff --git a/tests/FolderTest.php b/tests/FolderTest.php index f5d5c1830b..00103e7722 100644 --- a/tests/FolderTest.php +++ b/tests/FolderTest.php @@ -153,6 +153,7 @@ public function testJsonSerialize() { 'delimiter' => '.', 'folders' => [['subdir data']], 'specialRole' => 'sent', + 'syncToken' => null, ]; $this->assertEquals($expected, $this->folder->jsonSerialize()); } diff --git a/tests/Imap/AbstractTest.php b/tests/IMAP/AbstractTest.php similarity index 99% rename from tests/Imap/AbstractTest.php rename to tests/IMAP/AbstractTest.php index fbf2ab910e..127b2251bb 100644 --- a/tests/Imap/AbstractTest.php +++ b/tests/IMAP/AbstractTest.php @@ -21,7 +21,7 @@ * */ -namespace OCA\Mail\Tests\Imap; +namespace OCA\Mail\Tests\IMAP; use Exception; use OC; diff --git a/tests/Imap/AccountTest.php b/tests/IMAP/AccountTest.php similarity index 63% rename from tests/Imap/AccountTest.php rename to tests/IMAP/AccountTest.php index 451542e388..9debf458ea 100644 --- a/tests/Imap/AccountTest.php +++ b/tests/IMAP/AccountTest.php @@ -21,7 +21,8 @@ * along with this program. If not, see * */ -namespace OCA\Mail\Tests\Imap; + +namespace OCA\Mail\Tests\IMAP; /** * @group IMAP @@ -69,35 +70,4 @@ public function testListMessages($name) { $this->assertEquals(1, count($messages)); } - /** - * @dataProvider providesMailBoxNames - * @param $name - */ - public function testGetChangedMailboxes($name) { - $name = uniqid($name); - $newMailBox = parent::createMailBox($name); - $status = $newMailBox->getStatus(); - $changedMailBoxes = $this->getTestAccount()->getChangedMailboxes([ - $newMailBox->getFolderId() => [ 'uidvalidity' => $status['uidvalidity'], 'uidnext' => $status['uidnext'] ] - ]); - - $this->assertEquals(0, count($changedMailBoxes)); - - $this->createTestMessage($newMailBox); - - $changedMailBoxes = $this->getTestAccount()->getChangedMailboxes([ - $newMailBox->getFolderId() => [ 'uidvalidity' => $status['uidvalidity'], 'uidnext' => $status['uidnext'] ] - ]); - - $this->assertEquals(1, count($changedMailBoxes)); - $this->assertEquals(1, count($changedMailBoxes[$newMailBox->getFolderId()]['messages'])); - } - - public function testGetChangedMailboxesForNotExisting() { - $changedMailBoxes = $this->getTestAccount()->getChangedMailboxes([ - 'you-dont-know-me' => ['uidvalidity' => 0, 'uidnext' => 0] - ]); - - $this->assertEquals(0, count($changedMailBoxes)); - } } diff --git a/tests/Service/IMAP/IMAPClientFactoryTest.php b/tests/IMAP/IMAPClientFactoryTest.php similarity index 96% rename from tests/Service/IMAP/IMAPClientFactoryTest.php rename to tests/IMAP/IMAPClientFactoryTest.php index a3f9054db2..eb7d85b8a4 100644 --- a/tests/Service/IMAP/IMAPClientFactoryTest.php +++ b/tests/IMAP/IMAPClientFactoryTest.php @@ -19,13 +19,13 @@ * */ -namespace OCA\Mail\Tests\Service\IMAP; +namespace OCA\Mail\Tests\IMAP; use Horde_Imap_Client_Socket; use OC; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Service\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Tests\TestCase; use OCP\ICacheFactory; use OCP\IConfig; @@ -77,7 +77,7 @@ public function testGetClient() { ->method('decrypt') ->with($account->getMailAccount()->getInboundPassword()) ->willReturn('mypassword'); - + $client = $this->factory->getClient($account); $this->assertInstanceOf(Horde_Imap_Client_Socket::class, $client); @@ -89,7 +89,7 @@ public function testClientConectivity() { ->method('decrypt') ->with($account->getMailAccount()->getInboundPassword()) ->willReturn('mypassword'); - + $client = $this->factory->getClient($account); $client->login(); } diff --git a/tests/IMAP/MessageMapperTest.php b/tests/IMAP/MessageMapperTest.php new file mode 100644 index 0000000000..b5529deedc --- /dev/null +++ b/tests/IMAP/MessageMapperTest.php @@ -0,0 +1,73 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\IMAP; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Fetch; +use Horde_Imap_Client_Fetch_Results; +use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Model\IMAPMessage; +use PHPUnit_Framework_TestCase; + +class MessageMapperTest extends PHPUnit_Framework_TestCase { + + /** @var MessageMapper */ + private $mapper; + + protected function setUp() { + parent::setUp(); + + $this->mapper = new MessageMapper(); + } + + public function testGetByIds() { + $imapClient = $this->createMock(Horde_Imap_Client_Base::class); + $mailbox = 'inbox'; + $ids = [1, 3]; + + $fetchResults = new Horde_Imap_Client_Fetch_Results(); + $fetchResult1 = $this->createMock(Horde_Imap_Client_Data_Fetch::class); + $fetchResult2 = $this->createMock(Horde_Imap_Client_Data_Fetch::class); + $imapClient->expects($this->once()) + ->method('fetch') + ->willReturn($fetchResults); + $fetchResults[0] = $fetchResult1; + $fetchResults[1] = $fetchResult2; + $fetchResult1->expects($this->once()) + ->method('getUid') + ->willReturn(1); + $fetchResult2->expects($this->once()) + ->method('getUid') + ->willReturn(3); + $message1 = new IMAPMessage($imapClient, $mailbox, 1, $fetchResult1); + $message2 = new IMAPMessage($imapClient, $mailbox, 3, $fetchResult2); + $expected = [ + $message1, + $message2, + ]; + + $result = $this->mapper->findByIds($imapClient, $mailbox, $ids); + + $this->assertEquals($expected, $result); + } + +} diff --git a/tests/IMAP/Sync/FavouritesMailboxSyncTest.php b/tests/IMAP/Sync/FavouritesMailboxSyncTest.php new file mode 100644 index 0000000000..a87d38b502 --- /dev/null +++ b/tests/IMAP/Sync/FavouritesMailboxSyncTest.php @@ -0,0 +1,84 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\IMAP\Sync; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Sync; +use Horde_Imap_Client_Ids; +use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\IMAP\Sync\FavouritesMailboxSync; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\Model\IMAPMessage; +use PHPUnit_Framework_MockObject_MockObject; +use PHPUnit_Framework_TestCase; + +class FavouritesMailboxSyncTest extends PHPUnit_Framework_TestCase { + + /** @var MessageMapper|PHPUnit_Framework_MockObject_MockObject */ + private $mapper; + + /** @var FavouritesMailboxSync */ + private $sync; + + protected function setUp() { + parent::setUp(); + + $this->mapper = $this->createMock(MessageMapper::class); + + $this->sync = new FavouritesMailboxSync($this->mapper); + } + + public function testGetNewMessages() { + $imapClient = $this->createMock(Horde_Imap_Client_Base::class); + $syncRequest = $this->createMock(Request::class); + $hordeSync = $this->createMock(Horde_Imap_Client_Data_Sync::class); + $syncRequest->expects($this->once()) + ->method('getMailbox') + ->willReturn('inbox'); + $hordeSync->newmsgsuids = $this->createMock(Horde_Imap_Client_Ids::class); + $hordeSync->newmsgsuids->ids = [23, 24]; + $message1 = $this->createMock(IMAPMessage::class); + $message2 = $this->createMock(IMAPMessage::class); + $this->mapper->expects($this->once()) + ->method('findByIds') + ->willReturn([ + $message1, + $message2, + ]); + $message1->expects($this->once()) + ->method('getFlags') + ->willReturn([ + 'flagged' => true, + ]); + $message2->expects($this->once()) + ->method('getFlags') + ->willReturn([ + 'flagged' => false, + ]); + + $messages = $this->sync->getNewMessages($imapClient, $syncRequest, $hordeSync); + + $this->assertCount(1, $messages); + $this->assertSame($message1, $messages[0]); + } + +} diff --git a/tests/IMAP/Sync/RequestTest.php b/tests/IMAP/Sync/RequestTest.php new file mode 100644 index 0000000000..45bcf4bde2 --- /dev/null +++ b/tests/IMAP/Sync/RequestTest.php @@ -0,0 +1,55 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\IMAP\Sync; + +use OCA\Mail\IMAP\Sync\Request; +use PHPUnit_Framework_TestCase; + +class RequestTest extends PHPUnit_Framework_TestCase { + + /** @var string */ + private $mailbox; + + /** @var string */ + private $syncToken; + + /** @var Request */ + private $request; + + protected function setUp() { + parent::setUp(); + + $this->mailbox = 'inbox'; + $this->syncToken = 'ab123'; + + $this->request = new Request($this->mailbox, $this->syncToken, []); + } + + public function testGetMailbox() { + $this->assertEquals($this->mailbox, $this->request->getMailbox()); + } + + public function testGetSyncToken() { + $this->assertEquals($this->syncToken, $this->request->getToken()); + } + +} diff --git a/tests/IMAP/Sync/ResponseTest.php b/tests/IMAP/Sync/ResponseTest.php new file mode 100644 index 0000000000..d9b4741ce9 --- /dev/null +++ b/tests/IMAP/Sync/ResponseTest.php @@ -0,0 +1,47 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\IMAP\Sync; + +use OCA\Mail\IMAP\Sync\Response; +use PHPUnit_Framework_TestCase; + +class ResponseTest extends PHPUnit_Framework_TestCase { + + public function testJsonSerialize() { + $newMessages = []; + $changedMessages = []; + $vanishedMessages = []; + $syncToken = 'bc4564'; + $response = new Response($syncToken, $newMessages, $changedMessages, $vanishedMessages); + $expected = [ + 'newMessages' => [], + 'changedMessages' => [], + 'vanishedMessages' => [], + 'token' => $syncToken, + ]; + + $json = $response->jsonSerialize(); + + $this->assertEquals($expected, $json); + } + +} diff --git a/tests/IMAP/Sync/SimpleMailboxSyncTest.php b/tests/IMAP/Sync/SimpleMailboxSyncTest.php new file mode 100644 index 0000000000..4afb9bf8e9 --- /dev/null +++ b/tests/IMAP/Sync/SimpleMailboxSyncTest.php @@ -0,0 +1,109 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\IMAP\Sync; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Sync; +use Horde_Imap_Client_Ids; +use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\IMAP\Sync\SimpleMailboxSync; +use OCA\Mail\Model\IMAPMessage; +use PHPUnit_Framework_MockObject_MockObject; +use PHPUnit_Framework_TestCase; + +class SimpleMailboxSyncTest extends PHPUnit_Framework_TestCase { + + /** @var MessageMapper|PHPUnit_Framework_MockObject_MockObject */ + private $mapper; + + /** @var Horde_Imap_Client_Base|PHPUnit_Framework_MockObject_MockObject */ + private $imapClient; + + /** @var Request|PHPUnit_Framework_MockObject_MockObject */ + private $syncRequest; + + /** @var Horde_Imap_Client_Data_Sync|PHPUnit_Framework_MockObject_MockObject */ + private $hordeSync; + + /** @var SimpleMailboxSync */ + private $sync; + + protected function setUp() { + parent::setUp(); + + $this->mapper = $this->createMock(MessageMapper::class); + $this->imapClient = $this->createMock(Horde_Imap_Client_Base::class); + $this->syncRequest = $this->createMock(Request::class); + $this->hordeSync = $this->createMock(Horde_Imap_Client_Data_Sync::class); + + $this->sync = new SimpleMailboxSync($this->mapper); + } + + public function testGetNewMessages() { + $this->syncRequest->expects($this->once()) + ->method('getMailbox') + ->willReturn('inbox'); + $this->hordeSync->newmsgsuids = $this->createMock(Horde_Imap_Client_Ids::class); + $this->hordeSync->newmsgsuids->ids = [23, 24]; + $this->mapper->expects($this->once()) + ->method('findByIds') + ->with($this->equalTo($this->imapClient), $this->equalTo('inbox'), $this->equalTo([23, 24])) + ->willReturn([ + $this->createMock(IMAPMessage::class), + $this->createMock(IMAPMessage::class), + ]); + + $messages = $this->sync->getNewMessages($this->imapClient, $this->syncRequest, $this->hordeSync); + + $this->assertCount(2, $messages); + } + + public function testGetChangedMessages() { + $this->syncRequest->expects($this->once()) + ->method('getMailbox') + ->willReturn('inbox'); + $this->hordeSync->flagsuids = $this->createMock(Horde_Imap_Client_Ids::class); + $this->hordeSync->flagsuids->ids = [23, 24]; + $this->mapper->expects($this->once()) + ->method('findByIds') + ->with($this->equalTo($this->imapClient), $this->equalTo('inbox'), $this->equalTo([23, 24])) + ->willReturn([ + $this->createMock(IMAPMessage::class), + $this->createMock(IMAPMessage::class), + ]); + + $messages = $this->sync->getChangedMessages($this->imapClient, $this->syncRequest, $this->hordeSync); + + $this->assertCount(2, $messages); + } + + public function testGetVanishedMessages() { + $this->hordeSync->vanisheduids = $this->createMock(Horde_Imap_Client_Ids::class); + $this->hordeSync->vanisheduids->ids = [23, 24]; + + $ids = $this->sync->getVanishedMessages($this->imapClient, $this->syncRequest, $this->hordeSync); + + $this->assertEquals([23, 24], $ids); + } + +} diff --git a/tests/IMAP/Sync/SynchronizerTest.php b/tests/IMAP/Sync/SynchronizerTest.php new file mode 100644 index 0000000000..bf597df618 --- /dev/null +++ b/tests/IMAP/Sync/SynchronizerTest.php @@ -0,0 +1,111 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\IMAP\Sync; + +use Horde_Imap_Client_Base; +use Horde_Imap_Client_Data_Sync; +use Horde_Imap_Client_Ids; +use Horde_Imap_Client_Mailbox; +use OCA\Mail\IMAP\Sync\FavouritesMailboxSync; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\IMAP\Sync\Response; +use OCA\Mail\IMAP\Sync\SimpleMailboxSync; +use OCA\Mail\IMAP\Sync\Synchronizer; +use PHPUnit_Framework_MockObject_MockObject; +use PHPUnit_Framework_TestCase; + +class SynchronizerTest extends PHPUnit_Framework_TestCase { + + /** @var SimpleMailboxSync|PHPUnit_Framework_MockObject_MockObject */ + private $simpleSync; + + /** @var FavouritesMailboxSync|PHPUnit_Framework_MockObject_MockObject */ + private $favSync; + + /** @var Synchronizer */ + private $synchronizer; + + protected function setUp() { + parent::setUp(); + + $this->simpleSync = $this->createMock(SimpleMailboxSync::class); + $this->favSync = $this->createMock(FavouritesMailboxSync::class); + + $this->synchronizer = new Synchronizer($this->simpleSync, $this->favSync); + } + + public function syncData() { + return [ + [false], + [true], + ]; + } + + /** + * @dataProvider syncData + */ + public function testSync($flagged) { + $sync = $flagged ? $this->favSync : $this->simpleSync; + + $imapClient = $this->createMock(Horde_Imap_Client_Base::class); + $request = $this->createMock(Request::class); + $request->expects($this->any()) + ->method('getMailbox') + ->willReturn('inbox'); + $request->expects($this->once()) + ->method('getToken') + ->willReturn('123456'); + $hordeSync = $this->createMock(Horde_Imap_Client_Data_Sync::class); + $imapClient->expects($this->once()) + ->method('sync') + ->with($this->equalTo(new Horde_Imap_Client_Mailbox('inbox')), $this->equalTo('123456')) + ->willReturn($hordeSync); + $request->expects($this->once()) + ->method('isFlaggedMailbox') + ->willReturn($flagged); + $newMessages = []; + $changedMessages = []; + $vanishedMessages = [4, 5]; + $sync->expects($this->once()) + ->method('getNewMessages') + ->with($imapClient, $request, $hordeSync) + ->willReturn($newMessages); + $sync->expects($this->once()) + ->method('getChangedMessages') + ->with($imapClient, $request, $hordeSync) + ->willReturn($changedMessages); + $sync->expects($this->once()) + ->method('getVanishedMessages') + ->with($imapClient, $request, $hordeSync) + ->willReturn($vanishedMessages); + $imapClient->expects($this->once()) + ->method('getSyncToken') + ->with($this->equalTo('inbox')) + ->willReturn('54321'); + $expected = new Response('54321', $newMessages, $changedMessages, $vanishedMessages); + + $response = $this->synchronizer->sync($imapClient, $request); + + $this->assertEquals($expected, $response); + } + +} diff --git a/tests/Integration/FolderSynchronizationTest.php b/tests/Integration/FolderSynchronizationTest.php new file mode 100644 index 0000000000..505b3a0e25 --- /dev/null +++ b/tests/Integration/FolderSynchronizationTest.php @@ -0,0 +1,131 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\Integration; + +use OC; +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Controller\FoldersController; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Tests\Integration\Framework\ImapTest; +use OCA\Mail\Tests\Integration\Framework\ImapTestAccount; + +class FolderSynchronizationTest extends TestCase { + + use ImapTest, + ImapTestAccount; + + /** @var FoldersController */ + private $foldersController; + + protected function setUp() { + parent::setUp(); + + $this->foldersController = new FoldersController('mail', OC::$server->getRequest(), OC::$server->query(AccountService::class), $this->getTestAccountUserId(), OC::$server->query(IMailManager::class)); + } + + public function testSyncEmptyMailbox() { + $account = $this->createTestAccount(); + $mailbox = 'INBOX'; + $syncToken = $this->getMailboxSyncToken($mailbox); + + $sync = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken); + $syncJson = $sync->jsonSerialize(); + + $this->assertArrayHasKey('newMessages', $syncJson); + $this->assertArrayHasKey('changedMessages', $syncJson); + $this->assertArrayHasKey('vanishedMessages', $syncJson); + $this->assertArrayHasKey('token', $syncJson); + $this->assertEmpty($syncJson['newMessages']); + $this->assertEmpty($syncJson['changedMessages']); + $this->assertEmpty($syncJson['vanishedMessages']); + } + + public function testSyncNewMessage() { + // First, set up account and retrieve sync token + $account = $this->createTestAccount(); + $mailbox = 'INBOX'; + $syncToken = $this->getMailboxSyncToken($mailbox); + // Second, put a new message into the mailbox + $message = $this->getMessageBuilder() + ->from('ralph@buffington@domain.tld') + ->to('user@domain.tld') + ->finish(); + $this->saveMessage($mailbox, $message); + + $sync = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken); + $syncJson = $sync->jsonSerialize(); + + $this->assertCount(1, $syncJson['newMessages']); + $this->assertCount(0, $syncJson['changedMessages']); + $this->assertCount(0, $syncJson['vanishedMessages']); + } + + public function testSyncChangedMessage() { + // First, put a message into the mailbox + $account = $this->createTestAccount(); + $mailbox = 'INBOX'; + $message = $this->getMessageBuilder() + ->from('ralph@buffington@domain.tld') + ->to('user@domain.tld') + ->finish(); + $id = $this->saveMessage($mailbox, $message); + // Second, retrieve a sync token + $syncToken = $this->getMailboxSyncToken($mailbox); + // Third, flag it + $this->flagMessage($mailbox, $id); + + $sync = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken, [ + $id + ]); + $syncJson = $sync->jsonSerialize(); + + $this->assertCount(0, $syncJson['newMessages']); + $this->assertCount(1, $syncJson['changedMessages']); + $this->assertCount(0, $syncJson['vanishedMessages']); + } + + public function testSyncVanishedMessage() { + // First, put a message into the mailbox + $account = $this->createTestAccount(); + $mailbox = 'INBOX'; + $message = $this->getMessageBuilder() + ->from('ralph@buffington@domain.tld') + ->to('user@domain.tld') + ->finish(); + $id = $this->saveMessage($mailbox, $message); + // Second, retrieve a sync token + $syncToken = $this->getMailboxSyncToken($mailbox); + // Third, remove it again + $this->deleteMessage($mailbox, $id); + + $sync = $this->foldersController->sync($account->getId(), base64_encode($mailbox), $syncToken, [ + $id + ]); + $syncJson = $sync->jsonSerialize(); + + $this->assertCount(0, $syncJson['newMessages']); + // TODO: deleted messages are flagged as changed? could be a testing-only issue + // $this->assertCount(0, $syncJson['changedMessages']); + $this->assertCount(1, $syncJson['vanishedMessages']); + } + +} diff --git a/tests/Integration/Framework/ImapTest.php b/tests/Integration/Framework/ImapTest.php index af1416d789..32a8c4cf24 100644 --- a/tests/Integration/Framework/ImapTest.php +++ b/tests/Integration/Framework/ImapTest.php @@ -21,6 +21,7 @@ namespace OCA\Mail\Tests\Integration\Framework; +use Horde_Imap_Client; use Horde_Imap_Client_Fetch_Query; use Horde_Imap_Client_Ids; use Horde_Imap_Client_Socket; @@ -113,6 +114,7 @@ public function getMessageBuilder() { /** * @param string $mailbox * @param SimpleMessage $message + * @return int id of the new message */ public function saveMessage($mailbox, SimpleMessage $message) { $client = $this->getTestClient(); @@ -135,13 +137,40 @@ public function saveMessage($mailbox, SimpleMessage $message) { $raw = $mail->getRaw(); $data = stream_get_contents($raw); - $client->append($mailbox, [ - [ - 'data' => $data, - ] + return $client->append($mailbox, [ + [ + 'data' => $data, + ] + ])->ids[0]; + } + + public function flagMessage($mailbox, $id) { + $client = $this->getTestClient(); + + $client->store($mailbox, [ + 'ids' => new Horde_Imap_Client_Ids([$id]), + 'add' => [ + Horde_Imap_Client::FLAG_FLAGGED, + ], + ]); + } + + public function deleteMessage($mailbox, $id) { + $client = $this->getTestClient(); + + $ids = new Horde_Imap_Client_Ids([$id]); + $client->expunge($mailbox, [ + 'ids' => $ids, + 'delete' => true, ]); } + public function getMailboxSyncToken($mailbox) { + $client = $this->getTestClient(); + + return $client->getSyncToken($mailbox); + } + /** * @param Horde_Imap_Client_Socket $client * @param string $mailbox diff --git a/tests/Integration/Framework/ImapTestAccount.php b/tests/Integration/Framework/ImapTestAccount.php new file mode 100644 index 0000000000..d6f15da677 --- /dev/null +++ b/tests/Integration/Framework/ImapTestAccount.php @@ -0,0 +1,63 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Tests\Integration\Framework; + +use OC; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Service\AccountService; + +trait ImapTestAccount { + + /** + * @return string + */ + public function getTestAccountUserId() { + return 'user12345'; + } + + /** + * Create and persist a new mail account + * + * @return MailAccount + */ + public function createTestAccount() { + /* @var $accountService AccountService */ + $accountService = OC::$server->query(AccountService::class); + + $mailAccount = new MailAccount(); + $mailAccount->setUserId($this->getTestAccountUserId()); + $mailAccount->setEmail('user@domain.tld'); + $mailAccount->setInboundHost('localhost'); + $mailAccount->setInboundPort(993); + $mailAccount->setInboundSslMode('ssl'); + $mailAccount->setInboundUser('user@domain.tld'); + $mailAccount->setInboundPassword(OC::$server->getCrypto()->encrypt('mypassword')); + + $mailAccount->setOutboundHost('localhost'); + $mailAccount->setOutboundPort(2525); + $mailAccount->setOutboundUser('user@domain.tld'); + $mailAccount->setOutboundPassword(OC::$server->getCrypto()->encrypt('mypassword')); + $mailAccount->setOutboundSslMode('none'); + return $accountService->save($mailAccount); + } + +} diff --git a/tests/Integration/Framework/SelfTest.php b/tests/Integration/Framework/SelfTest.php index 3dc18fb2ca..80ba3a5617 100644 --- a/tests/Integration/Framework/SelfTest.php +++ b/tests/Integration/Framework/SelfTest.php @@ -47,8 +47,9 @@ public function testMessageCapabilities() { ->finish(); $this->assertMessageCount(0, 'INBOX'); - $this->saveMessage('INBOX', $message); + $id = $this->saveMessage('INBOX', $message); $this->assertMessageCount(1, 'INBOX'); + $this->assertInternalType('int', $id); } } diff --git a/tests/Service/MailManagerTest.php b/tests/Service/MailManagerTest.php index 53fb6455a8..7cd38be448 100644 --- a/tests/Service/MailManagerTest.php +++ b/tests/Service/MailManagerTest.php @@ -23,24 +23,30 @@ use Horde_Imap_Client_Socket; use OCA\Mail\Account; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\Sync\Request; +use OCA\Mail\IMAP\Sync\Synchronizer; use OCA\Mail\Service\FolderMapper; use OCA\Mail\Service\FolderNameTranslator; -use OCA\Mail\Service\IMAP\IMAPClientFactory; use OCA\Mail\Service\MailManager; use OCA\Mail\Tests\TestCase; use OCP\Files\Folder; +use PHPUnit_Framework_TestCase; class MailManagerTest extends TestCase { - /** @var IMAPClientFactory */ + /** @var IMAPClientFactory|PHPUnit_Framework_TestCase */ private $imapClientFactory; - /** @var FolderMapper */ + /** @var FolderMapper|PHPUnit_Framework_TestCase */ private $folderMapper; - /** @var FolderNameTranslator */ + /** @var FolderNameTranslator|PHPUnit_Framework_TestCase */ private $translator; + /** @var Synchronizer|PHPUnit_Framework_TestCase */ + private $sync; + /** @varr MailManager */ private $manager; @@ -50,8 +56,9 @@ protected function setUp() { $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); $this->folderMapper = $this->createMock(FolderMapper::class); $this->translator = $this->createMock(FolderNameTranslator::class); + $this->sync = $this->createMock(Synchronizer::class); - $this->manager = new MailManager($this->imapClientFactory, $this->folderMapper, $this->translator); + $this->manager = new MailManager($this->imapClientFactory, $this->folderMapper, $this->translator, $this->sync); } public function testGetFolders() { @@ -88,4 +95,18 @@ public function testGetFolders() { $this->manager->getFolders($account); } + public function testSync() { + $account = $this->createMock(Account::class); + $syncRequest = $this->createMock(Request::class); + $client = $this->createMock(Horde_Imap_Client_Socket::class); + $this->imapClientFactory->expects($this->once()) + ->method('getClient') + ->willReturn($client); + $this->sync->expects($this->once()) + ->method('sync') + ->with($client, $syncRequest); + + $this->manager->syncMessages($account, $syncRequest); + } + }