feat: implement rate limiting handling in JMAPClient #104
This commit is contained in:
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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 }; }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メールを送信しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user