feat: implement rate limiting handling in JMAPClient #104

This commit is contained in:
Linus Rath
2026-03-27 00:22:40 +01:00
parent 6696636df8
commit a46fb9c8af
17 changed files with 481 additions and 67 deletions
+8 -5
View File
@@ -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({
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<CalendarAlertProvider>
<EmbeddedBridgeProvider>
<TourProvider>
{children}
</TourProvider>
</EmbeddedBridgeProvider>
<RateLimitToastProvider>
<EmbeddedBridgeProvider>
<TourProvider>
{children}
</TourProvider>
</EmbeddedBridgeProvider>
</RateLimitToastProvider>
</CalendarAlertProvider>
</ThemeProvider>
</IntlProvider>
+26 -2
View File
@@ -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<ThreadGroup | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden">
{isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5" />
<span>{tCommon('rate_limited_title')}</span>
<span className="text-amber-700/80 dark:text-amber-300/80">{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}</span>
</div>
)}
{connectionLost && (
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
@@ -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<RateLimitBlockedDetail>).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}</>;
}
@@ -22,6 +22,16 @@ function mockFetchResponse(status: number, body?: unknown): Response {
});
}
function mockFetchResponseWithHeaders(status: number, headers: Record<string, string>, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: {
'Content-Type': 'application/json',
...headers,
},
});
}
describe('JMAPClient resilience', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
@@ -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', () => {
+3
View File
@@ -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 }; }
+3
View File
@@ -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;
+139 -4
View File
@@ -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<typeof fetch>[1]): Promise<Response> {
// 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<string, string>, '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<void> {
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<void> {
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;
}
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "メールを送信しました",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Письмо успешно отправлено",
+161 -47
View File
@@ -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<AuthState, 'isRateLimited' | 'rateLimitUntil'> {
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<AuthState>) => 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<AuthState>) => 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<AuthState>()(
isAuthenticated: false,
isLoading: false,
error: null,
isRateLimited: false,
rateLimitUntil: null,
serverUrl: null,
username: null,
client: null,
@@ -284,13 +347,10 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
// 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<AuthState>()(
serverUrl,
username,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
@@ -388,6 +450,8 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: classifyLoginError(error),
isAuthenticated: false,
isRateLimited: false,
rateLimitUntil: null,
client: null,
});
return false;
@@ -395,7 +459,7 @@ export const useAuthStore = create<AuthState>()(
},
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<AuthState>()(
serverUrl: 'demo.example.com',
username,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
@@ -450,6 +515,8 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: 'generic',
isAuthenticated: false,
isRateLimited: false,
rateLimitUntil: null,
client: null,
});
return false;
@@ -457,7 +524,7 @@ export const useAuthStore = create<AuthState>()(
},
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<AuthState>()(
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<AuthState>()(
}
clients.set(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
label: primaryIdentity?.name || username,
@@ -528,6 +593,7 @@ export const useAuthStore = create<AuthState>()(
serverUrl,
username,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'oauth',
@@ -564,6 +630,8 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: errorMsg,
isAuthenticated: false,
isRateLimited: false,
rateLimitUntil: null,
client: null,
});
return false;
@@ -571,7 +639,7 @@ export const useAuthStore = create<AuthState>()(
},
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<AuthState>()(
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<AuthState>()(
}
clients.set(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
label: primaryIdentity?.name || username,
@@ -645,6 +711,7 @@ export const useAuthStore = create<AuthState>()(
serverUrl: ssoServerUrl,
username,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'oauth',
@@ -675,6 +742,8 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: errorMsg,
isAuthenticated: false,
isRateLimited: false,
rateLimitUntil: null,
client: null,
});
return false;
@@ -866,7 +935,7 @@ export const useAuthStore = create<AuthState>()(
// 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<AuthState>()(
// 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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
}
} 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<AuthState>()(
serverUrl: targetAccount.serverUrl,
username: targetAccount.username,
client: targetClient,
...getClientRateLimitState(targetClient),
identities,
primaryIdentity,
authMode: targetAccount.authMode,
@@ -1119,6 +1210,7 @@ export const useAuthStore = create<AuthState>()(
serverUrl: acc.serverUrl,
username: acc.username,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: acc.authMode,
@@ -1132,10 +1224,24 @@ export const useAuthStore = create<AuthState>()(
}
// 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<AuthState>()(
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<AuthState>()(
isAuthenticated: true,
isLoading: false,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
accessToken: token,
@@ -1205,12 +1310,16 @@ export const useAuthStore = create<AuthState>()(
}
} 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<AuthState>()(
}
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<AuthState>()(
serverUrl,
username,
client,
...getClientRateLimitState(client),
identities,
primaryIdentity,
authMode: 'basic',
@@ -1270,6 +1378,10 @@ export const useAuthStore = create<AuthState>()(
}
} 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<AuthState>()(
set({
isAuthenticated: false,
isLoading: false,
isRateLimited: false,
rateLimitUntil: null,
client: null,
serverUrl: null,
username: null,