From a46fb9c8af5aad2325633474f0ffaedd85771f0a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:22:40 +0100 Subject: [PATCH] feat: implement rate limiting handling in JMAPClient #104 --- app/[locale]/layout.tsx | 13 +- app/[locale]/page.tsx | 28 ++- .../providers/rate-limit-toast-provider.tsx | 30 +++ lib/__tests__/jmap-client-resilience.test.ts | 66 ++++++ lib/demo/demo-client.ts | 3 + lib/jmap/client-interface.ts | 3 + lib/jmap/client.ts | 143 +++++++++++- locales/de/common.json | 6 +- locales/en/common.json | 6 +- locales/es/common.json | 6 +- locales/fr/common.json | 6 +- locales/it/common.json | 6 +- locales/ja/common.json | 6 +- locales/nl/common.json | 6 +- locales/pt/common.json | 6 +- locales/ru/common.json | 6 +- stores/auth-store.ts | 208 ++++++++++++++---- 17 files changed, 481 insertions(+), 67 deletions(-) create mode 100644 components/providers/rate-limit-toast-provider.tsx diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 825ffdc2..12b1a4e3 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -3,6 +3,7 @@ import { IntlProvider } from "@/components/providers/intl-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; +import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { TourProvider } from "@/components/tour/tour-provider"; import { locales } from "@/i18n/routing"; @@ -28,11 +29,13 @@ export default async function LocaleLayout({ - - - {children} - - + + + + {children} + + + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index d7029920..b72193cd 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -41,7 +41,7 @@ import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; import { appendPlainTextSignature } from "@/lib/signature-utils"; -import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react"; +import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; @@ -68,11 +68,28 @@ export default function Home() { const [conversationThread, setConversationThread] = useState(null); const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); + const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); + useEffect(() => { + if (!isRateLimited || !rateLimitUntil) { + setRateLimitSecondsLeft(null); + return; + } + + const updateCountdown = () => { + const seconds = Math.max(1, Math.ceil((rateLimitUntil - Date.now()) / 1000)); + setRateLimitSecondsLeft(seconds); + }; + + updateCountdown(); + const timer = setInterval(updateCountdown, 1000); + return () => clearInterval(timer); + }, [isRateLimited, rateLimitUntil]); + // Mobile/tablet responsive hooks const { isMobile, isTablet } = useDeviceDetection(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); @@ -1069,6 +1086,13 @@ export default function Home() { return (
+ {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,