Steps to reproduce
- Configure Microsoft (Azure) XOAUTH2 in the Mail admin settings and connect an Outlook/Microsoft 365 account. Confirm it syncs.
- Cause the account's OAuth refresh token to become unusable and leave it that way long enough for Microsoft to expire it. In practice this happens on its own: let the Azure client secret expire (max lifetime 24 months). Sync then fails on every refresh attempt, and after ~90 days of inactivity Microsoft expires the refresh token itself (
AADSTS700082: The refresh token has expired due to inactivity).
- Install a valid client secret again (admin settings → Mail → Microsoft integration). The secret is now good; only the account's refresh token is dead.
- Open Mail → Account settings for that account → Mail server → Manual tab.
- Click Reconnect Microsoft account.
Expected behavior
The Microsoft consent pop-up opens, and completing it stores a fresh access/refresh token pair for the account.
Actual behavior
No pop-up ever opens. The form shows the generic error:
There was an error while setting up your account
Nothing is written to nextcloud.log, because the failure is entirely client-side.
Network trace for the single button click:
PUT /apps/mail/api/accounts/5 -> 200 (account update succeeds)
GET /apps/mail/api/mailboxes?accountId=5&forceSync=true -> 500 (IMAP sync; token is dead)
Console:
[ERROR] mail: could not save account details
AxiosError: Request failed with status code 500 (ERR_BAD_RESPONSE)
response.config.url = "/apps/mail/api/mailboxes?accountId=5&forceSync=true"
at onSubmit (AccountForm.vue)
The AxiosError is not from the PUT — that returns 200. It comes from the mailbox sync that updateAccount() performs immediately afterwards.
Cause
onSubmit() awaits updateAccount() before it calls getUserConsent():
|
const oldAccountData = this.account |
|
const account = await this.mainStore.updateAccount({ |
|
...data, |
|
accountId: this.account.id, |
|
}) |
|
if (this.useOauth) { |
|
this.loadingMessage = t('mail', 'Awaiting user consent') |
|
try { |
|
if (this.isGoogleAccount) { |
|
this.feedback = t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Google account') |
|
await getUserConsent(this.googleOauthUrl |
|
.replace('_state_', await generateOauthState(account.id)) |
|
.replace('_email_', encodeURIComponent(account.emailAddress))) |
|
} else { |
|
this.feedback = t('mail', 'Account updated. Please follow the pop-up instructions to reconnect your Microsoft account') |
|
await getUserConsent(this.microsoftOauthUrl |
|
.replace('_state_', await generateOauthState(account.id)) |
|
.replace('_email_', encodeURIComponent(account.emailAddress))) |
|
} |
const oldAccountData = this.account
const account = await this.mainStore.updateAccount({
...data,
accountId: this.account.id,
})
if (this.useOauth) { // never reached: the await above throws
this.loadingMessage = t('mail', 'Awaiting user consent')
...
await getUserConsent(this.microsoftOauthUrl
.replace('_state_', await generateOauthState(account.id))
.replace('_email_', encodeURIComponent(account.emailAddress)))
}
and the updateAccount store action synchronises mailboxes as its last step:
|
async updateAccount(config) { |
|
return handleHttpAuthErrors(async () => { |
|
const account = await updateAccount(config) |
|
logger.debug('account updated', { account }) |
|
this.editAccountMutation({ ...account, error: false }) |
|
await this.syncMailboxesForAccount(this.accountsUnmapped[account.id]) |
|
return account |
|
}) |
|
}, |
async updateAccount(config) {
return handleHttpAuthErrors(async () => {
const account = await updateAccount(config)
logger.debug('account updated', { account })
this.editAccountMutation({ ...account, error: false })
await this.syncMailboxesForAccount(this.accountsUnmapped[account.id]) // <-- needs a valid token
return account
})
}
syncMailboxesForAccount() performs an IMAP login, which requires a valid OAuth access token. When the refresh token is dead, Nextcloud cannot mint one, the request 500s, the promise rejects, the catch in onSubmit() rolls the account back, and getUserConsent() is never invoked.
This is circular: the only UI path to obtain a new token first requires the token you are trying to replace. Once an account reaches this state the button can never succeed, no matter how many times it is clicked.
Note this is distinct from #8641 / #8644 (reconnect button used the Google OAuth URL for Microsoft accounts, fixed in 2024). The user-visible message is the same, the cause is not. AccountForm.vue is otherwise correct here — the label reads "Reconnect Microsoft account" and microsoftOauthUrl is used.
Workaround
The consent URL is built client-side from initial state plus a server-minted, single-use state nonce, so the broken form can be bypassed entirely. Run in the browser console while logged in as the account's user, then complete the Microsoft sign-in:
const oauthUrl = JSON.parse(atob(
document.querySelector('#initial-state-mail-microsoft-oauth-url').value))
const res = await fetch('/apps/mail/api/oauth/state', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
requesttoken: OC.requestToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ accountId: ACCOUNT_ID }),
})
const { data: { state } } = await res.json()
location.href = oauthUrl
.replace('_state_', state)
.replace('_email_', encodeURIComponent('user@example.com'))
Using a top-level navigation rather than window.open() also avoids pop-up blocking. After consent, oauth_token_ttl and oauth_access_token in oc_mail_accounts are rewritten and sync resumes.
Suggested fix
Either:
- call
getUserConsent() before updateAccount() when this.useOauth is true, or
- let
updateAccount() tolerate a failing syncMailboxesForAccount() — the mailbox sync is a convenience refresh, not part of persisting the account, and a reconnect is precisely the moment it is expected to fail.
Mail app version
5.10.7
Nextcloud version
No response
Mailserver or service
Microsoft 365 (outlook.office365.com, XOAUTH2, tenant common)
Operating system
Debian (Nextcloud All-in-One container)
PHP engine version
PHP 8.3
Nextcloud memory caching
No response
Web server
Apache (supported)
Database
PostgreSQL
Additional info
Code references above are against main at a406c97ecacd25b220386c37c29adfe5bbed0d1a (2026-07-08); the ordering is unchanged there, so this is not specific to 5.10.7. Reproduced with Firefox 153 and Chrome; pop-up blocking is not a factor, as the pop-up is never reached.
Confirmation that the account state was the only problem: after completing consent via the workaround above, occ mail:account:diagnose <id> reported IMAP capabilities and 19,540 messages across 139 mailboxes with no other change, using the same client secret the failing form had.
A related but independent defect, filed separately: oauthRedirect() accepts an $error parameter and never reads it, so a provider-returned error renders the same "Account connected" page as success and logs nothing. That is what initially made the failing reconnect attempts look as though they had worked.
Steps to reproduce
AADSTS700082: The refresh token has expired due to inactivity).Expected behavior
The Microsoft consent pop-up opens, and completing it stores a fresh access/refresh token pair for the account.
Actual behavior
No pop-up ever opens. The form shows the generic error:
Nothing is written to
nextcloud.log, because the failure is entirely client-side.Network trace for the single button click:
Console:
The
AxiosErroris not from thePUT— that returns 200. It comes from the mailbox sync thatupdateAccount()performs immediately afterwards.Cause
onSubmit()awaitsupdateAccount()before it callsgetUserConsent():mail/src/components/AccountForm.vue
Lines 655 to 673 in a406c97
and the
updateAccountstore action synchronises mailboxes as its last step:mail/src/store/mainStore/actions.js
Lines 264 to 272 in a406c97
syncMailboxesForAccount()performs an IMAP login, which requires a valid OAuth access token. When the refresh token is dead, Nextcloud cannot mint one, the request 500s, the promise rejects, thecatchinonSubmit()rolls the account back, andgetUserConsent()is never invoked.This is circular: the only UI path to obtain a new token first requires the token you are trying to replace. Once an account reaches this state the button can never succeed, no matter how many times it is clicked.
Note this is distinct from #8641 / #8644 (reconnect button used the Google OAuth URL for Microsoft accounts, fixed in 2024). The user-visible message is the same, the cause is not.
AccountForm.vueis otherwise correct here — the label reads "Reconnect Microsoft account" andmicrosoftOauthUrlis used.Workaround
The consent URL is built client-side from initial state plus a server-minted, single-use
statenonce, so the broken form can be bypassed entirely. Run in the browser console while logged in as the account's user, then complete the Microsoft sign-in:Using a top-level navigation rather than
window.open()also avoids pop-up blocking. After consent,oauth_token_ttlandoauth_access_tokeninoc_mail_accountsare rewritten and sync resumes.Suggested fix
Either:
getUserConsent()beforeupdateAccount()whenthis.useOauthis true, orupdateAccount()tolerate a failingsyncMailboxesForAccount()— the mailbox sync is a convenience refresh, not part of persisting the account, and a reconnect is precisely the moment it is expected to fail.Mail app version
5.10.7
Nextcloud version
No response
Mailserver or service
Microsoft 365 (
outlook.office365.com, XOAUTH2, tenantcommon)Operating system
Debian (Nextcloud All-in-One container)
PHP engine version
PHP 8.3
Nextcloud memory caching
No response
Web server
Apache (supported)
Database
PostgreSQL
Additional info
Code references above are against
mainata406c97ecacd25b220386c37c29adfe5bbed0d1a(2026-07-08); the ordering is unchanged there, so this is not specific to 5.10.7. Reproduced with Firefox 153 and Chrome; pop-up blocking is not a factor, as the pop-up is never reached.Confirmation that the account state was the only problem: after completing consent via the workaround above,
occ mail:account:diagnose <id>reported IMAP capabilities and 19,540 messages across 139 mailboxes with no other change, using the same client secret the failing form had.A related but independent defect, filed separately:
oauthRedirect()accepts an$errorparameter and never reads it, so a provider-returned error renders the same "Account connected" page as success and logs nothing. That is what initially made the failing reconnect attempts look as though they had worked.