Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions lib/BackgroundJob/SyncJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@

namespace OCA\Mail\BackgroundJob;

use Horde_Imap_Client_Exception;
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Exception\ImapAuthenticationFailedException;
use OCA\Mail\Exception\IncompleteSyncException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\MailboxSync;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\Sync\ImapToDbSynchronizer;
Expand Down Expand Up @@ -123,9 +122,7 @@ protected function run($argument) {
'exception' => $e,
]);
} catch (Throwable $e) {
if ($e instanceof ServiceException
&& $e->getPrevious() instanceof Horde_Imap_Client_Exception
&& $e->getPrevious()->getCode() === Horde_Imap_Client_Exception::LOGIN_AUTHENTICATIONFAILED) {
if (ImapAuthenticationFailedException::findHordeAuthFailure($e) !== null) {
$this->logger->info('Cron mail sync authentication failed for account {accountId}', [
'accountId' => $accountId,
'exception' => $e,
Expand Down
1 change: 1 addition & 0 deletions lib/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ public function getItineraries(int $id): JSONResponse {
* @param int $id
* @return JSONResponse
*/
#[TrapError]
public function getDkim(int $id): JSONResponse {
if ($this->userId === null) {
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
Expand Down
65 changes: 65 additions & 0 deletions lib/Exception/ImapAuthenticationFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Exception;

use Horde_Imap_Client_Exception;
use OCP\AppFramework\Http;
use Throwable;

/**
* IMAP rejected the account's credentials (or the local auth rate limiter is
* engaged). Unlike a generic ServiceException this is sent to the client as a
* distinct, machine-readable failure so the frontend can pause syncing and
* ask the user to update their credentials instead of retrying forever.
*/
class ImapAuthenticationFailedException extends ClientException {
public const REASON_AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED';
public const REASON_RATE_LIMITED = 'RATE_LIMITED';

private string $reason;

public function __construct(string $message = 'IMAP authentication failed',
int $code = 0,
?Throwable $previous = null,
string $reason = self::REASON_AUTHENTICATION_FAILED) {
parent::__construct($message, $code, $previous);
$this->reason = $reason;
}

public static function fromHorde(Horde_Imap_Client_Exception $e): self {
$reason = $e->getMessage() === 'Too many auth attempts'
? self::REASON_RATE_LIMITED
: self::REASON_AUTHENTICATION_FAILED;
return new self('IMAP authentication failed', $e->getCode(), $e, $reason);
}

/**
* Find an IMAP authentication failure anywhere in an exception chain
*/
public static function findHordeAuthFailure(?Throwable $e): ?Horde_Imap_Client_Exception {
while ($e !== null) {
if ($e instanceof Horde_Imap_Client_Exception
&& $e->getCode() === Horde_Imap_Client_Exception::LOGIN_AUTHENTICATIONFAILED) {
return $e;
}
$e = $e->getPrevious();
}
return null;
}

public function getReason(): string {
return $this->reason;
}

#[\Override]
public function getHttpCode(): int {
return Http::STATUS_FAILED_DEPENDENCY;
}
}
18 changes: 18 additions & 0 deletions lib/Http/Middleware/ErrorMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Exception;
use Horde_Imap_Client_Exception;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ImapAuthenticationFailedException;
use OCA\Mail\Exception\NotImplemented;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Http\JsonResponse;
Expand Down Expand Up @@ -69,6 +70,23 @@ public function afterException($controller, $methodName, Exception $exception) {
return JSONResponse::fail([], Http::STATUS_NOT_IMPLEMENTED);
}

$authFailure = ImapAuthenticationFailedException::findHordeAuthFailure($exception);
if ($authFailure !== null) {
$wrapped = ImapAuthenticationFailedException::fromHorde($authFailure);
// The client is informed and can react, no need to flood the log
$this->logger->info('IMAP authentication failed: ' . $authFailure->getMessage(), [
'exception' => $exception,
]);
return JsonResponse::fail(
[
'message' => $wrapped->getMessage(),
'type' => get_class($wrapped),
'reason' => $wrapped->getReason(),
],
$wrapped->getHttpCode()
);
}

$temporary = $this->isTemporaryException($exception);
if ($temporary) {
$this->logger->warning($exception->getMessage(), [
Expand Down
54 changes: 39 additions & 15 deletions lib/IMAP/HordeImapClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IMemcache;
use OCP\IMemcacheTTL;
use function floor;

/**
* "Decorator" around Horde's IMAP client to add auth error rate limiting.
Expand Down Expand Up @@ -62,7 +61,14 @@ public function login() {
}
}

private const RATE_LIMIT_WINDOW = 3 * 60 * 60;
/** Failed attempts are forgotten after this many seconds */
private const FAILURE_TTL = 3 * 60 * 60;
/** Unthrottled attempts before the leaky bucket engages */
private const THROTTLE_THRESHOLD = 3;
/** Throttled attempts allowed at the fast interval before slowing down */
private const FAST_RETRIES = 3;
private const FAST_RETRY_INTERVAL = 30;
private const SLOW_RETRY_INTERVAL = 5 * 60;

protected function imapLogin() {
$result = parent::_login();
Expand All @@ -76,27 +82,45 @@ protected function _login() {
return $this->imapLogin();
}

$now = $this->timeFactory->getTime();
$window = floor($now / self::RATE_LIMIT_WINDOW);
$cacheKey = $this->hash . (string)$window;
$failureKey = $this->hash . '_failures';
$attemptKey = $this->hash . '_last_attempt';

$counter = $this->rateLimiterCache->get($cacheKey);
if ($counter !== null && $counter >= 3) {
// Enough errors. Let's fail without involving IMAP
throw new Horde_Imap_Client_Exception(
'Too many auth attempts',
Horde_Imap_Client_Exception::LOGIN_AUTHENTICATIONFAILED
);
$failures = (int)$this->rateLimiterCache->get($failureKey);
if ($failures >= self::THROTTLE_THRESHOLD) {
// Leaky bucket instead of a hard block: keep allowing one real
// attempt per interval so transient server-side failures heal on
// their own — a few quick retries first, then a slow trickle.
$interval = $failures < self::THROTTLE_THRESHOLD + self::FAST_RETRIES
? self::FAST_RETRY_INTERVAL
: self::SLOW_RETRY_INTERVAL;
$now = $this->timeFactory->getTime();
$lastAttempt = (int)$this->rateLimiterCache->get($attemptKey);
if ($now - $lastAttempt < $interval) {
// Enough errors. Let's fail without involving IMAP
throw new Horde_Imap_Client_Exception(
'Too many auth attempts',
Horde_Imap_Client_Exception::LOGIN_AUTHENTICATIONFAILED
);
}
// Not atomic: a parallel burst may pass together, but the next
// interval only opens once the timestamp is stored
$this->rateLimiterCache->set($attemptKey, $now, self::FAILURE_TTL);
}

try {
return $this->imapLogin();
$result = $this->imapLogin();
if ($failures > 0) {
// The credentials work (again): reset the throttle state
$this->rateLimiterCache->remove($failureKey);
$this->rateLimiterCache->remove($attemptKey);
}
return $result;
} catch (Horde_Imap_Client_Exception $e) {
if ($e->getCode() === Horde_Imap_Client_Exception::LOGIN_AUTHENTICATIONFAILED
&& in_array($e->getMessage(), ['Authentication failed.', 'Mail server denied authentication.'], true)) {
$this->rateLimiterCache->inc($cacheKey);
$this->rateLimiterCache->inc($failureKey);
if ($this->rateLimiterCache instanceof IMemcacheTTL) {
$this->rateLimiterCache->setTTL($cacheKey, self::RATE_LIMIT_WINDOW);
$this->rateLimiterCache->setTTL($failureKey, self::FAILURE_TTL);
}
}
throw $e;
Expand Down
10 changes: 9 additions & 1 deletion lib/Service/Sync/ImapToDbSynchronizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,15 @@ public function sync(Account $account,
return $rebuildThreads;
}

$client->login(); // Need to login before fetching capabilities.
try {
$client->login(); // Need to login before fetching capabilities.
} catch (Horde_Imap_Client_Exception $e) {
throw new ServiceException(
'Sync failed for account ' . $account->getId() . ' mailbox ' . $mailbox->getId() . ': ' . $e->getMessage(),
0,
$e,
);
}

// There is no partial sync when using QRESYNC. As per RFC the client will always pull
// all changes. This is a cheap operation when using QRESYNC as the server keeps track
Expand Down
2 changes: 1 addition & 1 deletion src/components/Mailbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export default {
this.error = false

logger.debug(`syncing folder ${this.mailbox.databaseId} (${this.query}) during cache initalization`)
this.sync(true)
return this.sync(true)
.then(() => {
this.loadingCacheInitialization = false

Expand Down
18 changes: 14 additions & 4 deletions src/components/ThreadEnvelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -872,22 +872,32 @@ export default {
this.handleThreadScrolling()
})
} catch (error) {
this.error = error
const account = this.mainStore.getAccount(this.envelope.accountId)
if (account?.error) {
// The account cannot authenticate against IMAP: show an
// actionable message instead of a generic 'Not found'
this.error = new Error(t('mail', 'Authentication failed for account {email}. Please update your credentials in the account settings.', { email: account.emailAddress }))
} else {
this.error = error
}
this.loading = Loading.Done
logger.error('Could not fetch message', { error })
}

// Skip IMAP-backed background lookups while the account cannot authenticate
const accountHasAuthError = !!this.mainStore.getAccount(this.envelope.accountId)?.error

// Fetch itineraries if they haven't been included in the message data
if (this.message && !this.message.itineraries) {
if (this.message && !this.message.itineraries && !accountHasAuthError) {
this.fetchItineraries()
}
// Fetch dkim
if (this.message && this.message.dkimValid === undefined) {
if (this.message && this.message.dkimValid === undefined && !accountHasAuthError) {
this.fetchDkim()
}

// Fetch smart replies
if (this.enabledFreePrompt && this.message && !['trash', 'junk'].includes(this.mailbox.specialRole) && !this.showFollowUpHeader) {
if (this.enabledFreePrompt && this.message && !accountHasAuthError && !['trash', 'junk'].includes(this.mailbox.specialRole) && !this.showFollowUpHeader) {
try {
this.smartReplies = await smartReply(this.envelope.databaseId)
} catch (error) {
Expand Down
16 changes: 16 additions & 0 deletions src/errors/ImapAuthenticationFailedError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export default class ImapAuthenticationFailedError extends Error {
constructor(message) {
super(message)
this.name = ImapAuthenticationFailedError.getName()
this.message = message
}

static getName() {
return 'ImapAuthenticationFailedError'
}
}
7 changes: 6 additions & 1 deletion src/errors/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import CouldNotConnectError from './CouldNotConnectError.js'
import ImapAuthenticationFailedError from './ImapAuthenticationFailedError.js'
import MailboxLockedError from './MailboxLockedError.js'
import MailboxNotCachedError from './MailboxNotCachedError.js'
import ManageSieveError from './ManageSieveError.js'
Expand All @@ -19,6 +20,7 @@ const map = {
'OCA\\Mail\\Exception\\SentMailboxNotSetException': NoSentMailboxConfiguredError,
'OCA\\Mail\\Exception\\TrashMailboxNotSetException': NoTrashMailboxConfiguredError,
'OCA\\Mail\\Exception\\CouldNotConnectException': CouldNotConnectError,
'OCA\\Mail\\Exception\\ImapAuthenticationFailedException': ImapAuthenticationFailedError,
'OCA\\Mail\\Exception\\ManyRecipientsException': ManyRecipientsError,
'Horde\\ManageSieve\\Exception': ManageSieveError,
}
Expand All @@ -44,5 +46,8 @@ export function convertAxiosError(axiosError) {
return axiosError
}

return new map[response.data.data.type](response.data.data.message)
const error = new map[response.data.data.type](response.data.data.message)
// Attach the structured payload so handlers can inspect extra fields (e.g. reason)
error.data = response.data.data
return error
}
Loading