diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2022-11-03 12:28:13 +0300 |
---|---|---|
committer | greta <gretadoci@gmail.com> | 2022-11-03 18:28:06 +0300 |
commit | c31336e08a3abe7dd10e691bdbef4102b05d4b54 (patch) | |
tree | 3b532e584426f9c77a8946dbd1b6166635be3837 | |
parent | eecb86e284e14a3232f2d1bb861bbdb437126312 (diff) |
Catch expired session and reload the page
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Signed-off-by: greta <gretadoci@gmail.com>
-rw-r--r-- | src/App.vue | 21 | ||||
-rw-r--r-- | src/http/sessionExpiryHandler.js | 37 | ||||
-rw-r--r-- | src/store/actions.js | 1595 | ||||
-rw-r--r-- | src/store/getters.js | 3 | ||||
-rw-r--r-- | src/store/index.js | 1 | ||||
-rw-r--r-- | src/store/mutations.js | 3 | ||||
-rw-r--r-- | src/tests/unit/App.spec.js | 66 | ||||
-rw-r--r-- | src/tests/unit/http/sessionExpiryHandler.spec.js | 82 | ||||
-rw-r--r-- | src/tests/unit/store/getters.spec.js | 6 |
9 files changed, 1079 insertions, 735 deletions
diff --git a/src/App.vue b/src/App.vue index 10ca26f4b..eeaa12d0d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,6 +26,10 @@ </template> <script> +import { mapGetters } from 'vuex' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + import logger from './logger' import { matchError } from './errors/match' import MailboxLockedError from './errors/MailboxLockedError' @@ -33,10 +37,24 @@ import MailboxLockedError from './errors/MailboxLockedError' export default { name: 'App', computed: { + ...mapGetters([ + 'isExpiredSession', + ]), hasMailAccounts() { return !!this.$store.getters.accounts.find((account) => !account.isUnified) }, }, + watch: { + isExpiredSession(expired) { + if (expired) { + showError(t('mail', 'Your session expired. The page will be reloaded.'), { + onRemove: () => { + this.reload() + }, + }) + } + }, + }, async mounted() { // Redirect to setup page if no accounts are configured if (!this.hasMailAccounts) { @@ -50,6 +68,9 @@ export default { await this.$store.dispatch('loadCollections') }, methods: { + reload() { + window.location.reload() + }, sync() { setTimeout(async () => { try { diff --git a/src/http/sessionExpiryHandler.js b/src/http/sessionExpiryHandler.js new file mode 100644 index 000000000..cafe2ea1d --- /dev/null +++ b/src/http/sessionExpiryHandler.js @@ -0,0 +1,37 @@ +/* + * @copyright 2022 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2022 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import logger from '../logger' + +export async function handleHttpAuthErrors(commit, cb) { + try { + const res = await cb() + logger.debug('req done') + return res + } catch (error) { + logger.debug('req err', { error, status: error.response?.status, message: error.response?.data?.message }) + if (error.response?.status === 401 && error.response?.data?.message === 'Current user is not logged in') { + logger.warn('Request failed due to expired session') + commit('setSessionExpired') + } + throw error + } +} diff --git a/src/store/actions.js b/src/store/actions.js index 3be457399..5fc12ecb5 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -99,6 +99,7 @@ import { import { html, plain, toPlain } from '../util/text' import Axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' +import { handleHttpAuthErrors } from '../http/sessionExpiryHandler' import { showWarning } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import { @@ -129,936 +130,1056 @@ const combineEnvelopeLists = pipe(flatten, orderBy(prop('dateInt'), 'desc')) export default { savePreference({ commit, getters }, { key, value }) { - return savePreference(key, value).then(({ value }) => { + return handleHttpAuthErrors(commit, async () => { + const newValue = await savePreference(key, value) commit('savePreference', { key, - value, + value: newValue, }) }) }, async fetchAccounts({ commit, getters }) { - const accounts = await fetchAllAccounts() - accounts.forEach((account) => commit('addAccount', account)) - return getters.accounts + return handleHttpAuthErrors(commit, async () => { + const accounts = await fetchAllAccounts() + accounts.forEach((account) => commit('addAccount', account)) + return getters.accounts + }) }, async fetchAccount({ commit }, id) { - const account = await fetchAccount(id) - commit('addAccount', account) - return account + return handleHttpAuthErrors(commit, async () => { + const account = await fetchAccount(id) + commit('addAccount', account) + return account + }) }, async createAccount({ commit }, config) { - const account = await createAccount(config) - logger.debug(`account ${account.id} created, fetching mailboxes …`, account) - account.mailboxes = await fetchAllMailboxes(account.id) - commit('addAccount', account) - logger.info("new account's mailboxes fetched", { account, config }) - return account + return handleHttpAuthErrors(commit, async () => { + const account = await createAccount(config) + logger.debug(`account ${account.id} created, fetching mailboxes …`, account) + account.mailboxes = await fetchAllMailboxes(account.id) + commit('addAccount', account) + logger.info("new account's mailboxes fetched", { account, config }) + return account + }) }, async updateAccount({ commit }, config) { - const account = await updateAccount(config) - logger.debug('account updated', { account }) - commit('editAccount', account) - return account + return handleHttpAuthErrors(commit, async () => { + const account = await updateAccount(config) + logger.debug('account updated', { account }) + commit('editAccount', account) + return account + }) }, async patchAccount({ commit }, { account, data }) { - const patchedAccount = await patchAccount(account, data) - logger.debug('account patched', { account: patchedAccount, data }) - commit('patchAccount', { account, data }) - return account + return handleHttpAuthErrors(commit, async () => { + const patchedAccount = await patchAccount(account, data) + logger.debug('account patched', { account: patchedAccount, data }) + commit('patchAccount', { account, data }) + return account + }) }, async updateAccountSignature({ commit }, { account, signature }) { - await updateSignature(account, signature) - logger.debug('account signature updated', { account, signature }) - const updated = Object.assign({}, account, { signature }) - commit('editAccount', updated) - return account + return handleHttpAuthErrors(commit, async () => { + await updateSignature(account, signature) + logger.debug('account signature updated', { account, signature }) + const updated = Object.assign({}, account, { signature }) + commit('editAccount', updated) + return account + }) }, async setAccountSetting({ commit, getters }, { accountId, key, value }) { - commit('setAccountSetting', { accountId, key, value }) - return await savePreference('account-settings', JSON.stringify(getters.getAllAccountSettings)) + return handleHttpAuthErrors(commit, async () => { + commit('setAccountSetting', { accountId, key, value }) + return await savePreference('account-settings', JSON.stringify(getters.getAllAccountSettings)) + }) }, async deleteAccount({ commit }, account) { - try { - await deleteAccount(account.id) - } catch (error) { - logger.error('could not delete account', { error }) - throw error - } + return handleHttpAuthErrors(commit, async () => { + try { + await deleteAccount(account.id) + } catch (error) { + logger.error('could not delete account', { error }) + throw error + } + }) }, async deleteMailbox({ commit }, { mailbox }) { - await deleteMailbox(mailbox.databaseId) - commit('removeMailbox', { id: mailbox.databaseId }) + return handleHttpAuthErrors(commit, async () => { + await deleteMailbox(mailbox.databaseId) + commit('removeMailbox', { id: mailbox.databaseId }) + }) }, async clearMailbox({ commit }, { mailbox }) { - await clearMailbox(mailbox.databaseId) - commit('removeEnvelopes', { id: mailbox.databaseId }) - commit('setMailboxUnreadCount', { id: mailbox.databaseId }) + return handleHttpAuthErrors(commit, async () => { + await clearMailbox(mailbox.databaseId) + commit('removeEnvelopes', { id: mailbox.databaseId }) + commit('setMailboxUnreadCount', { id: mailbox.databaseId }) + }) }, async createMailbox({ commit }, { account, name }) { - const prefixed = (account.personalNamespace && !name.startsWith(account.personalNamespace)) - ? account.personalNamespace + name - : name - const mailbox = await createMailbox(account.id, prefixed) - console.debug(`mailbox ${prefixed} created for account ${account.id}`, { mailbox }) - commit('addMailbox', { account, mailbox }) - commit('expandAccount', account.id) - commit('setAccountSetting', { accountId: account.id, key: 'collapsed', value: false }) - return mailbox + return handleHttpAuthErrors(commit, async () => { + const prefixed = (account.personalNamespace && !name.startsWith(account.personalNamespace)) + ? account.personalNamespace + name + : name + const mailbox = await createMailbox(account.id, prefixed) + console.debug(`mailbox ${prefixed} created for account ${account.id}`, { mailbox }) + commit('addMailbox', { account, mailbox }) + commit('expandAccount', account.id) + commit('setAccountSetting', { + accountId: account.id, + key: 'collapsed', + value: false, + }) + return mailbox + }) }, async moveAccount({ commit, getters }, { account, up }) { - const accounts = getters.accounts - const index = accounts.indexOf(account) - if (up) { - const previous = accounts[index - 1] - accounts[index - 1] = account - accounts[index] = previous - } else { - const next = accounts[index + 1] - accounts[index + 1] = account - accounts[index] = next - } - return await Promise.all( - accounts.map((account, idx) => { - if (account.id === 0) { - return Promise.resolve() - } - commit('saveAccountsOrder', { account, order: idx }) - return patchAccount(account, { order: idx }) - }) - ) + return handleHttpAuthErrors(commit, async () => { + const accounts = getters.accounts + const index = accounts.indexOf(account) + if (up) { + const previous = accounts[index - 1] + accounts[index - 1] = account + accounts[index] = previous + } else { + const next = accounts[index + 1] + accounts[index + 1] = account + accounts[index] = next + } + return await Promise.all( + accounts.map((account, idx) => { + if (account.id === 0) { + return Promise.resolve() + } + commit('saveAccountsOrder', { account, order: idx }) + return patchAccount(account, { order: idx }) + }) + ) + }) }, - async markMailboxRead({ getters, dispatch }, { accountId, mailboxId }) { - const mailbox = getters.getMailbox(mailboxId) - - if (mailbox.isUnified) { - const findIndividual = findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole) - const individualMailboxes = findIndividual(getters.accounts) - return Promise.all( - individualMailboxes.map((mb) => - dispatch('markMailboxRead', { - accountId: mb.accountId, - mailboxId: mb.databaseId, - }) + async markMailboxRead({ commit, getters, dispatch }, { accountId, mailboxId }) { + return handleHttpAuthErrors(commit, async () => { + const mailbox = getters.getMailbox(mailboxId) + + if (mailbox.isUnified) { + const findIndividual = findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole) + const individualMailboxes = findIndividual(getters.accounts) + return Promise.all( + individualMailboxes.map((mb) => + dispatch('markMailboxRead', { + accountId: mb.accountId, + mailboxId: mb.databaseId, + }) + ) ) - ) - } + } - await markMailboxRead(mailboxId) - dispatch('syncEnvelopes', { - accountId, - mailboxId, + await markMailboxRead(mailboxId) + dispatch('syncEnvelopes', { + accountId, + mailboxId, + }) }) }, async changeMailboxSubscription({ commit }, { mailbox, subscribed }) { - logger.debug(`toggle subscription for mailbox ${mailbox.databaseId}`, { - mailbox, - subscribed, - }) - const updated = await patchMailbox(mailbox.databaseId, { subscribed }) + return handleHttpAuthErrors(commit, async () => { + logger.debug(`toggle subscription for mailbox ${mailbox.databaseId}`, { + mailbox, + subscribed, + }) + const updated = await patchMailbox(mailbox.databaseId, { subscribed }) - commit('updateMailbox', { - mailbox: updated, - }) - logger.debug(`subscription for mailbox ${mailbox.databaseId} updated`, { - mailbox, - updated, + commit('updateMailbox', { + mailbox: updated, + }) + logger.debug(`subscription for mailbox ${mailbox.databaseId} updated`, { + mailbox, + updated, + }) }) }, async patchMailbox({ commit }, { mailbox, attributes }) { - logger.debug('patching mailbox', { - mailbox, - attributes, - }) + return handleHttpAuthErrors(commit, async () => { + logger.debug('patching mailbox', { + mailbox, + attributes, + }) - const updated = await patchMailbox(mailbox.databaseId, attributes) + const updated = await patchMailbox(mailbox.databaseId, attributes) - commit('updateMailbox', { - mailbox: updated, - }) - logger.debug(`mailbox ${mailbox.databaseId} patched`, { - mailbox, - updated, + commit('updateMailbox', { + mailbox: updated, + }) + logger.debug(`mailbox ${mailbox.databaseId} patched`, { + mailbox, + updated, + }) }) }, async showMessageComposer({ commit, dispatch, getters }, { type = 'imap', data = {}, reply, forwardedMessages = [], templateMessageId }) { - if (reply) { - const original = await dispatch('fetchMessage', reply.data.databaseId) - - // Fetch and transform the body into a rich text object - if (original.hasHtmlBody) { - const resp = await Axios.get( - generateUrl('/apps/mail/api/messages/{id}/html?plain=true', { - id: original.databaseId, + return handleHttpAuthErrors(commit, async () => { + if (reply) { + const original = await dispatch('fetchMessage', reply.data.databaseId) + + // Fetch and transform the body into a rich text object + if (original.hasHtmlBody) { + const resp = await Axios.get( + generateUrl('/apps/mail/api/messages/{id}/html?plain=true', { + id: original.databaseId, + }) + ) + + resp.data = DOMPurify.sanitize(resp.data, { + FORBID_TAGS: ['style'], }) - ) - resp.data = DOMPurify.sanitize(resp.data, { - FORBID_TAGS: ['style'], - }) + data.body = html(resp.data) + } else { + data.body = plain(original.body) + } - data.body = html(resp.data) - } else { - data.body = plain(original.body) - } + if (reply.mode === 'reply') { + logger.debug('Show simple reply composer', { reply }) + commit('showMessageComposer', { + data: { + accountId: reply.data.accountId, + to: reply.data.from, + cc: [], + subject: buildReplySubject(reply.data.subject), + body: data.body, + originalBody: data.body, + replyTo: reply.data, + }, + }) + return + } else if (reply.mode === 'replyAll') { + logger.debug('Show reply all reply composer', { reply }) + const account = getters.getAccount(reply.data.accountId) + const recipients = buildReplyRecipients(reply.data, { + email: account.emailAddress, + label: account.name, + }) + commit('showMessageComposer', { + data: { + accountId: reply.data.accountId, + to: recipients.to, + cc: recipients.cc, + subject: buildReplySubject(reply.data.subject), + body: data.body, + originalBody: data.body, + replyTo: reply.data, + }, + }) + return + } else if (reply.mode === 'forward') { + logger.debug('Show forward composer', { reply }) + commit('showMessageComposer', { + data: { + accountId: reply.data.accountId, + to: [], + cc: [], + subject: buildForwardSubject(reply.data.subject), + body: data.body, + originalBody: data.body, + forwardFrom: reply.data, + attachments: original.attachments.map(attachment => ({ + ...attachment, + mailboxId: original.mailboxId, + // messageId for attachments is actually the uid + uid: attachment.messageId, + type: 'message-attachment', + })), + }, + }) + return + } + } else if (templateMessageId) { + const message = await dispatch('fetchMessage', templateMessageId) + // Merge the original into any existing data + data = { + ...data, + message, + } - if (reply.mode === 'reply') { - logger.debug('Show simple reply composer', { reply }) - commit('showMessageComposer', { - data: { - accountId: reply.data.accountId, - to: reply.data.from, - cc: [], - subject: buildReplySubject(reply.data.subject), - body: data.body, - originalBody: data.body, - replyTo: reply.data, - }, - }) - return - } else if (reply.mode === 'replyAll') { - logger.debug('Show reply all reply composer', { reply }) - const account = getters.getAccount(reply.data.accountId) - const recipients = buildReplyRecipients(reply.data, { - email: account.emailAddress, - label: account.name, - }) - commit('showMessageComposer', { - data: { - accountId: reply.data.accountId, - to: recipients.to, - cc: recipients.cc, - subject: buildReplySubject(reply.data.subject), - body: data.body, - originalBody: data.body, - replyTo: reply.data, - }, - }) - return - } else if (reply.mode === 'forward') { - logger.debug('Show forward composer', { reply }) - commit('showMessageComposer', { - data: { - accountId: reply.data.accountId, - to: [], - cc: [], - subject: buildForwardSubject(reply.data.subject), - body: data.body, - originalBody: data.body, - forwardFrom: reply.data, - attachments: original.attachments.map(attachment => ({ - ...attachment, - mailboxId: original.mailboxId, - // messageId for attachments is actually the uid - uid: attachment.messageId, - type: 'message-attachment', - })), - }, - }) - return - } - } else if (templateMessageId) { - const message = await dispatch('fetchMessage', templateMessageId) - // Merge the original into any existing data - data = { - ...data, - message, - } + // Fetch and transform the body into a rich text object + if (message.hasHtmlBody) { + const resp = await Axios.get( + generateUrl('/apps/mail/api/messages/{id}/html?plain=true', { + id: templateMessageId, + }) + ) - // Fetch and transform the body into a rich text object - if (message.hasHtmlBody) { - const resp = await Axios.get( - generateUrl('/apps/mail/api/messages/{id}/html?plain=true', { - id: templateMessageId, + resp.data = DOMPurify.sanitize(resp.data, { + FORBID_TAGS: ['style'], }) - ) - resp.data = DOMPurify.sanitize(resp.data, { - FORBID_TAGS: ['style'], - }) + data.body = html(resp.data) + } else { + data.body = plain(message.body) + } - data.body = html(resp.data) - } else { - data.body = plain(message.body) + // TODO: implement attachments + if (message.attachments.length) { + showWarning(t('mail', 'Attachments were not copied. Please add them manually.')) + } } - // TODO: implement attachments - if (message.attachments.length) { - showWarning(t('mail', 'Attachments were not copied. Please add them manually.')) - } - } - - // Stop schedule when editing outbox messages and backup sendAt timestamp - let originalSendAt - if (type === 'outbox' && data.id && data.sendAt) { - originalSendAt = data.sendAt - const message = { - ...data, - body: data.isHtml ? data.body.value : toPlain(data.body).value, + // Stop schedule when editing outbox messages and backup sendAt timestamp + let originalSendAt + if (type === 'outbox' && data.id && data.sendAt) { + originalSendAt = data.sendAt + const message = { + ...data, + body: data.isHtml ? data.body.value : toPlain(data.body).value, + } + await dispatch('outbox/stopMessage', { message }) } - await dispatch('outbox/stopMessage', { message }) - } - - commit('showMessageComposer', { - type, - data, - forwardedMessages, - templateMessageId, - originalSendAt, + + commit('showMessageComposer', { + type, + data, + forwardedMessages, + templateMessageId, + originalSendAt, + }) }) }, async closeMessageComposer({ commit, dispatch, getters }, { restoreOriginalSendAt }) { - // Restore original sendAt timestamp when requested - const message = getters.composerMessage - if (restoreOriginalSendAt && message.type === 'outbox' && message.options?.originalSendAt) { - const body = message.data.body - await dispatch('outbox/updateMessage', { - id: message.data.id, - message: { - ...message.data, - body: message.data.isHtml ? body.value : toPlain(body).value, - sendAt: message.options.originalSendAt, - }, - }) - } + return handleHttpAuthErrors(commit, async () => { + // Restore original sendAt timestamp when requested + const message = getters.composerMessage + if (restoreOriginalSendAt && message.type === 'outbox' && message.options?.originalSendAt) { + const body = message.data.body + await dispatch('outbox/updateMessage', { + id: message.data.id, + message: { + ...message.data, + body: message.data.isHtml ? body.value : toPlain(body).value, + sendAt: message.options.originalSendAt, + }, + }) + } - commit('hideMessageComposer') + commit('hideMessageComposer') + }) }, async fetchEnvelope({ commit, getters }, id) { - const cached = getters.getEnvelope(id) - if (cached) { - logger.debug(`using cached value for envelope ${id}`) - return cached - } - - const envelope = await fetchEnvelope(id) - // Only commit if not undefined (not found) - if (envelope) { - commit('addEnvelope', { - envelope, - }) - } + return handleHttpAuthErrors(commit, async () => { + const cached = getters.getEnvelope(id) + if (cached) { + logger.debug(`using cached value for envelope ${id}`) + return cached + } + + const envelope = await fetchEnvelope(id) + // Only commit if not undefined (not found) + if (envelope) { + commit('addEnvelope', { + envelope, + }) + } - // Always use the object from the store - return getters.getEnvelope(id) + // Always use the object from the store + return getters.getEnvelope(id) + }) }, fetchEnvelopes({ state, commit, getters, dispatch }, { mailboxId, query, addToUnifiedMailboxes = true }) { - const mailbox = getters.getMailbox(mailboxId) + return handleHttpAuthErrors(commit, async () => { + const mailbox = getters.getMailbox(mailboxId) - if (mailbox.isUnified) { - const fetchIndividualLists = pipe( - map((mb) => - dispatch('fetchEnvelopes', { - mailboxId: mb.databaseId, - query, - addToUnifiedMailboxes: false, - }) - ), - Promise.all.bind(Promise), - andThen(map(sliceToPage)) - ) - const fetchUnifiedEnvelopes = pipe( - findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole), - fetchIndividualLists, - andThen(combineEnvelopeLists), - andThen(sliceToPage), + if (mailbox.isUnified) { + const fetchIndividualLists = pipe( + map((mb) => + dispatch('fetchEnvelopes', { + mailboxId: mb.databaseId, + query, + addToUnifiedMailboxes: false, + }) + ), + Promise.all.bind(Promise), + andThen(map(sliceToPage)) + ) + const fetchUnifiedEnvelopes = pipe( + findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole), + fetchIndividualLists, + andThen(combineEnvelopeLists), + andThen(sliceToPage), + andThen( + tap( + map((envelope) => + commit('addEnvelope', { + envelope, + query, + }) + ) + ) + ) + ) + + return fetchUnifiedEnvelopes(getters.accounts) + } + + return pipe( + fetchEnvelopes, andThen( tap( map((envelope) => commit('addEnvelope', { - envelope, query, + envelope, + addToUnifiedMailboxes, }) ) ) ) - ) - - return fetchUnifiedEnvelopes(getters.accounts) - } - - return pipe( - fetchEnvelopes, - andThen( - tap( - map((envelope) => - commit('addEnvelope', { - query, - envelope, - addToUnifiedMailboxes, - }) - ) - ) - ) - )(mailbox.accountId, mailboxId, query, undefined, PAGE_SIZE) + )(mailbox.accountId, mailboxId, query, undefined, PAGE_SIZE) + }) }, async fetchNextEnvelopePage({ commit, getters, dispatch }, { mailboxId, query }) { - const envelopes = await dispatch('fetchNextEnvelopes', { - mailboxId, - query, - quantity: PAGE_SIZE, + return handleHttpAuthErrors(commit, async () => { + const envelopes = await dispatch('fetchNextEnvelopes', { + mailboxId, + query, + quantity: PAGE_SIZE, + }) + return envelopes }) - return envelopes }, async fetchNextEnvelopes({ commit, getters, dispatch }, { mailboxId, query, quantity, rec = true, addToUnifiedMailboxes = true }) { - const mailbox = getters.getMailbox(mailboxId) - - if (mailbox.isUnified) { - const getIndivisualLists = curry((query, m) => getters.getEnvelopes(m.databaseId, query)) - const individualCursor = curry((query, m) => - prop('dateInt', last(getters.getEnvelopes(m.databaseId, query))) - ) - const cursor = individualCursor(query, mailbox) + return handleHttpAuthErrors(commit, async () => { + const mailbox = getters.getMailbox(mailboxId) - if (cursor === undefined) { - throw new Error('Unified list has no tail') - } - const nextLocalUnifiedEnvelopes = pipe( - findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole), - map(getIndivisualLists(query)), - combineEnvelopeLists, - filter( - where({ - dateInt: gt(cursor), - }) - ), - slice(0, quantity) - ) - // We know the next envelopes based on local data - // We have to fetch individual envelopes only if it ends in the known - // next fetch. If it ended before, there is no data to fetch anyway. If - // it ends after, we have all the relevant data already - const needsFetch = curry((query, nextEnvelopes, mb) => { - const c = individualCursor(query, mb) - return nextEnvelopes.length < quantity || c >= head(nextEnvelopes).dateInt || c <= last(nextEnvelopes).dateInt - }) + if (mailbox.isUnified) { + const getIndivisualLists = curry((query, m) => getters.getEnvelopes(m.databaseId, query)) + const individualCursor = curry((query, m) => + prop('dateInt', last(getters.getEnvelopes(m.databaseId, query))) + ) + const cursor = individualCursor(query, mailbox) - const mailboxesToFetch = (accounts) => - pipe( + if (cursor === undefined) { + throw new Error('Unified list has no tail') + } + const nextLocalUnifiedEnvelopes = pipe( findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole), - filter(needsFetch(query, nextLocalUnifiedEnvelopes(accounts))) - )(accounts) - const mbs = mailboxesToFetch(getters.accounts) - - if (rec && mbs.length) { - logger.debug('not enough local envelopes for the next unified page. ' + mbs.length + ' fetches required', { - mailboxes: mbs.map(mb => mb.databaseId), - }) - return pipe( - map((mb) => - dispatch('fetchNextEnvelopes', { - mailboxId: mb.databaseId, - query, - quantity, - addToUnifiedMailboxes: false, + map(getIndivisualLists(query)), + combineEnvelopeLists, + filter( + where({ + dateInt: gt(cursor), }) ), - Promise.all.bind(Promise), - andThen(() => - dispatch('fetchNextEnvelopes', { - mailboxId, - query, - quantity, - rec: false, - addToUnifiedMailboxes: false, - }) - ) - )(mbs) + slice(0, quantity) + ) + // We know the next envelopes based on local data + // We have to fetch individual envelopes only if it ends in the known + // next fetch. If it ended before, there is no data to fetch anyway. If + // it ends after, we have all the relevant data already + const needsFetch = curry((query, nextEnvelopes, mb) => { + const c = individualCursor(query, mb) + return nextEnvelopes.length < quantity || c >= head(nextEnvelopes).dateInt || c <= last(nextEnvelopes).dateInt + }) + + const mailboxesToFetch = (accounts) => + pipe( + findIndividualMailboxes(getters.getMailboxes, mailbox.specialRole), + filter(needsFetch(query, nextLocalUnifiedEnvelopes(accounts))) + )(accounts) + const mbs = mailboxesToFetch(getters.accounts) + + if (rec && mbs.length) { + logger.debug('not enough local envelopes for the next unified page. ' + mbs.length + ' fetches required', { + mailboxes: mbs.map(mb => mb.databaseId), + }) + return pipe( + map((mb) => + dispatch('fetchNextEnvelopes', { + mailboxId: mb.databaseId, + query, + quantity, + addToUnifiedMailboxes: false, + }) + ), + Promise.all.bind(Promise), + andThen(() => + dispatch('fetchNextEnvelopes', { + mailboxId, + query, + quantity, + rec: false, + addToUnifiedMailboxes: false, + }) + ) + )(mbs) + } + + const envelopes = nextLocalUnifiedEnvelopes(getters.accounts) + logger.debug('next unified page can be built locally and consists of ' + envelopes.length + ' envelopes', { addToUnifiedMailboxes }) + envelopes.map((envelope) => + commit('addEnvelope', { + query, + envelope, + }) + ) + return envelopes } - const envelopes = nextLocalUnifiedEnvelopes(getters.accounts) - logger.debug('next unified page can be built locally and consists of ' + envelopes.length + ' envelopes', { addToUnifiedMailboxes }) - envelopes.map((envelope) => - commit('addEnvelope', { - query, - envelope, - }) - ) - return envelopes - } - - const list = mailbox.envelopeLists[normalizedEnvelopeListId(query)] - if (list === undefined) { - console.warn("envelope list is not defined, can't fetch next envelopes", mailboxId, query) - return Promise.resolve([]) - } - const lastEnvelopeId = last(list) - if (typeof lastEnvelopeId === 'undefined') { - console.error('mailbox is empty', list) - return Promise.reject(new Error('Local mailbox has no envelopes, cannot determine cursor')) - } - const lastEnvelope = getters.getEnvelope(lastEnvelopeId) - if (typeof lastEnvelope === 'undefined') { - return Promise.reject(new Error('Cannot find last envelope. Required for the mailbox cursor')) - } - - return fetchEnvelopes(mailbox.accountId, mailboxId, query, lastEnvelope.dateInt, quantity).then((envelopes) => { - logger.debug(`fetched ${envelopes.length} messages for mailbox ${mailboxId}`, { - envelopes, - addToUnifiedMailboxes, - }) - envelopes.forEach((envelope) => - commit('addEnvelope', { - query, - envelope, + const list = mailbox.envelopeLists[normalizedEnvelopeListId(query)] + if (list === undefined) { + console.warn("envelope list is not defined, can't fetch next envelopes", mailboxId, query) + return Promise.resolve([]) + } + const lastEnvelopeId = last(list) + if (typeof lastEnvelopeId === 'undefined') { + console.error('mailbox is empty', list) + return Promise.reject(new Error('Local mailbox has no envelopes, cannot determine cursor')) + } + const lastEnvelope = getters.getEnvelope(lastEnvelopeId) + if (typeof lastEnvelope === 'undefined') { + return Promise.reject(new Error('Cannot find last envelope. Required for the mailbox cursor')) + } + + return fetchEnvelopes(mailbox.accountId, mailboxId, query, lastEnvelope.dateInt, quantity).then((envelopes) => { + logger.debug(`fetched ${envelopes.length} messages for mailbox ${mailboxId}`, { + envelopes, addToUnifiedMailboxes, }) - ) - return envelopes + envelopes.forEach((envelope) => + commit('addEnvelope', { + query, + envelope, + addToUnifiedMailboxes, + }) + ) + return envelopes + }) }) }, syncEnvelopes({ commit, getters, dispatch }, { mailboxId, query, init = false }) { - logger.debug(`starting mailbox sync of ${mailboxId} (${query})`) + return handleHttpAuthErrors(commit, async () => { + logger.debug(`starting mailbox sync of ${mailboxId} (${query})`) - const mailbox = getters.getMailbox(mailboxId) + const mailbox = getters.getMailbox(mailboxId) - if (mailbox.isUnified) { - return Promise.all( - getters.accounts - .filter((account) => !account.isUnified) - .map((account) => - Promise.all( - getters - .getMailboxes(account.id) - .filter((mb) => mb.specialRole === mailbox.specialRole) - .map((mailbox) => - dispatch('syncEnvelopes', { - mailboxId: mailbox.databaseId, - query, - init, - }) - ) + if (mailbox.isUnified) { + return Promise.all( + getters.accounts + .filter((account) => !account.isUnified) + .map((account) => + Promise.all( + getters + .getMailboxes(account.id) + .filter((mb) => mb.specialRole === mailbox.specialRole) + .map((mailbox) => + dispatch('syncEnvelopes', { + mailboxId: mailbox.databaseId, + query, + init, + }) + ) + ) ) - ) - ) - } else if (mailbox.isPriorityInbox && query === undefined) { - return Promise.all( - getPrioritySearchQueries().map((query) => { - return Promise.all( - getters.accounts - .filter((account) => !account.isUnified) - .map((account) => - Promise.all( - getters - .getMailboxes(account.id) - .filter((mb) => mb.specialRole === mailbox.specialRole) - .map((mailbox) => - dispatch('syncEnvelopes', { - mailboxId: mailbox.databaseId, - query, - init, - }) - ) + ) + } else if (mailbox.isPriorityInbox && query === undefined) { + return Promise.all( + getPrioritySearchQueries().map((query) => { + return Promise.all( + getters.accounts + .filter((account) => !account.isUnified) + .map((account) => + Promise.all( + getters + .getMailboxes(account.id) + .filter((mb) => mb.specialRole === mailbox.specialRole) + .map((mailbox) => + dispatch('syncEnvelopes', { + mailboxId: mailbox.databaseId, + query, + init, + }) + ) + ) ) - ) - ) - }) - ) - } + ) + }) + ) + } - const ids = getters.getEnvelopes(mailboxId, query).map((env) => env.databaseId) - logger.debug(`mailbox sync of ${mailboxId} (${query}) has ${ids.length} known IDs`) - return syncEnvelopes(mailbox.accountId, mailboxId, ids, query, init) - .then((syncData) => { - logger.debug(`mailbox ${mailboxId} (${query}) synchronized, ${syncData.newMessages.length} new, ${syncData.changedMessages.length} changed and ${syncData.vanishedMessages.length} vanished messages`) + const ids = getters.getEnvelopes(mailboxId, query).map((env) => env.databaseId) + logger.debug(`mailbox sync of ${mailboxId} (${query}) has ${ids.length} known IDs`) + return syncEnvelopes(mailbox.accountId, mailboxId, ids, query, init) + .then((syncData) => { + logger.debug(`mailbox ${mailboxId} (${query}) synchronized, ${syncData.newMessages.length} new, ${syncData.changedMessages.length} changed and ${syncData.vanishedMessages.length} vanished messages`) - const unifiedMailbox = getters.getUnifiedMailbox(mailbox.specialRole) + const unifiedMailbox = getters.getUnifiedMailbox(mailbox.specialRole) - syncData.newMessages.forEach((envelope) => { - commit('addEnvelope', { - envelope, - query, + syncData.newMessages.forEach((envelope) => { + commit('addEnvelope', { + envelope, + query, + }) + if (unifiedMailbox) { + commit('updateEnvelope', { + envelope, + }) + } }) - if (unifiedMailbox) { + syncData.changedMessages.forEach((envelope) => { commit('updateEnvelope', { envelope, }) - } - }) - syncData.changedMessages.forEach((envelope) => { - commit('updateEnvelope', { - envelope, }) - }) - syncData.vanishedMessages.forEach((id) => { - commit('removeEnvelope', { - id, + syncData.vanishedMessages.forEach((id) => { + commit('removeEnvelope', { + id, + }) + // Already removed from unified inbox + }) + + commit('setMailboxUnreadCount', { + id: mailboxId, + unread: syncData.stats.unread, }) - // Already removed from unified inbox - }) - commit('setMailboxUnreadCount', { - id: mailboxId, - unread: syncData.stats.unread, + return syncData.newMessages }) + .catch((error) => { + return matchError(error, { + [SyncIncompleteError.getName()]() { + console.warn(`(initial) sync of mailbox ${mailboxId} (${query}) is incomplete, retriggering`) + return dispatch('syncEnvelopes', { + mailboxId, + query, + init, + }) + }, + [MailboxLockedError.getName()](error) { + if (init) { + logger.info('Sync failed because the mailbox is locked, stopping here because this is an initial sync', { error }) + throw error + } - return syncData.newMessages - }) - .catch((error) => { - return matchError(error, { - [SyncIncompleteError.getName()]() { - console.warn(`(initial) sync of mailbox ${mailboxId} (${query}) is incomplete, retriggering`) - return dispatch('syncEnvelopes', { mailboxId, query, init }) - }, - [MailboxLockedError.getName()](error) { - if (init) { - logger.info('Sync failed because the mailbox is locked, stopping here because this is an initial sync', { error }) + logger.info('Sync failed because the mailbox is locked, retriggering', { error }) + return wait(1500).then(() => dispatch('syncEnvelopes', { + mailboxId, + query, + init, + })) + }, + default(error) { + console.error('Could not sync envelopes: ' + error.message, error) throw error - } - - logger.info('Sync failed because the mailbox is locked, retriggering', { error }) - return wait(1500).then(() => dispatch('syncEnvelopes', { mailboxId, query, init })) - }, - default(error) { - console.error('Could not sync envelopes: ' + error.message, error) - }, + }, + }) }) - }) + }) }, - async syncInboxes({ getters, dispatch }) { - const results = await Promise.all( - getters.accounts - .filter((a) => !a.isUnified) - .map((account) => { - return Promise.all( - getters.getMailboxes(account.id).map(async (mailbox) => { - if (mailbox.specialRole !== 'inbox') { - return - } + async syncInboxes({ commit, getters, dispatch }) { + return handleHttpAuthErrors(commit, async () => { + const results = await Promise.all( + getters.accounts + .filter((a) => !a.isUnified) + .map((account) => { + return Promise.all( + getters.getMailboxes(account.id).map(async (mailbox) => { + if (mailbox.specialRole !== 'inbox') { + return + } + + const list = mailbox.envelopeLists[normalizedEnvelopeListId(undefined)] + if (list === undefined) { + await dispatch('fetchEnvelopes', { + mailboxId: mailbox.databaseId, + }) + } - const list = mailbox.envelopeLists[normalizedEnvelopeListId(undefined)] - if (list === undefined) { - await dispatch('fetchEnvelopes', { + return await dispatch('syncEnvelopes', { mailboxId: mailbox.databaseId, }) - } - - return await dispatch('syncEnvelopes', { - mailboxId: mailbox.databaseId, }) + ) + }) + ) + const newMessages = flatMapDeep(identity, results).filter((m) => m !== undefined) + if (newMessages.length === 0) { + return + } + + try { + // Make sure the priority inbox is updated as well + logger.info('updating priority inbox') + for (const query of [priorityImportantQuery, priorityOtherQuery]) { + logger.info("sync'ing priority inbox section", { query }) + const mailbox = getters.getMailbox(UNIFIED_INBOX_ID) + const list = mailbox.envelopeLists[normalizedEnvelopeListId(query)] + if (list === undefined) { + await dispatch('fetchEnvelopes', { + mailboxId: UNIFIED_INBOX_ID, + query, }) - ) - }) - ) - const newMessages = flatMapDeep(identity, results).filter((m) => m !== undefined) - if (newMessages.length === 0) { - return - } - - try { - // Make sure the priority inbox is updated as well - logger.info('updating priority inbox') - for (const query of [priorityImportantQuery, priorityOtherQuery]) { - logger.info("sync'ing priority inbox section", { query }) - const mailbox = getters.getMailbox(UNIFIED_INBOX_ID) - const list = mailbox.envelopeLists[normalizedEnvelopeListId(query)] - if (list === undefined) { - await dispatch('fetchEnvelopes', { + } + + await dispatch('syncEnvelopes', { mailboxId: UNIFIED_INBOX_ID, query, }) } - - await dispatch('syncEnvelopes', { - mailboxId: UNIFIED_INBOX_ID, - query, - }) + } finally { + showNewMessagesNotification(newMessages) } - } finally { - showNewMessagesNotification(newMessages) - } + }) }, toggleEnvelopeFlagged({ commit, getters }, envelope) { - // Change immediately and switch back on error - const oldState = envelope.flags.flagged - commit('flagEnvelope', { - envelope, - flag: 'flagged', - value: !oldState, - }) - - setEnvelopeFlag(envelope.databaseId, 'flagged', !oldState).catch((e) => { - console.error('could not toggle message flagged state', e) - - // Revert change + return handleHttpAuthErrors(commit, async () => { + // Change immediately and switch back on error + const oldState = envelope.flags.flagged commit('flagEnvelope', { envelope, flag: 'flagged', - value: oldState, + value: !oldState, }) + + try { + await setEnvelopeFlag(envelope.databaseId, 'flagged', !oldState) + } catch (error) { + logger.error('Could not toggle message flagged state', { error }) + + // Revert change + commit('flagEnvelope', { + envelope, + flag: 'flagged', + value: oldState, + }) + + throw error + } }) }, - async toggleEnvelopeImportant({ dispatch, getters }, envelope) { - const importantLabel = '$label1' - const hasTag = getters - .getEnvelopeTags(envelope.databaseId) - .some((tag) => tag.imapLabel === importantLabel) - if (hasTag) { - await dispatch('removeEnvelopeTag', { - envelope, - imapLabel: importantLabel, - }) - } else { - await dispatch('addEnvelopeTag', { - envelope, - imapLabel: importantLabel, - }) - } + async toggleEnvelopeImportant({ commit, dispatch, getters }, envelope) { + return handleHttpAuthErrors(commit, async () => { + const importantLabel = '$label1' + const hasTag = getters + .getEnvelopeTags(envelope.databaseId) + .some((tag) => tag.imapLabel === importantLabel) + if (hasTag) { + await dispatch('removeEnvelopeTag', { + envelope, + imapLabel: importantLabel, + }) + } else { + await dispatch('addEnvelopeTag', { + envelope, + imapLabel: importantLabel, + }) + } + }) }, async toggleEnvelopeSeen({ commit, getters }, { envelope, seen }) { - // Change immediately and switch back on error - const oldState = envelope.flags.seen - const newState = seen === undefined ? !oldState : seen - commit('flagEnvelope', { - envelope, - flag: 'seen', - value: newState, - }) - - try { - await setEnvelopeFlag(envelope.databaseId, 'seen', newState) - } catch (error) { - console.error('could not toggle message seen state', error) - - // Revert change + return handleHttpAuthErrors(commit, async () => { + // Change immediately and switch back on error + const oldState = envelope.flags.seen + const newState = seen === undefined ? !oldState : seen commit('flagEnvelope', { envelope, flag: 'seen', - value: oldState, + value: newState, }) - } - }, - async toggleEnvelopeJunk({ commit, getters }, envelope) { - // Change immediately and switch back on error - const oldState = envelope.flags.$junk - commit('flagEnvelope', { - envelope, - flag: '$junk', - value: !oldState, - }) - commit('flagEnvelope', { - envelope, - flag: '$notjunk', - value: oldState, - }) - try { - await setEnvelopeFlag(envelope.databaseId, '$junk', !oldState) - await setEnvelopeFlag(envelope.databaseId, '$notjunk', oldState) - } catch (error) { - console.error('could not toggle message junk state', error) + try { + await setEnvelopeFlag(envelope.databaseId, 'seen', newState) + } catch (error) { + console.error('could not toggle message seen state', error) + + // Revert change + commit('flagEnvelope', { + envelope, + flag: 'seen', + value: oldState, + }) - // Revert change + throw error + } + }) + }, + async toggleEnvelopeJunk({ commit, getters }, envelope) { + return handleHttpAuthErrors(commit, async () => { + // Change immediately and switch back on error + const oldState = envelope.flags.$junk commit('flagEnvelope', { envelope, flag: '$junk', - value: oldState, + value: !oldState, }) commit('flagEnvelope', { envelope, flag: '$notjunk', - value: !oldState, + value: oldState, }) - } - }, - async markEnvelopeFavoriteOrUnfavorite({ commit, getters }, { envelope, favFlag }) { - // Change immediately and switch back on error - const oldState = envelope.flags.flagged - commit('flagEnvelope', { - envelope, - flag: 'flagged', - value: favFlag, - }) - try { - await setEnvelopeFlag(envelope.databaseId, 'flagged', favFlag) - } catch (error) { - console.error('could not favorite/unfavorite message ' + envelope.uid, error) + try { + await setEnvelopeFlag(envelope.databaseId, '$junk', !oldState) + await setEnvelopeFlag(envelope.databaseId, '$notjunk', oldState) + } catch (error) { + console.error('could not toggle message junk state', error) + + // Revert change + commit('flagEnvelope', { + envelope, + flag: '$junk', + value: oldState, + }) + commit('flagEnvelope', { + envelope, + flag: '$notjunk', + value: !oldState, + }) - // Revert change + throw error + } + }) + }, + async markEnvelopeFavoriteOrUnfavorite({ commit, getters }, { envelope, favFlag }) { + return handleHttpAuthErrors(commit, async () => { + // Change immediately and switch back on error + const oldState = envelope.flags.flagged commit('flagEnvelope', { envelope, flag: 'flagged', - value: oldState, + value: favFlag, }) - } + + try { + await setEnvelopeFlag(envelope.databaseId, 'flagged', favFlag) + } catch (error) { + console.error('could not favorite/unfavorite message ' + envelope.uid, error) + + // Revert change + commit('flagEnvelope', { + envelope, + flag: 'flagged', + value: oldState, + }) + + throw error + } + }) }, - async markEnvelopeImportantOrUnimportant({ dispatch, getters }, { envelope, addTag }) { - const importantLabel = '$label1' - const hasTag = getters - .getEnvelopeTags(envelope.databaseId) - .some((tag) => tag.imapLabel === importantLabel) - if (hasTag && !addTag) { - await dispatch('removeEnvelopeTag', { - envelope, - imapLabel: importantLabel, - }) - } else if (!hasTag && addTag) { - await dispatch('addEnvelopeTag', { - envelope, - imapLabel: importantLabel, - }) - } + async markEnvelopeImportantOrUnimportant({ commit, dispatch, getters }, { envelope, addTag }) { + return handleHttpAuthErrors(commit, async () => { + const importantLabel = '$label1' + const hasTag = getters + .getEnvelopeTags(envelope.databaseId) + .some((tag) => tag.imapLabel === importantLabel) + if (hasTag && !addTag) { + await dispatch('removeEnvelopeTag', { + envelope, + imapLabel: importantLabel, + }) + } else if (!hasTag && addTag) { + await dispatch('addEnvelopeTag', { + envelope, + imapLabel: importantLabel, + }) + } + }) }, async fetchThread({ getters, commit }, id) { - const thread = await fetchThread(id) - commit('addEnvelopeThread', { - id, - thread, + return handleHttpAuthErrors(commit, async () => { + const thread = await fetchThread(id) + commit('addEnvelopeThread', { + id, + thread, + }) + return thread }) - return thread }, async fetchMessage({ getters, commit }, id) { - const message = await fetchMessage(id) - // Only commit if not undefined (not found) - if (message) { - commit('addMessage', { - message, - }) - } - return message + return handleHttpAuthErrors(commit, async () => { + const message = await fetchMessage(id) + // Only commit if not undefined (not found) + if (message) { + commit('addMessage', { + message, + }) + } + return message + }) }, async fetchItineraries({ commit }, id) { - const itineraries = await fetchMessageItineraries(id) - commit('addMessageItineraries', { - id, - itineraries, + return handleHttpAuthErrors(commit, async () => { + const itineraries = await fetchMessageItineraries(id) + commit('addMessageItineraries', { + id, + itineraries, + }) + return itineraries }) - return itineraries }, async deleteMessage({ getters, commit }, { id }) { - commit('removeEnvelope', { id }) + return handleHttpAuthErrors(commit, async () => { + commit('removeEnvelope', { id }) - try { - await deleteMessage(id) - commit('removeMessage', { id }) - console.debug('message removed') - } catch (err) { - console.error('could not delete message', err) - const envelope = getters.getEnvelope(id) - if (envelope) { - commit('addEnvelope', { envelope }) - } else { - logger.error('could not find envelope', { id }) + try { + await deleteMessage(id) + commit('removeMessage', { id }) + console.debug('message removed') + } catch (err) { + console.error('could not delete message', err) + const envelope = getters.getEnvelope(id) + if (envelope) { + commit('addEnvelope', { envelope }) + } else { + logger.error('could not find envelope', { id }) + } + throw err } - throw err - } + }) }, async createAlias({ commit }, { account, alias, name }) { - const entity = await AliasService.createAlias(account.id, alias, name) - commit('createAlias', { - account, - alias: entity, + return handleHttpAuthErrors(commit, async () => { + const entity = await AliasService.createAlias(account.id, alias, name) + commit('createAlias', { + account, + alias: entity, + }) }) }, async deleteAlias({ commit }, { account, aliasId }) { - const entity = await AliasService.deleteAlias(account.id, aliasId) - commit('deleteAlias', { - account, - aliasId: entity.id, + return handleHttpAuthErrors(commit, async () => { + const entity = await AliasService.deleteAlias(account.id, aliasId) + commit('deleteAlias', { + account, + aliasId: entity.id, + }) }) }, async updateAlias({ commit }, { account, aliasId, alias, name }) { - const entity = await AliasService.updateAlias(account.id, aliasId, alias, name) - commit('patchAlias', { - account, - aliasId: entity.id, - data: { alias: entity.alias, name: entity.name }, + return handleHttpAuthErrors(commit, async () => { + const entity = await AliasService.updateAlias(account.id, aliasId, alias, name) + commit('patchAlias', { + account, + aliasId: entity.id, + data: { alias: entity.alias, name: entity.name }, + }) + commit('editAccount', account) }) - commit('editAccount', account) }, async updateAliasSignature({ commit }, { account, aliasId, signature }) { - const entity = await AliasService.updateSignature(account.id, aliasId, signature) - commit('patchAlias', { - account, - aliasId: entity.id, - data: { signature: entity.signature }, + return handleHttpAuthErrors(commit, async () => { + const entity = await AliasService.updateSignature(account.id, aliasId, signature) + commit('patchAlias', { + account, + aliasId: entity.id, + data: { signature: entity.signature }, + }) + commit('editAccount', account) }) - commit('editAccount', account) }, async renameMailbox({ commit }, { account, mailbox, newName }) { - const newMailbox = await patchMailbox(mailbox.databaseId, { - name: newName, - }) + return handleHttpAuthErrors(commit, async () => { + const newMailbox = await patchMailbox(mailbox.databaseId, { + name: newName, + }) - console.debug(`mailbox ${mailbox.databaseId} renamed to ${newName}`, { mailbox }) - commit('removeMailbox', { id: mailbox.databaseId }) - commit('addMailbox', { account, mailbox: newMailbox }) + console.debug(`mailbox ${mailbox.databaseId} renamed to ${newName}`, { mailbox }) + commit('removeMailbox', { id: mailbox.databaseId }) + commit('addMailbox', { account, mailbox: newMailbox }) + }) }, async moveMessage({ commit }, { id, destMailboxId }) { - await moveMessage(id, destMailboxId) - commit('removeEnvelope', { id }) - commit('removeMessage', { id }) + return handleHttpAuthErrors(commit, async () => { + await moveMessage(id, destMailboxId) + commit('removeEnvelope', { id }) + commit('removeMessage', { id }) + }) }, async fetchActiveSieveScript({ commit }, { accountId }) { - const scriptData = await getActiveScript(accountId) - commit('setActiveSieveScript', { accountId, scriptData }) + return handleHttpAuthErrors(commit, async () => { + const scriptData = await getActiveScript(accountId) + commit('setActiveSieveScript', { accountId, scriptData }) + }) }, async updateActiveSieveScript({ commit }, { accountId, scriptData }) { - await updateActiveScript(accountId, scriptData) - commit('setActiveSieveScript', { accountId, scriptData }) + return handleHttpAuthErrors(commit, async () => { + await updateActiveScript(accountId, scriptData) + commit('setActiveSieveScript', { accountId, scriptData }) + }) }, async updateSieveAccount({ commit }, { account, data }) { - logger.debug(`update sieve settings for account ${account.id}`) - try { - await updateSieveAccount(account.id, data) - commit('patchAccount', { account, data }) - } catch (error) { - logger.error('failed to update sieve account: ', { error }) - throw error - } + return handleHttpAuthErrors(commit, async () => { + logger.debug(`update sieve settings for account ${account.id}`) + try { + await updateSieveAccount(account.id, data) + commit('patchAccount', { account, data }) + } catch (error) { + logger.error('failed to update sieve account: ', { error }) + throw error + } + }) }, async createTag({ commit }, { displayName, color }) { - const tag = await createEnvelopeTag(displayName, color) - commit('addTag', { tag }) + return handleHttpAuthErrors(commit, async () => { + const tag = await createEnvelopeTag(displayName, color) + commit('addTag', { tag }) + }) }, async addEnvelopeTag({ commit, getters }, { envelope, imapLabel }) { - // TODO: fetch tags indepently of envelopes and only send tag id here - const tag = await setEnvelopeTag(envelope.databaseId, imapLabel) - if (!getters.getTag(tag.id)) { - commit('addTag', { tag }) - } + return handleHttpAuthErrors(commit, async () => { + // TODO: fetch tags indepently of envelopes and only send tag id here + const tag = await setEnvelopeTag(envelope.databaseId, imapLabel) + if (!getters.getTag(tag.id)) { + commit('addTag', { tag }) + } - commit('addEnvelopeTag', { - envelope, - tagId: tag.id, + commit('addEnvelopeTag', { + envelope, + tagId: tag.id, + }) }) }, async removeEnvelopeTag({ commit }, { envelope, imapLabel }) { - const tag = await removeEnvelopeTag(envelope.databaseId, imapLabel) - commit('removeEnvelopeTag', { - envelope, - tagId: tag.id, + return handleHttpAuthErrors(commit, async () => { + const tag = await removeEnvelopeTag(envelope.databaseId, imapLabel) + commit('removeEnvelopeTag', { + envelope, + tagId: tag.id, + }) }) }, async updateTag({ commit }, { tag, displayName, color }) { - await updateEnvelopeTag(tag.id, displayName, color) - commit('updateTag', { tag, displayName, color }) - logger.debug('tag updated', { tag, displayName, color }) + return handleHttpAuthErrors(commit, async () => { + await updateEnvelopeTag(tag.id, displayName, color) + commit('updateTag', { tag, displayName, color }) + logger.debug('tag updated', { tag, displayName, color }) + }) }, async deleteThread({ getters, commit }, { envelope }) { - commit('removeEnvelope', { id: envelope.databaseId }) - - try { - await ThreadService.deleteThread(envelope.databaseId) - console.debug('thread removed') - } catch (e) { - commit('addEnvelope', envelope) - console.error('could not delete thread', e) - throw e - } + return handleHttpAuthErrors(commit, async () => { + commit('removeEnvelope', { id: envelope.databaseId }) + + try { + await ThreadService.deleteThread(envelope.databaseId) + console.debug('thread removed') + } catch (e) { + commit('addEnvelope', envelope) + console.error('could not delete thread', e) + throw e + } + }) }, async moveThread({ getters, commit }, { envelope, destMailboxId }) { - commit('removeEnvelope', { id: envelope.databaseId }) - - try { - await ThreadService.moveThread(envelope.databaseId, destMailboxId) - console.debug('thread removed') - } catch (e) { - commit('addEnvelope', envelope) - console.error('could not move thread', e) - throw e - } + return handleHttpAuthErrors(commit, async () => { + commit('removeEnvelope', { id: envelope.databaseId }) + + try { + await ThreadService.moveThread(envelope.databaseId, destMailboxId) + console.debug('thread removed') + } catch (e) { + commit('addEnvelope', envelope) + console.error('could not move thread', e) + throw e + } + }) }, /** @@ -1068,8 +1189,10 @@ export default { * @param {Function} context.commit Vuex store mutations */ async fetchCurrentUserPrincipal({ commit }) { - await initializeClientForUserView() - commit('setCurrentUserPrincipal', { currentUserPrincipal: getCurrentUserPrincipal() }) + return handleHttpAuthErrors(commit, async () => { + await initializeClientForUserView() + commit('setCurrentUserPrincipal', { currentUserPrincipal: getCurrentUserPrincipal() }) + }) }, /** @@ -1080,9 +1203,11 @@ export default { * @return {Promise<void>} */ async loadCollections({ commit }) { - const { calendars } = await findAll() - for (const calendar of calendars) { - commit('addCalendar', { calendar }) - } + await handleHttpAuthErrors(commit, async () => { + const { calendars } = await findAll() + for (const calendar of calendars) { + commit('addCalendar', { calendar }) + } + }) }, } diff --git a/src/store/getters.js b/src/store/getters.js index 56341e422..abc8fd2d1 100644 --- a/src/store/getters.js +++ b/src/store/getters.js @@ -30,6 +30,9 @@ export const getters = { getPreference: (state) => (key, def) => { return defaultTo(def, state.preferences[key]) }, + isExpiredSession: (state) => { + return state.isExpiredSession + }, getAccount: (state) => (id) => { return state.accounts[id] }, diff --git a/src/store/index.js b/src/store/index.js index e9fd46939..bd13f5d03 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -41,6 +41,7 @@ export default new Store({ root: { namespaced: false, state: { + isExpiredSession: false, preferences: {}, accounts: { [UNIFIED_ACCOUNT_ID]: { diff --git a/src/store/mutations.js b/src/store/mutations.js index b23931edc..80d180979 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -132,6 +132,9 @@ export default { savePreference(state, { key, value }) { Vue.set(state.preferences, key, value) }, + setSessionExpired(state) { + Vue.set(state, 'isExpiredSession', true) + }, addAccount(state, account) { account.collapsed = account.collapsed ?? true Vue.set(state.accounts, account.id, account) diff --git a/src/tests/unit/App.spec.js b/src/tests/unit/App.spec.js new file mode 100644 index 000000000..a33f7f645 --- /dev/null +++ b/src/tests/unit/App.spec.js @@ -0,0 +1,66 @@ +/* + * @copyright 2022 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2022 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import { createLocalVue, shallowMount } from '@vue/test-utils' + +import App from '../../App' +import Nextcloud from '../../mixins/Nextcloud' +import Vuex from 'vuex' + +const localVue = createLocalVue() +localVue.use(Vuex) +localVue.mixin(Nextcloud) + +jest.mock('../../service/AutoConfigService') + +describe('App', () => { + + let state + let getters + let store + let view + + beforeEach(() => { + state = { isExpiredSession: false }; + getters = { + isExpiredSession: (state) => state.isExpiredSession, + } + store = new Vuex.Store({ + getters, + state, + }) + + view = shallowMount(App, { + store, + localVue, + }) + }) + + it('handles session expiry', async() => { + // Stub and prevent the actual reload + view.vm.reload = jest.fn() + + expect(view.vm.isExpiredSession).toBe(false) + state.isExpiredSession = true + expect(view.vm.isExpiredSession).toBe(true) + }) + +}) diff --git a/src/tests/unit/http/sessionExpiryHandler.spec.js b/src/tests/unit/http/sessionExpiryHandler.spec.js new file mode 100644 index 000000000..0bcbde213 --- /dev/null +++ b/src/tests/unit/http/sessionExpiryHandler.spec.js @@ -0,0 +1,82 @@ +/* + * @copyright 2022 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2022 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import { handleHttpAuthErrors } from '../../../http/sessionExpiryHandler' + +describe('sessionExpiryHandler', () => { + it('does not influence successful requests', async () => { + const commit = jest.fn() + + await handleHttpAuthErrors(commit, () => {}) + + expect(commit).not.toHaveBeenCalled() + }) + + it('ignores other 401s', async () => { + const commit = jest.fn() + let exception + + try { + await handleHttpAuthErrors(commit, () => { + throw { + response: { + status: 401, + data: { + message: 'Bonjour', + }, + }, + } + }) + } catch (e) { + exception = e + } + + // Is this our exception? + expect(exception.response?.status === 401) + expect(commit).not.toHaveBeenCalled() + }) + + it('handles relevant 401s', async () => { + const commit = jest.fn() + let exception + + try { + await handleHttpAuthErrors(commit, () => { + throw { + response: { + status: 401, + data: { + message: 'Current user is not logged in', + }, + }, + } + }) + } catch (e) { + exception = e + } + + // Is this our exception? + expect(exception.response?.status === 401) + expect(commit).toHaveBeenCalled() + }) + +}) + diff --git a/src/tests/unit/store/getters.spec.js b/src/tests/unit/store/getters.spec.js index f049a5ff7..f8af1ecf6 100644 --- a/src/tests/unit/store/getters.spec.js +++ b/src/tests/unit/store/getters.spec.js @@ -31,6 +31,7 @@ describe('Vuex store getters', () => { beforeEach(() => { state = { + isExpiredSession: false, accountList: [], accounts: {}, mailboxes: {}, @@ -43,6 +44,11 @@ describe('Vuex store getters', () => { bindGetters = () => mapObjIndexed(bindGetterToState(getters, state), getters) }) + it('gets session expiry', () => { + const getters = bindGetters() + + expect(getters.isExpiredSession).toEqual(false) + }) it('gets all accounts', () => { state.accountList.push('13') state.accounts[13] = { |