diff --git a/lib/account.js b/lib/account.js index 253c78ef..f2d4a18d 100644 --- a/lib/account.js +++ b/lib/account.js @@ -189,6 +189,27 @@ class Account { } break; + case 'path': + if (!accountData[key] || !accountData[key].length) { + break; + } + if (accountData[key].length > 1 && (/^"|"$/.test(accountData[key]) || /^\[|\]$/.test(accountData[key]))) { + // seems like JSON array or string + try { + let value = JSON.parse(accountData[key]); + if (value === null) { + break; + } + result[key] = value; + } catch (err) { + this.logger.error({ msg: 'Failed to parse input from Redis', key, value: accountData[key], err }); + } + } else { + // regular string + result[key] = accountData[key]; + } + break; + case 'subconnections': try { let value = JSON.parse(accountData[key]); @@ -301,12 +322,29 @@ class Account { } break; + case 'path': + if ( + accountData[key] === null || + accountData[key] === '*' || + (Array.isArray(accountData[key]) && (accountData[key].length === 0 || (accountData[key].length === 1 && accountData[key][0] === '*'))) + ) { + result[key] = ''; + } else { + try { + result[key] = JSON.stringify(accountData[key]); + } catch (err) { + this.logger.error({ msg: 'Failed to stringify input for Redis', key, err }); + } + } + break; + case 'subconnections': try { result[key] = JSON.stringify(accountData[key]); } catch (err) { this.logger.error({ msg: 'Failed to stringify input for Redis', key, err }); } + break; case 'imap': @@ -520,7 +558,7 @@ class Account { if ('path' in accountData && !reconnectRequested) { try { - strictEqual(oldAccountData.path || '*', accountData.path || '*'); + strictEqual(JSON.stringify(oldAccountData.path || '*'), JSON.stringify(accountData.path || '*')); } catch (err) { // changes detected! reconnectRequested = true; diff --git a/lib/connection.js b/lib/connection.js index 1e471d24..d5c54d41 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -390,9 +390,11 @@ class Connection { mailbox = false; } - async getCurrentListing(options) { + async getCurrentListing(options, allowSecondary) { this.checkIMAPConnection(); + const connectionClient = await this.getImapConnection(allowSecondary); + let accountData = await this.accountObject.loadAccountData(); let specialUseHints = {}; @@ -406,7 +408,7 @@ class Connection { specialUseHints }); - let listing = await this.imapClient.list(options); + let listing = await connectionClient.list(options); let inboxData = (listing || []).find(entry => /^INBOX$/i.test(entry.path)); if (inboxData && inboxData.delimiter) { @@ -489,6 +491,12 @@ class Connection { try { let accountData = await this.accountObject.loadAccountData(); + + const accountPaths = [].concat(accountData.path || '*'); + if (!accountPaths.length) { + accountPaths.push('*'); + } + let listing = await this.getCurrentListing(); let syncNeeded = new Set(); @@ -497,8 +505,8 @@ class Connection { // previously unseen !this.mailboxes.has(normalizePath(entry.path)) ) { - if (accountData.path && accountData.path !== '*') { - if (accountData.path !== entry.path && accountData.path !== entry.specialUse) { + if (!accountPaths.includes('*')) { + if (!accountPaths.includes(entry.path) && !accountPaths.includes(entry.specialUse)) { // ignore changes entry.syncDisabled = true; } @@ -546,12 +554,22 @@ class Connection { this.isGmail = imapClient.capabilities.has('X-GM-EXT-1') && listing.some(entry => entry.specialUse === '\\All'); this.isOutlook = /\boffice365\.com$/i.test(imapClient.host); // || /The Microsoft Exchange IMAP4 service is ready/.test(imapClient.greeting); + const accountPaths = [].concat(accountData.path || '*'); + if (!accountPaths.length) { + accountPaths.push('*'); + } + + // store synced folder entries + const mainList = []; + for (let entry of listing) { - if (accountData.path && accountData.path !== '*') { - if (accountData.path !== entry.path && accountData.path !== entry.specialUse) { + if (!accountPaths.includes('*')) { + if (!accountPaths.includes(entry.path) && !accountPaths.includes(entry.specialUse)) { entry.syncDisabled = true; } else { - this.main = entry; + // insert to stored list with the sorting index + let index = accountPaths.indexOf(entry.path) >= 0 ? accountPaths.indexOf(entry.path) : accountPaths.indexOf(entry.specialUse); + mainList.push({ index, entry }); } } else { if ((this.isGmail && entry.specialUse === '\\All') || (!this.isGmail && entry.specialUse === '\\Inbox')) { @@ -570,6 +588,11 @@ class Connection { this.mailboxes.set(normalizePath(entry.path), mailbox); } + if (mainList.length) { + // set the highest synced entry as the main folder + this.main = mainList.sort((a, b) => a.index - b.index)[0].entry; + } + // Process untagged EXISTS responses imapClient.on('exists', async event => { if (!event || !event.path || !this.mailboxes.has(normalizePath(event.path))) { @@ -1503,7 +1526,7 @@ class Connection { async listMailboxes(options) { this.checkIMAPConnection(); - return await this.getCurrentListing(options); + return await this.getCurrentListing(options, true); } async moveMessage(id, target) { @@ -1735,6 +1758,11 @@ class Connection { } runPostListing(accountData) { + const accountPaths = [].concat(accountData.path || '*'); + if (!accountPaths.length) { + accountPaths.push('*'); + } + this.getCurrentListing() .then(listing => { let syncNeeded = new Set(); @@ -1743,8 +1771,8 @@ class Connection { // previously unseen !this.mailboxes.has(normalizePath(entry.path)) ) { - if (accountData.path && accountData.path !== '*') { - if (accountData.path !== entry.path && accountData.path !== entry.specialUse) { + if (!accountPaths.includes('*')) { + if (!accountPaths.includes(entry.path) && !accountPaths.includes(entry.specialUse)) { // ignore changes entry.syncDisabled = true; } @@ -3365,7 +3393,7 @@ class Connection { if (err.mailboxMissing) { // this mailbox is missing, refresh listing try { - await this.connection.getCurrentListing(); + await this.getCurrentListing(false, true); } catch (E) { this.logger.error({ msg: 'Missing mailbox', err, E }); } @@ -3396,7 +3424,7 @@ class Connection { let mailboxes = []; - let listing = await this.getCurrentListing(); + let listing = await this.getCurrentListing(false, true); for (let path of accountData.subconnections || []) { let entry = listing.find(entry => path === entry.path || path === entry.specialUse); @@ -3405,16 +3433,21 @@ class Connection { continue; } - if (accountData.path && entry.path === accountData.path) { + const accountPaths = [].concat(accountData.path || '*'); + if (!accountPaths.length) { + accountPaths.push('*'); + } + + if (accountPaths.includes(entry.path)) { continue; } - if (this.isGmail && (!accountData.path || accountData.path === '*') && !['\\Trash', '\\Junk'].includes(entry.specialUse)) { + if (this.isGmail && accountPaths.includes('*') && !['\\Trash', '\\Junk'].includes(entry.specialUse)) { // no need to check this folder, as \All already covers it continue; } - if (!this.isGmail && (!accountData.path || accountData.path === '*') && entry.specialUse === '\\Inbox') { + if (!this.isGmail && accountPaths.includes('*') && entry.specialUse === '\\Inbox') { // already the default continue; } diff --git a/lib/routes-ui.js b/lib/routes-ui.js index b7e23ee4..f78e3710 100644 --- a/lib/routes-ui.js +++ b/lib/routes-ui.js @@ -6847,10 +6847,6 @@ ${Buffer.from(data.content, 'base64url').toString('base64')} accountData = formatAccountData(accountData); - if (accountData.path === '*') { - accountData.path = ''; - } - accountData.imap = accountData.imap || { disabled: !accountData.oauth2 }; @@ -6944,7 +6940,7 @@ ${Buffer.from(data.content, 'base64url').toString('base64')} redirectUrl: `/admin/accounts/${request.params.account}` }), - showAdvanced: accountData.path || accountData.proxy || accountData.webhooks, + showAdvanced: accountData.proxy || accountData.webhooks, subConnectionInfo, @@ -7283,7 +7279,6 @@ ${Buffer.from(data.content, 'base64url').toString('base64')} email: request.payload.email, proxy: request.payload.proxy, smtpEhloName: request.payload.smtpEhloName, - path: request.payload.path || '*', webhooks: request.payload.webhooks }; @@ -7457,8 +7452,6 @@ ${Buffer.from(data.content, 'base64url').toString('base64')} .example('https://myservice.com/imap/webhooks') .description('Account-specific webhook URL'), - path: Joi.string().empty('').max(1024).default('').example('INBOX').description('Check changes only on selected path'), - smtp: Joi.boolean().truthy('Y', 'true', '1', 'on').falsy('N', 'false', 0, '').default(false), smtp_auth_user: Joi.string().empty('').trim().max(1024), diff --git a/lib/schemas.js b/lib/schemas.js index 9585f30e..da887db6 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -1289,6 +1289,14 @@ const accountCountersSchema = Joi.object({ events: Joi.object().unknown().description('Lifetime event counters').label('AcountCountersEvents').example({ messageNew: 30, messageDeleted: 5 }) }).label('AccountCounters'); +const pathSchema = Joi.string().empty('').max(1024).example('INBOX'); +const accountPathSchema = Joi.alternatives() + .try(pathSchema, Joi.array().items(pathSchema).min(1)) + .allow(null) + .description( + 'Check changes only on selected paths. Either a single string path or an array of paths. Can use references like `"\\Sent"` or `"\\Inbox"`. Set to `null` to unset.' + ); + module.exports = { ADDRESS_STRATEGIES, @@ -1318,7 +1326,8 @@ module.exports = { tokenRestrictionsSchema, accountIdSchema, ipSchema, - accountCountersSchema + accountCountersSchema, + accountPathSchema }; /* diff --git a/workers/api.js b/workers/api.js index 0d69ad74..18285e23 100644 --- a/workers/api.js +++ b/workers/api.js @@ -155,7 +155,8 @@ const { tokenRestrictionsSchema, accountIdSchema, ipSchema, - accountCountersSchema + accountCountersSchema, + accountPathSchema } = require('../lib/schemas'); const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash']; @@ -2118,7 +2119,7 @@ When making API calls remember that requests against the same account are queued name: Joi.string().max(256).required().example('My Email Account').description('Display name for the account'), email: Joi.string().empty('').email().example('user@example.com').description('Default email address of the account'), - path: Joi.string().empty('').max(1024).default('*').example('INBOX').description('Check changes only on selected path'), + path: accountPathSchema, subconnections: accountSchemas.subconnections, @@ -2371,7 +2372,7 @@ When making API calls remember that requests against the same account are queued name: Joi.string().max(256).example('My Email Account').description('Display name for the account'), email: Joi.string().empty('').email().example('user@example.com').description('Default email address of the account'), - path: Joi.string().empty('').max(1024).default('*').example('INBOX').description('Check changes only on selected path'), + path: accountPathSchema, subconnections: accountSchemas.subconnections, @@ -2958,7 +2959,7 @@ When making API calls remember that requests against the same account are queued notifyFrom: accountSchemas.notifyFrom, syncFrom: accountSchemas.syncFrom, - path: Joi.string().empty('').max(1024).default('*').example('INBOX').description('Check changes only on selected path'), + path: accountPathSchema, subconnections: accountSchemas.subconnections,