+ {isRateLimited && rateLimitSecondsLeft !== null && (
+
+
+
{tCommon('rate_limited_title')}
+
{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}
+
+ )}
{connectionLost && (
diff --git a/components/providers/rate-limit-toast-provider.tsx b/components/providers/rate-limit-toast-provider.tsx
new file mode 100644
index 00000000..c3c0287c
--- /dev/null
+++ b/components/providers/rate-limit-toast-provider.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect } from 'react';
+import { useTranslations } from 'next-intl';
+import { toast } from '@/stores/toast-store';
+
+type RateLimitBlockedDetail = {
+ retryAfterMs?: number;
+};
+
+export function RateLimitToastProvider({ children }: { children: React.ReactNode }) {
+ const tCommon = useTranslations('common');
+
+ useEffect(() => {
+ const onRateLimitBlocked = (event: Event) => {
+ const detail = (event as CustomEvent
).detail;
+ const seconds = Math.max(1, Math.ceil((detail?.retryAfterMs ?? 0) / 1000));
+
+ toast.warning(
+ tCommon('rate_limited_action_title'),
+ tCommon('rate_limited_action_detail', { seconds }),
+ );
+ };
+
+ window.addEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked);
+ return () => window.removeEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked);
+ }, [tCommon]);
+
+ return <>{children}>;
+}
\ No newline at end of file
diff --git a/lib/__tests__/jmap-client-resilience.test.ts b/lib/__tests__/jmap-client-resilience.test.ts
index 62d7af24..8bf2427b 100644
--- a/lib/__tests__/jmap-client-resilience.test.ts
+++ b/lib/__tests__/jmap-client-resilience.test.ts
@@ -22,6 +22,16 @@ function mockFetchResponse(status: number, body?: unknown): Response {
});
}
+function mockFetchResponseWithHeaders(status: number, headers: Record, body?: unknown): Response {
+ return new Response(body ? JSON.stringify(body) : null, {
+ status,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ });
+}
+
describe('JMAPClient resilience', () => {
let fetchSpy: ReturnType;
@@ -162,6 +172,36 @@ describe('JMAPClient resilience', () => {
});
});
+ describe('authenticatedFetch — 429 rate limiting', () => {
+ it('stops sending authenticated requests until the retry window expires', async () => {
+ const client = await createConnectedClient();
+
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
+ type: 'about:blank',
+ status: 429,
+ title: 'Too Many Authentication Attempts',
+ })
+ );
+
+ await expect(client.ping()).rejects.toThrow('Rate limited by server');
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ fetchSpy.mockClear();
+ await expect(client.ping()).rejects.toThrow('Rate limited by server');
+ expect(fetchSpy).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(120_000);
+ fetchSpy.mockClear();
+
+ const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
+
+ await expect(client.ping()).resolves.toBeUndefined();
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('refreshSession', () => {
it('updates session fields from server response', async () => {
const client = await createConnectedClient();
@@ -260,6 +300,32 @@ describe('JMAPClient resilience', () => {
const trueCall = callback.mock.calls.find((c) => c[0] === true);
expect(trueCall).toBeUndefined();
});
+
+ it('does not mark the connection lost or reconnect repeatedly while rate limited', async () => {
+ const client = await createConnectedClient();
+ const callback = vi.fn();
+ client.onConnectionChange(callback);
+
+ fetchSpy.mockResolvedValueOnce(
+ mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
+ type: 'about:blank',
+ status: 429,
+ title: 'Too Many Authentication Attempts',
+ })
+ );
+
+ await vi.advanceTimersByTimeAsync(30_000);
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(callback).not.toHaveBeenCalledWith(false);
+
+ fetchSpy.mockClear();
+ await vi.advanceTimersByTimeAsync(30_000);
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
});
describe('disconnect', () => {
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index f9f3a706..7ea7321c 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -77,6 +77,9 @@ export class DemoJMAPClient implements IJMAPClient {
setupPushNotifications(): boolean { return true; }
closePushNotifications(): void { /* no-op in demo */ }
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
+ onRateLimit(): void { /* no-op in demo */ }
+ isRateLimited(): boolean { return false; }
+ getRateLimitRemainingMs(): number { return 0; }
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
getLastStates(): AccountStates { return { ...this.lastStates }; }
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 777875c9..8537e880 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -40,6 +40,9 @@ export interface IJMAPClient {
setupPushNotifications(): boolean;
closePushNotifications(): void;
onConnectionChange(callback: (connected: boolean) => void): void;
+ onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void;
+ isRateLimited(): boolean;
+ getRateLimitRemainingMs(): number;
onStateChange(callback: (change: StateChange) => void): void;
getLastStates(): AccountStates;
setLastStates(states: AccountStates): void;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 97ca8233..2ff209b8 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -4,6 +4,15 @@ import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
import { debug } from "@/lib/debug";
+export class RateLimitError extends Error {
+ retryAfterMs: number;
+ constructor(retryAfterMs: number) {
+ super('Rate limited by server');
+ this.name = 'RateLimitError';
+ this.retryAfterMs = retryAfterMs;
+ }
+}
+
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
apiUrl: string;
@@ -102,6 +111,8 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
}
export class JMAPClient implements IJMAPClient {
+ private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
+
private serverUrl: string;
private username: string;
private password: string;
@@ -121,6 +132,10 @@ export class JMAPClient implements IJMAPClient {
private lastStates: AccountStates = {};
private reconnecting = false;
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
+ private rateLimitedUntil: number = 0;
+ private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null;
+ private rateLimitTimeout: NodeJS.Timeout | null = null;
+ private lastRateLimitNoticeAt: number = 0;
constructor(serverUrl: string, username: string, password: string) {
this.serverUrl = serverUrl.replace(/\/$/, '');
@@ -155,6 +170,13 @@ export class JMAPClient implements IJMAPClient {
}
private async authenticatedFetch(url: string, init?: Parameters[1]): Promise {
+ // Short-circuit: if rate-limited, reject immediately without sending a request
+ if (this.isRateLimited()) {
+ const remaining = this.rateLimitedUntil - Date.now();
+ this.notifyRateLimitBlocked(remaining);
+ throw new RateLimitError(remaining);
+ }
+
const headers = { ...init?.headers as Record, 'Authorization': this.authHeader };
let response: Response;
@@ -167,6 +189,13 @@ export class JMAPClient implements IJMAPClient {
response = await fetch(url, { ...init, headers });
}
+ // Handle 429 rate limiting — stop immediately, do not retry
+ if (response.status === 429) {
+ const retryAfterMs = JMAPClient.parseRetryAfter(response);
+ this.setRateLimited(retryAfterMs);
+ throw new RateLimitError(retryAfterMs);
+ }
+
if (response.status === 401) {
if (this.authMode === 'bearer' && this.onTokenRefresh) {
const newToken = await this.onTokenRefresh();
@@ -273,10 +302,15 @@ export class JMAPClient implements IJMAPClient {
this.stopKeepAlive();
this.pingInterval = setInterval(async () => {
+ // Skip ping while rate-limited to avoid compounding auth failures
+ if (this.isRateLimited()) return;
try {
await this.ping();
this.connectionChangeCallback?.(true);
} catch (error) {
+ if (error instanceof RateLimitError) {
+ return;
+ }
console.error('Keep-alive ping failed:', error);
this.connectionChangeCallback?.(false);
try {
@@ -319,6 +353,10 @@ export class JMAPClient implements IJMAPClient {
disconnect(): void {
this.stopKeepAlive();
this.closePushNotifications();
+ if (this.rateLimitTimeout) {
+ clearTimeout(this.rateLimitTimeout);
+ this.rateLimitTimeout = null;
+ }
this.apiUrl = "";
this.accountId = "";
this.session = null;
@@ -3542,6 +3580,11 @@ export class JMAPClient implements IJMAPClient {
}
private connectSSE(templateUrl: string): void {
+ if (this.isRateLimited()) {
+ this.scheduleSSEReconnect();
+ return;
+ }
+
const url = templateUrl
.replace('{types}', '*')
.replace('{closeafter}', 'no')
@@ -3549,8 +3592,8 @@ export class JMAPClient implements IJMAPClient {
this.sseAbortController = new AbortController();
- fetch(url, {
- headers: { 'Authorization': this.authHeader, 'Accept': 'text/event-stream' },
+ this.authenticatedFetch(url, {
+ headers: { 'Accept': 'text/event-stream' },
signal: this.sseAbortController.signal,
}).then(response => {
if (!response.ok || !response.body) {
@@ -3558,7 +3601,12 @@ export class JMAPClient implements IJMAPClient {
return;
}
this.readSSEStream(response.body);
- }).catch(() => {
+ }).catch((error) => {
+ if (error instanceof RateLimitError) {
+ this.sseAbortController = null;
+ this.scheduleSSEReconnect();
+ return;
+ }
this.fallbackToPolling();
});
}
@@ -3619,9 +3667,16 @@ export class JMAPClient implements IJMAPClient {
this.fallbackToPolling();
return;
}
+ const delay = this.isRateLimited()
+ ? Math.max(this.rateLimitedUntil - Date.now(), JMAPClient.SSE_RECONNECT_DELAY)
+ : JMAPClient.SSE_RECONNECT_DELAY;
this.sseReconnectTimeout = setTimeout(() => {
+ if (this.isRateLimited()) {
+ this.scheduleSSEReconnect();
+ return;
+ }
this.connectSSE(eventSourceUrl);
- }, JMAPClient.SSE_RECONNECT_DELAY);
+ }, delay);
}
private fallbackToPolling(): void {
@@ -3632,6 +3687,9 @@ export class JMAPClient implements IJMAPClient {
}
private startPollingFallback(): void {
+ if (this.isRateLimited()) {
+ return;
+ }
this.fetchCurrentStates();
this.pollingInterval = setInterval(() => {
this.checkForStateChanges();
@@ -3665,6 +3723,9 @@ export class JMAPClient implements IJMAPClient {
}
private async fetchCurrentStates(): Promise {
+ if (this.isRateLimited()) {
+ return;
+ }
try {
const { using, methodCalls } = this.buildStatePollingRequest();
const response = await this.authenticatedFetch(this.apiUrl, {
@@ -3688,6 +3749,9 @@ export class JMAPClient implements IJMAPClient {
}
private async checkForStateChanges(): Promise {
+ if (this.isRateLimited()) {
+ return;
+ }
try {
const { using, methodCalls } = this.buildStatePollingRequest();
const response = await this.authenticatedFetch(this.apiUrl, {
@@ -3749,6 +3813,77 @@ export class JMAPClient implements IJMAPClient {
this.connectionChangeCallback = callback;
}
+ onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void {
+ this.rateLimitCallback = callback;
+ }
+
+ isRateLimited(): boolean {
+ return Date.now() < this.rateLimitedUntil;
+ }
+
+ getRateLimitRemainingMs(): number {
+ return Math.max(0, this.rateLimitedUntil - Date.now());
+ }
+
+ private setRateLimited(retryAfterMs: number): void {
+ this.rateLimitedUntil = Date.now() + retryAfterMs;
+
+ if (this.rateLimitTimeout) {
+ clearTimeout(this.rateLimitTimeout);
+ this.rateLimitTimeout = null;
+ }
+
+ this.rateLimitCallback?.(true, retryAfterMs);
+
+ // Pause live updates until the server's rate-limit window expires.
+ const stateChangeCallback = this.stateChangeCallback;
+ this.closePushNotifications();
+ this.stateChangeCallback = stateChangeCallback;
+
+ // Schedule clearing the rate-limit flag and notifying listeners.
+ this.rateLimitTimeout = setTimeout(() => {
+ this.rateLimitTimeout = null;
+ if (!this.isRateLimited()) {
+ this.rateLimitCallback?.(false, 0);
+ if (this.session && this.stateChangeCallback) {
+ this.setupPushNotifications();
+ }
+ }
+ }, retryAfterMs);
+ }
+
+ private notifyRateLimitBlocked(retryAfterMs: number): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const now = Date.now();
+ if ((now - this.lastRateLimitNoticeAt) < JMAPClient.RATE_LIMIT_TOAST_THROTTLE_MS) {
+ return;
+ }
+
+ this.lastRateLimitNoticeAt = now;
+ window.dispatchEvent(new CustomEvent('bulwark:rate-limit-blocked', {
+ detail: { retryAfterMs },
+ }));
+ }
+
+ private static parseRetryAfter(response: Response): number {
+ const header = response.headers.get('Retry-After');
+ if (!header) return 60_000; // default 60s if no header
+ const seconds = Number(header);
+ if (!Number.isNaN(seconds) && seconds > 0) {
+ return Math.min(seconds * 1000, 300_000); // cap at 5 minutes
+ }
+ // Try HTTP-date format
+ const date = Date.parse(header);
+ if (!Number.isNaN(date)) {
+ const ms = date - Date.now();
+ return ms > 0 ? Math.min(ms, 300_000) : 60_000;
+ }
+ return 60_000;
+ }
+
onStateChange(callback: (change: StateChange) => void): void {
this.stateChangeCallback = callback;
}
diff --git a/locales/de/common.json b/locales/de/common.json
index b28a7c27..818355e6 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -511,7 +511,11 @@
"no": "Nein",
"unknown": "Unbekannt",
"app_title": "Webmail",
- "reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…"
+ "reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…",
+ "rate_limited_title": "Die Serverauthentifizierung ist vorubergehend begrenzt.",
+ "rate_limited_detail": "Bulwark hat Hintergrundanfragen pausiert, um eine Sperre zu vermeiden. Neuer Versuch in {seconds}s.",
+ "rate_limited_action_title": "Anfrage pausiert, um eine Sperre zu vermeiden.",
+ "rate_limited_action_detail": "Bulwark wartet, bis die Serverabklingzeit endet, bevor weitere authentifizierte Anfragen gesendet werden. Versuchen Sie es in {seconds}s erneut."
},
"notifications": {
"email_sent": "E-Mail erfolgreich gesendet",
diff --git a/locales/en/common.json b/locales/en/common.json
index 3c17c48d..199e0bea 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -511,7 +511,11 @@
"no": "No",
"unknown": "Unknown",
"app_title": "Webmail",
- "reconnecting": "Connection lost. Attempting to reconnect…"
+ "reconnecting": "Connection lost. Attempting to reconnect…",
+ "rate_limited_title": "Server authentication is temporarily rate limited.",
+ "rate_limited_detail": "Bulwark has paused background requests to avoid lockout. Retrying in {seconds}s.",
+ "rate_limited_action_title": "Request paused to avoid lockout.",
+ "rate_limited_action_detail": "Bulwark is waiting for the server cooldown to end before sending more authenticated requests. Try again in {seconds}s."
},
"notifications": {
"email_sent": "Email sent successfully",
diff --git a/locales/es/common.json b/locales/es/common.json
index a51953ae..5a2d923b 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -511,7 +511,11 @@
"no": "No",
"unknown": "Desconocido",
"app_title": "Correo Web",
- "reconnecting": "Conexión perdida. Intentando reconectar…"
+ "reconnecting": "Conexión perdida. Intentando reconectar…",
+ "rate_limited_title": "La autenticación del servidor está limitada temporalmente.",
+ "rate_limited_detail": "Bulwark ha pausado las solicitudes en segundo plano para evitar un bloqueo. Reintentando en {seconds}s.",
+ "rate_limited_action_title": "Solicitud pausada para evitar el bloqueo.",
+ "rate_limited_action_detail": "Bulwark está esperando a que termine el enfriamiento del servidor antes de enviar más solicitudes autenticadas. Inténtalo de nuevo en {seconds}s."
},
"notifications": {
"email_sent": "Correo enviado exitosamente",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 75009fd1..e7162496 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -511,7 +511,11 @@
"no": "Non",
"unknown": "Inconnu",
"app_title": "Webmail",
- "reconnecting": "Connexion perdue. Tentative de reconnexion…"
+ "reconnecting": "Connexion perdue. Tentative de reconnexion…",
+ "rate_limited_title": "L'authentification du serveur est temporairement limitee.",
+ "rate_limited_detail": "Bulwark a suspendu les requetes en arriere-plan pour eviter un blocage. Nouvelle tentative dans {seconds}s.",
+ "rate_limited_action_title": "Requete suspendue pour eviter le blocage.",
+ "rate_limited_action_detail": "Bulwark attend la fin du delai impose par le serveur avant d'envoyer d'autres requetes authentifiees. Reessayez dans {seconds}s."
},
"notifications": {
"email_sent": "Email envoyé avec succès",
diff --git a/locales/it/common.json b/locales/it/common.json
index 767edae7..f8846d0f 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -511,7 +511,11 @@
"no": "No",
"unknown": "Sconosciuto",
"app_title": "Webmail",
- "reconnecting": "Connessione persa. Tentativo di riconnessione…"
+ "reconnecting": "Connessione persa. Tentativo di riconnessione…",
+ "rate_limited_title": "L'autenticazione del server e temporaneamente limitata.",
+ "rate_limited_detail": "Bulwark ha sospeso le richieste in background per evitare il blocco. Nuovo tentativo tra {seconds}s.",
+ "rate_limited_action_title": "Richiesta sospesa per evitare il blocco.",
+ "rate_limited_action_detail": "Bulwark attende che termini il cooldown del server prima di inviare altre richieste autenticate. Riprova tra {seconds}s."
},
"notifications": {
"email_sent": "Messaggio inviato con successo",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 98193fb7..466f523a 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -511,7 +511,11 @@
"no": "いいえ",
"unknown": "不明",
"app_title": "ウェブメール",
- "reconnecting": "接続が切れました。再接続を試みています…"
+ "reconnecting": "接続が切れました。再接続を試みています…",
+ "rate_limited_title": "サーバー認証は一時的に制限されています。",
+ "rate_limited_detail": "ロックアウトを避けるため、Bulwark はバックグラウンド要求を一時停止しました。{seconds} 秒後に再試行します。",
+ "rate_limited_action_title": "ロックアウトを避けるため要求を一時停止しました。",
+ "rate_limited_action_detail": "Bulwark はサーバーのクールダウンが終わるまで、追加の認証付きリクエストを送信しません。{seconds} 秒後に再試行してください。"
},
"notifications": {
"email_sent": "メールを送信しました",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 9acd3346..577458dc 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -511,7 +511,11 @@
"no": "Nee",
"unknown": "Onbekend",
"app_title": "Webmail",
- "reconnecting": "Verbinding verloren. Opnieuw verbinden…"
+ "reconnecting": "Verbinding verloren. Opnieuw verbinden…",
+ "rate_limited_title": "Serverauthenticatie is tijdelijk beperkt.",
+ "rate_limited_detail": "Bulwark heeft achtergrondverzoeken gepauzeerd om een blokkade te voorkomen. Nieuwe poging over {seconds}s.",
+ "rate_limited_action_title": "Verzoek gepauzeerd om blokkade te voorkomen.",
+ "rate_limited_action_detail": "Bulwark wacht tot de servercooldown voorbij is voordat er nieuwe geauthenticeerde verzoeken worden verzonden. Probeer het over {seconds}s opnieuw."
},
"notifications": {
"email_sent": "E-mail succesvol verzonden",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 2966c3a8..292b74e7 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -511,7 +511,11 @@
"no": "Não",
"unknown": "Desconhecido",
"app_title": "Webmail",
- "reconnecting": "Conexão perdida. Tentando reconectar…"
+ "reconnecting": "Conexão perdida. Tentando reconectar…",
+ "rate_limited_title": "A autenticação do servidor está temporariamente limitada.",
+ "rate_limited_detail": "O Bulwark pausou as solicitações em segundo plano para evitar bloqueio. Nova tentativa em {seconds}s.",
+ "rate_limited_action_title": "Solicitação pausada para evitar bloqueio.",
+ "rate_limited_action_detail": "O Bulwark está aguardando o fim do cooldown do servidor antes de enviar mais solicitações autenticadas. Tente novamente em {seconds}s."
},
"notifications": {
"email_sent": "E-mail enviado com sucesso",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index ae751ce7..83eee8e0 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -511,7 +511,11 @@
"no": "Нет",
"unknown": "Неизвестно",
"app_title": "Веб-почта",
- "reconnecting": "Соединение потеряно. Попытка переподключения…"
+ "reconnecting": "Соединение потеряно. Попытка переподключения…",
+ "rate_limited_title": "Аутентификация на сервере временно ограничена.",
+ "rate_limited_detail": "Bulwark приостановил фоновые запросы, чтобы избежать блокировки. Повтор через {seconds}с.",
+ "rate_limited_action_title": "Запрос приостановлен, чтобы избежать блокировки.",
+ "rate_limited_action_detail": "Bulwark ждет окончания серверного тайм-аута перед отправкой новых аутентифицированных запросов. Повторите через {seconds}с."
},
"notifications": {
"email_sent": "Письмо успешно отправлено",
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index fd0a29a8..492f0e38 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
-import { JMAPClient } from '@/lib/jmap/client';
+import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
@@ -21,6 +21,8 @@ interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
+ isRateLimited: boolean;
+ rateLimitUntil: number | null;
serverUrl: string | null;
username: string | null;
client: IJMAPClient | null;
@@ -64,6 +66,63 @@ function classifyLoginError(error: unknown): string {
return 'generic';
}
+function isRateLimitError(error: unknown): error is RateLimitError {
+ return error instanceof RateLimitError;
+}
+
+function getClientRateLimitState(client: IJMAPClient | null): Pick {
+ if (!client) {
+ return { isRateLimited: false, rateLimitUntil: null };
+ }
+
+ const remainingMs = client.getRateLimitRemainingMs();
+ if (remainingMs <= 0) {
+ return { isRateLimited: false, rateLimitUntil: null };
+ }
+
+ return {
+ isRateLimited: true,
+ rateLimitUntil: Date.now() + remainingMs,
+ };
+}
+
+function bindClientStatusHandlers(
+ client: IJMAPClient,
+ set: (state: Partial) => void,
+ get: () => AuthState,
+ accountId?: string,
+): void {
+ client.onConnectionChange((connected) => {
+ if (!accountId || get().activeAccountId === accountId) {
+ set({ connectionLost: !connected });
+ }
+ if (accountId) {
+ useAccountStore.getState().updateAccount(accountId, { isConnected: connected });
+ }
+ });
+
+ client.onRateLimit((rateLimited, retryAfterMs) => {
+ const isActiveAccount = !accountId || get().activeAccountId === accountId;
+ const nextRateLimitUntil = rateLimited ? Date.now() + retryAfterMs : null;
+
+ if (isActiveAccount) {
+ set({
+ isRateLimited: rateLimited,
+ rateLimitUntil: nextRateLimitUntil,
+ connectionLost: false,
+ });
+ }
+
+ if (accountId) {
+ useAccountStore.getState().updateAccount(accountId, {
+ isConnected: !rateLimited,
+ hasError: rateLimited,
+ errorMessage: rateLimited ? 'Temporarily rate limited by server' : undefined,
+ });
+ }
+ });
+}
+
function emailMatchesUsername(email: string, username: string): boolean {
if (email === username) return true;
// Handle local-part login: username "user" should match "user@domain.tld"
@@ -240,6 +299,8 @@ function performFullLogout(set: (state: Partial) => void): void {
set({
isAuthenticated: false,
isLoading: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
serverUrl: null,
username: null,
client: null,
@@ -269,6 +330,8 @@ export const useAuthStore = create()(
isAuthenticated: false,
isLoading: false,
error: null,
+ isRateLimited: false,
+ rateLimitUntil: null,
serverUrl: null,
username: null,
client: null,
@@ -284,13 +347,10 @@ export const useAuthStore = create()(
login: async (serverUrl, username, password, totp, rememberMe) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
- set({ isLoading: true, error: null });
+ set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
const client = new JMAPClient(serverUrl, username, effectivePassword);
- client.onConnectionChange((connected) => {
- set({ connectionLost: !connected });
- });
await client.connect();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
@@ -313,6 +373,7 @@ export const useAuthStore = create()(
// Store client in multi-account map
clients.set(accountId, client);
+ bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
label: primaryIdentity?.name || username,
@@ -362,6 +423,7 @@ export const useAuthStore = create()(
serverUrl,
username,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
@@ -388,6 +450,8 @@ export const useAuthStore = create()(
isLoading: false,
error: classifyLoginError(error),
isAuthenticated: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
client: null,
});
return false;
@@ -395,7 +459,7 @@ export const useAuthStore = create()(
},
loginDemo: async () => {
- set({ isLoading: true, error: null });
+ set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Clear all store data before re-initializing with fresh demo data
clearAllStores();
@@ -432,6 +496,7 @@ export const useAuthStore = create()(
serverUrl: 'demo.example.com',
username,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
@@ -450,6 +515,8 @@ export const useAuthStore = create()(
isLoading: false,
error: 'generic',
isAuthenticated: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
client: null,
});
return false;
@@ -457,7 +524,7 @@ export const useAuthStore = create()(
},
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
- set({ isLoading: true, error: null });
+ set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Determine slot for this account (use slot from sessionStorage if re-adding)
@@ -481,9 +548,6 @@ export const useAuthStore = create()(
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn());
- client.onConnectionChange((connected) => {
- set({ connectionLost: !connected });
- });
await client.connect();
const jmapUsername = client.getUsername();
@@ -506,6 +570,7 @@ export const useAuthStore = create()(
}
clients.set(accountId, client);
+ bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
label: primaryIdentity?.name || username,
@@ -528,6 +593,7 @@ export const useAuthStore = create()(
serverUrl,
username,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'oauth',
@@ -564,6 +630,8 @@ export const useAuthStore = create()(
isLoading: false,
error: errorMsg,
isAuthenticated: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
client: null,
});
return false;
@@ -571,7 +639,7 @@ export const useAuthStore = create()(
},
loginWithServerSso: async (code, state) => {
- set({ isLoading: true, error: null });
+ set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
@@ -601,9 +669,6 @@ export const useAuthStore = create()(
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
- client.onConnectionChange((connected) => {
- set({ connectionLost: !connected });
- });
await client.connect();
const jmapUsername = client.getUsername();
@@ -623,6 +688,7 @@ export const useAuthStore = create()(
}
clients.set(accountId, client);
+ bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
label: primaryIdentity?.name || username,
@@ -645,6 +711,7 @@ export const useAuthStore = create()(
serverUrl: ssoServerUrl,
username,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'oauth',
@@ -675,6 +742,8 @@ export const useAuthStore = create()(
isLoading: false,
error: errorMsg,
isAuthenticated: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
client: null,
});
return false;
@@ -866,7 +935,7 @@ export const useAuthStore = create()(
// Null out the client immediately so the page doesn't fire data-loading
// effects with the old client while stores are being cleared.
- set({ isLoading: true, client: null });
+ set({ isLoading: true, client: null, isRateLimited: false, rateLimitUntil: null });
// Snapshot current account
if (state.activeAccountId) {
@@ -879,6 +948,7 @@ export const useAuthStore = create()(
// Get or create client for target account
let targetClient = clients.get(accountId);
+ let targetRestoreRateLimited = false;
if (!targetClient) {
// Client not connected — try to restore
@@ -889,12 +959,7 @@ export const useAuthStore = create()(
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn());
- targetClient.onConnectionChange((connected) => {
- if (get().activeAccountId === accountId) {
- set({ connectionLost: !connected });
- }
- accountStore.updateAccount(accountId, { isConnected: connected });
- });
+ bindClientStatusHandlers(targetClient, set, get, accountId);
await targetClient.connect();
clients.set(accountId, targetClient);
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
@@ -904,22 +969,47 @@ export const useAuthStore = create()(
if (res.ok) {
const { serverUrl, username, password } = await res.json();
targetClient = new JMAPClient(serverUrl, username, password);
- targetClient.onConnectionChange((connected) => {
- if (get().activeAccountId === accountId) {
- set({ connectionLost: !connected });
- }
- accountStore.updateAccount(accountId, { isConnected: connected });
- });
+ bindClientStatusHandlers(targetClient, set, get, accountId);
await targetClient.connect();
clients.set(accountId, targetClient);
}
}
} catch (err) {
debug.error(`Failed to restore client for ${accountId}:`, err);
+ if (isRateLimitError(err)) {
+ targetRestoreRateLimited = true;
+ }
}
}
if (!targetClient) {
+ if (targetRestoreRateLimited) {
+ if (state.activeAccountId && state.activeAccountId !== accountId) {
+ const prevClient = clients.get(state.activeAccountId);
+ const prevAccount = accountStore.getAccountById(state.activeAccountId);
+ if (prevClient && prevAccount) {
+ restoreAccount(state.activeAccountId);
+ accountStore.setActiveAccount(state.activeAccountId);
+ set({
+ isLoading: false,
+ serverUrl: prevAccount.serverUrl,
+ username: prevAccount.username,
+ client: prevClient,
+ ...getClientRateLimitState(prevClient),
+ authMode: prevAccount.authMode,
+ rememberMe: prevAccount.rememberMe,
+ connectionLost: false,
+ error: 'connection_failed',
+ activeAccountId: state.activeAccountId,
+ });
+ return;
+ }
+ }
+
+ set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
+ return;
+ }
+
// Cannot restore — remove the stale account and redirect to login
evictAccount(accountId);
accountStore.removeAccount(accountId);
@@ -937,6 +1027,7 @@ export const useAuthStore = create()(
serverUrl: prevAccount.serverUrl,
username: prevAccount.username,
client: prevClient,
+ ...getClientRateLimitState(prevClient),
authMode: prevAccount.authMode,
rememberMe: prevAccount.rememberMe,
connectionLost: false,
@@ -967,6 +1058,7 @@ export const useAuthStore = create()(
serverUrl: targetAccount.serverUrl,
username: targetAccount.username,
client: targetClient,
+ ...getClientRateLimitState(targetClient),
authMode: targetAccount.authMode,
rememberMe: targetAccount.rememberMe,
connectionLost: false,
@@ -1029,12 +1121,7 @@ export const useAuthStore = create()(
const { access_token, expires_in } = await res.json();
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn());
- client.onConnectionChange((connected) => {
- if (get().activeAccountId === account.id) {
- set({ connectionLost: !connected });
- }
- accountStore.updateAccount(account.id, { isConnected: connected });
- });
+ bindClientStatusHandlers(client, set, get, account.id);
await client.connect();
clients.set(account.id, client);
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
@@ -1047,12 +1134,7 @@ export const useAuthStore = create()(
if (res.ok) {
const { serverUrl, username, password } = await res.json();
const client = new JMAPClient(serverUrl, username, password);
- client.onConnectionChange((connected) => {
- if (get().activeAccountId === account.id) {
- set({ connectionLost: !connected });
- }
- accountStore.updateAccount(account.id, { isConnected: connected });
- });
+ bindClientStatusHandlers(client, set, get, account.id);
await client.connect();
clients.set(account.id, client);
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
@@ -1065,6 +1147,14 @@ export const useAuthStore = create()(
}
} catch (err) {
debug.error(`Failed to restore account ${account.id}:`, err);
+ if (isRateLimitError(err)) {
+ accountStore.updateAccount(account.id, {
+ isConnected: false,
+ hasError: true,
+ errorMessage: 'Temporarily rate limited by server',
+ });
+ continue;
+ }
// Remove unrestorable accounts so the user is prompted to log in
// again rather than seeing a stale error entry forever.
evictAccount(account.id);
@@ -1087,6 +1177,7 @@ export const useAuthStore = create()(
serverUrl: targetAccount.serverUrl,
username: targetAccount.username,
client: targetClient,
+ ...getClientRateLimitState(targetClient),
identities,
primaryIdentity,
authMode: targetAccount.authMode,
@@ -1119,6 +1210,7 @@ export const useAuthStore = create()(
serverUrl: acc.serverUrl,
username: acc.username,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: acc.authMode,
@@ -1132,10 +1224,24 @@ export const useAuthStore = create()(
}
// No accounts could be restored
+ if (accounts.some((account) => accountStore.getAccountById(account.id))) {
+ set({
+ isAuthenticated: false,
+ isLoading: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
+ client: null,
+ error: 'connection_failed',
+ });
+ return;
+ }
+
markSessionExpired();
set({
isAuthenticated: false,
isLoading: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
client: null,
serverUrl: null,
username: null,
@@ -1152,19 +1258,17 @@ export const useAuthStore = create()(
const state = get();
if (state.isAuthenticated && !state.client) {
if (state.authMode === 'oauth' && state.serverUrl) {
- set({ isLoading: true });
+ set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
try {
const token = await get().refreshAccessToken();
if (token && state.serverUrl) {
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn());
- client.onConnectionChange((connected) => {
- set({ connectionLost: !connected });
- });
await client.connect();
const accountId = generateAccountId(state.username || '', state.serverUrl);
clients.set(accountId, client);
+ bindClientStatusHandlers(client, set, get, accountId);
// Migrate to account registry
accountStore.addAccount({
@@ -1189,6 +1293,7 @@ export const useAuthStore = create()(
isAuthenticated: true,
isLoading: false,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
accessToken: token,
@@ -1205,12 +1310,16 @@ export const useAuthStore = create()(
}
} catch (error) {
debug.error('OAuth session restore failed:', error);
+ if (isRateLimitError(error)) {
+ set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
+ return;
+ }
clearRefreshTimer();
}
}
if (state.authMode === 'basic') {
- set({ isLoading: true });
+ set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
try {
const res = await fetch('/api/auth/session');
if (res.ok) {
@@ -1221,13 +1330,11 @@ export const useAuthStore = create()(
}
const { serverUrl, username, password } = data;
const client = new JMAPClient(serverUrl, username, password);
- client.onConnectionChange((connected) => {
- set({ connectionLost: !connected });
- });
await client.connect();
const accountId = generateAccountId(username, serverUrl);
clients.set(accountId, client);
+ bindClientStatusHandlers(client, set, get, accountId);
// Migrate to account registry
accountStore.addAccount({
@@ -1254,6 +1361,7 @@ export const useAuthStore = create()(
serverUrl,
username,
client,
+ ...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
@@ -1270,6 +1378,10 @@ export const useAuthStore = create()(
}
} catch (error) {
debug.error('Basic session restore failed:', error);
+ if (isRateLimitError(error)) {
+ set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
+ return;
+ }
}
}
@@ -1278,6 +1390,8 @@ export const useAuthStore = create()(
set({
isAuthenticated: false,
isLoading: false,
+ isRateLimited: false,
+ rateLimitUntil: null,
client: null,
serverUrl: null,
username: null,