fix: polish scheduled send follow-ups

This commit is contained in:
Lucas Gaitzsch
2026-08-12 17:46:51 +02:00
parent 6d827e430f
commit 17f18e378e
10 changed files with 250 additions and 145 deletions
@@ -5,8 +5,15 @@ import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
const editorKeyDown = vi.hoisted(() => vi.fn());
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
RichTextEditor: () => React.createElement('div', {
'data-testid': 'rich-text-editor',
onKeyDown: (event: React.KeyboardEvent) => {
if (!event.defaultPrevented) editorKeyDown();
},
}),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
@@ -192,6 +199,7 @@ const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButton
describe('composer empty subject warning', () => {
beforeEach(() => {
updateSetting.mockClear();
editorKeyDown.mockClear();
});
it('keeps Send enabled when the subject is empty', () => {
@@ -209,6 +217,17 @@ describe('composer empty subject warning', () => {
expect(onSend).not.toHaveBeenCalled();
});
it('intercepts Ctrl+Enter before the editor inserts a newline', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
fireEvent.keyDown(screen.getByTestId('rich-text-editor'), { key: 'Enter', ctrlKey: true });
expect(await screen.findByText('empty_subject.title')).toBeInTheDocument();
expect(editorKeyDown).not.toHaveBeenCalled();
expect(onSend).not.toHaveBeenCalled();
});
it('sends with an empty subject once confirmed', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
+60 -39
View File
@@ -565,6 +565,7 @@ export function EmailComposer({
const [scheduleError, setScheduleError] = useState('');
const [showSendMenu, setShowSendMenu] = useState(false);
const sendMenuRef = useRef<HTMLDivElement>(null);
const mobileSendMenuRef = useRef<HTMLDivElement>(null);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -696,7 +697,7 @@ export function EmailComposer({
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
if (!sendMenuRef.current?.contains(event.target as Node)) {
if (!sendMenuRef.current?.contains(event.target as Node) && !mobileSendMenuRef.current?.contains(event.target as Node)) {
setShowSendMenu(false);
}
};
@@ -2099,30 +2100,6 @@ export function EmailComposer({
handleSend({ delayedUntil: new Date(scheduleValue).toISOString() });
};
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
// draft. Scoped to events whose target lives inside this composer's
// DOM tree — in Pro mode multiple composer tabs can be mounted at
// once (inactive tabs are CSS-hidden, not unmounted), so a window
// listener would otherwise fire every mounted composer's handleSend
// on a single keystroke. handleSend is rebound every render, so we
// route through a ref to keep the listener stable.
const composerRootRef = useRef<HTMLDivElement | null>(null);
const handleSendRef = useRef<typeof handleSend | undefined>(undefined);
handleSendRef.current = handleSend;
useEffect(() => {
const handleSendShortcut = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
if (!(e.ctrlKey || e.metaKey)) return;
if (e.altKey || e.shiftKey) return;
const root = composerRootRef.current;
if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return;
e.preventDefault();
void handleSendRef.current?.();
};
window.addEventListener('keydown', handleSendShortcut);
return () => window.removeEventListener('keydown', handleSendShortcut);
}, []);
const cleanClose = () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
@@ -2237,7 +2214,11 @@ export function EmailComposer({
};
return (
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
<div
data-testid="email-composer"
className={cn("flex h-full bg-background", className)}
onKeyDownCapture={handleComposerKeyDown}
>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
@@ -2250,7 +2231,6 @@ export function EmailComposer({
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onKeyDown={handleComposerKeyDown}
>
{/* Drag overlay */}
{isDraggingOver && (
@@ -2289,18 +2269,59 @@ export function EmailComposer({
)}
</div>
</div>
{/* Mobile: send button in header */}
<Button
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 me-1.5" />
{t('send')}
</Button>
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
{composerClient?.hasDelayedSend() ? (
<div ref={mobileSendMenuRef} className="relative inline-flex md:hidden">
<Button
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="h-9 rounded-e-none border-e border-primary-foreground/20 px-3"
>
<Send className="w-4 h-4 me-1.5" />
{t('send')}
</Button>
<Button
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
title={t('schedule_send')}
size="sm"
className="h-9 rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
>
<ChevronDown className="w-4 h-4" />
</Button>
{showSendMenu && (
<div role="menu" className="absolute end-0 top-full z-50 mt-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg">
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
</div>
)}
</div>
) : (
<Button
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 me-1.5" />
{t('send')}
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-auto">
-9
View File
@@ -31,7 +31,6 @@ import {
ShieldCheck,
EditIcon,
CalendarClock,
XCircle,
Paperclip,
Link as LinkIcon,
MessagesSquare,
@@ -74,7 +73,6 @@ interface EmailContextMenuProps {
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: () => void;
// Batch actions
@@ -135,7 +133,6 @@ export function EmailContextMenu({
onBatchMarkAsSpam,
onBatchUndoSpam,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailContextMenuProps) {
@@ -210,12 +207,6 @@ export function EmailContextMenu({
onClick={() => handleAction(onRescheduleScheduled!)}
disabled={!onRescheduleScheduled}
/>
<ContextMenuItem
icon={XCircle}
label={t("cancel_scheduled_send")}
onClick={() => handleAction(onCancelScheduled!)}
disabled={!onCancelScheduled}
/>
<ContextMenuItem
icon={EditIcon}
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
-3
View File
@@ -47,7 +47,6 @@ interface EmailListProps {
onEditDraft?: (email: Email) => void;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
onCancelScheduled?: (email: Email) => void | Promise<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
}
@@ -78,7 +77,6 @@ export function EmailList({
onEditDraft,
isScheduledView = false,
onLoadMoreScheduled,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailListProps) {
@@ -608,7 +606,6 @@ export function EmailList({
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
+18 -14
View File
@@ -136,7 +136,6 @@ interface EmailViewerProps {
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: (delayedUntil: string) => void;
onCompose?: () => void;
@@ -636,7 +635,6 @@ export function EmailViewer({
onNavigatePrev,
onShowShortcuts,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
onCompose,
@@ -2854,16 +2852,12 @@ export function EmailViewer({
variant="default"
size="sm"
onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}
className="sm:flex sm:h-8"
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('send_now')}
>
<Send className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('send_now')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>}
</Button>
<Button
variant="ghost"
size="sm"
@@ -2871,13 +2865,13 @@ export function EmailViewer({
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
className="hidden sm:flex sm:h-8"
className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0"
title={t('reschedule_send')}
>
<CalendarClock className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<EditIcon className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
@@ -4409,18 +4403,25 @@ export function EmailViewer({
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="px-4 sm:px-6 py-2.5 flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{canCancelScheduled && (
<>
<Button size="sm" variant="default" onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}>{t('send_now')}</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}
className="gap-1.5"
>
<Send className="w-3.5 h-3.5" />
{t('send_now')}
</Button>
<Button
size="sm"
variant="outline"
@@ -4428,10 +4429,13 @@ export function EmailViewer({
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
className="gap-1.5"
>
<CalendarClock className="w-3.5 h-3.5" />
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit} className="gap-1.5">
<EditIcon className="w-3.5 h-3.5" />
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
+38 -38
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo, ReactNode } from "react";
import { useState, useEffect, useMemo, Fragment, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -985,6 +985,22 @@ export function Sidebar({
})
: [];
const renderScheduledRow = (key: string) => showScheduledMailbox ? (
<SidebarRow
key={key}
icon={<CalendarClock className="w-4 h-4 flex-shrink-0 text-sky-600 dark:text-sky-400" />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
testRole="scheduled"
testName="scheduled"
testMailboxId="__scheduled__"
/>
) : null;
const getUnifiedIcon = (role: UnifiedMailboxRole) => {
switch (role) {
case 'inbox': return Inbox;
@@ -1235,32 +1251,24 @@ export function Sidebar({
) : (
<>
{tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
<Fragment key={node.id}>
<MailboxTreeItem
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
{isActive && node.role === 'drafts' && renderScheduledRow(`scheduled-${account.id}`)}
</Fragment>
))}
{isActive && showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
{isActive && !tree.some((node) => node.role === 'drafts') && renderScheduledRow(`scheduled-${account.id}`)}
</>
)}
</>
@@ -1288,8 +1296,8 @@ export function Sidebar({
) : (
<>
{ownTree.map((node) => (
<Fragment key={node.id}>
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
@@ -1300,18 +1308,10 @@ export function Sidebar({
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
{node.role === 'drafts' && renderScheduledRow('scheduled')}
</Fragment>
))}
{showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
{!ownTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')}
</>
)}
</>
+3 -8
View File
@@ -337,7 +337,6 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
setScheduledView,
fetchScheduledEmails,
loadMoreScheduledEmails,
cancelScheduledEmail,
cancelScheduledEmailForEdit,
rescheduleScheduledEmail,
refreshScheduledMetadata,
@@ -459,6 +458,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
const ctx = navRestoreStateRef.current;
let restoredEmails = ctx.emails;
// Restore sidebar overlay state.
setSidebarOpen(state.sidebarOpen);
@@ -489,6 +489,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
if (ctx.client) {
try {
await fetchScheduledEmails(ctx.client);
restoredEmails = useEmailStore.getState().scheduledEmails;
} catch (error) {
debug.error('Failed to fetch scheduled emails on history restore:', error);
}
@@ -522,7 +523,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
} else {
// Try the in-memory list first; the existing useEffect will fetch
// body content if it's missing.
const found = ctx.emails.find(e => e.id === state.emailId);
const found = restoredEmails.find(e => e.id === state.emailId);
if (found) {
selectEmail(found);
} else if (ctx.client) {
@@ -3533,9 +3534,6 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
isScheduledView={isScheduledView}
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
onCancelScheduled={async (email) => {
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
}}
onCancelScheduledForEdit={async (email) => {
if (!client) return;
const restored = await cancelScheduledEmailForEdit(client, email);
@@ -3847,9 +3845,6 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCancelScheduled={async () => {
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
}}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiFetch = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
import { useSettingsStore } from '../settings-store';
describe('settings sync debounce', () => {
beforeEach(() => {
apiFetch.mockReset();
apiFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
useSettingsStore.getState().disableSync();
useSettingsStore.setState({ sendDelaySeconds: 0 });
});
it('flushes a pending snapshot for the account that scheduled it', async () => {
const settings = useSettingsStore.getState();
settings.enableSync('a@example.com', 'https://mail-a.example.com');
settings.updateSetting('sendDelaySeconds', 10);
await useSettingsStore.getState().flushSync();
expect(apiFetch).toHaveBeenCalledTimes(1);
const request = apiFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(request.body as string)).toMatchObject({
username: 'a@example.com',
serverUrl: 'https://mail-a.example.com',
settings: { sendDelaySeconds: 10 },
});
});
it('does not rewrite a pending account snapshot after another account becomes active', async () => {
const settings = useSettingsStore.getState();
settings.enableSync('a@example.com', 'https://mail-a.example.com');
settings.updateSetting('sendDelaySeconds', 10);
const flush = useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
useSettingsStore.getState().enableSync('b@example.com', 'https://mail-b.example.com');
useSettingsStore.getState().updateSetting('sendDelaySeconds', 30);
await flush;
const request = apiFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(request.body as string)).toMatchObject({
username: 'a@example.com',
settings: { sendDelaySeconds: 10 },
});
});
});
+3
View File
@@ -1236,6 +1236,7 @@ export const useAuthStore = create<AuthState>()(
accountStore.removeAccount(accountId);
}
await useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
await authHooks.onAfterLogout.emit({
@@ -1406,6 +1407,7 @@ export const useAuthStore = create<AuthState>()(
snapshotAccount(state.activeAccountId);
}
clearAllStores();
await useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
// Client not connected - try to restore
@@ -1546,6 +1548,7 @@ export const useAuthStore = create<AuthState>()(
snapshotAccount(state.activeAccountId);
}
clearAllStores();
await useSettingsStore.getState().flushSync();
useSettingsStore.getState().disableSync();
}
+55 -33
View File
@@ -26,10 +26,43 @@ let syncEnabled = false;
let syncUsername: string | null = null;
let syncServerUrl: string | null = null;
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingSync: SettingsSyncJob | null = null;
let isLoadingFromServer = false;
const SYNC_DEBOUNCE_MS = 2000;
interface SettingsSyncJob {
username: string;
serverUrl: string;
settings: Record<string, unknown>;
}
async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise<void> {
syncLog('Syncing settings to server for', job.username);
const res = await apiFetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(job),
});
if (res.status === 404) {
syncWarn('Settings sync endpoint returned 404, disabling sync');
syncEnabled = false;
} else if (res.status === 403) {
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
syncEnabled = false;
} else if (res.status >= 500 && retries > 0) {
const body = await res.json().catch(() => ({}));
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');
await new Promise((resolve) => setTimeout(resolve, 2000));
return syncSettingsJob(job, retries - 1);
} else if (!res.ok) {
const body = await res.json().catch(() => ({}));
syncError('Settings sync failed:', body.error || `status ${res.status}`);
} else {
syncLog('Settings synced to server successfully');
}
}
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
/** @deprecated Use Density instead */
@@ -450,6 +483,7 @@ interface SettingsState {
// Settings sync
enableSync: (username: string, serverUrl: string) => void;
flushSync: () => Promise<void>;
disableSync: () => void;
loadFromServer: (username: string, serverUrl: string) => Promise<boolean>;
}
@@ -967,6 +1001,16 @@ export const useSettingsStore = create<SettingsState>()(
syncLog('Settings sync enabled for', username);
},
flushSync: async () => {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
const job = pendingSync;
pendingSync = null;
if (job) await syncSettingsJob(job);
},
disableSync: () => {
syncEnabled = false;
syncUsername = null;
@@ -975,6 +1019,7 @@ export const useSettingsStore = create<SettingsState>()(
clearTimeout(syncTimeout);
syncTimeout = null;
}
pendingSync = null;
syncLog('Settings sync disabled');
},
@@ -1189,44 +1234,21 @@ if (typeof window !== 'undefined') {
applyDensity(store.density);
applyAnimations(store.animationsEnabled);
// Shared sync function used by all store subscribers
const syncToServer = async (retries = 1): Promise<void> => {
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
syncLog('Syncing settings to server...');
const res = await apiFetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
});
if (res.status === 404) {
syncWarn('Settings sync endpoint returned 404, disabling sync');
syncEnabled = false;
} else if (res.status === 403) {
// Identity mismatch - current session cookies don't match the
// username/serverUrl we're syncing for (common in dev mock mode where
// no stalwart-context cookie is written, or when rememberMe is off).
// Retrying won't help for this session; disable to stop the noise.
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
syncEnabled = false;
} else if (res.status >= 500 && retries > 0) {
const body = await res.json().catch(() => ({}));
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');
await new Promise((r) => setTimeout(r, 2000));
return syncToServer(retries - 1);
} else if (!res.ok) {
const body = await res.json().catch(() => ({}));
syncError('Settings sync failed:', body.error || `status ${res.status}`);
} else {
syncLog('Settings synced to server successfully');
}
};
const triggerSync = () => {
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
pendingSync = {
username: syncUsername,
serverUrl: syncServerUrl,
settings: JSON.parse(useSettingsStore.getState().exportSettings()),
};
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(async () => {
syncTimeout = null;
const job = pendingSync;
pendingSync = null;
if (!job) return;
try {
await syncToServer();
await syncSettingsJob(job);
} catch (error) {
syncError('Settings sync error:', error);
}