diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index fd0e79ad7..43a55dab4 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -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(); + + 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(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 113f00296..2ed1ea435 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -565,6 +565,7 @@ export function EmailComposer({ const [scheduleError, setScheduleError] = useState(''); const [showSendMenu, setShowSendMenu] = useState(false); const sendMenuRef = useRef(null); + const mobileSendMenuRef = useRef(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(null); - const handleSendRef = useRef(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 ( -
+
{/* Drag overlay */} {isDraggingOver && ( @@ -2289,18 +2269,59 @@ export function EmailComposer({ )}
- {/* Mobile: send button in header */} - + {/* Mobile: keep scheduled send reachable without consuming footer space. */} + {composerClient?.hasDelayedSend() ? ( +
+ + + {showSendMenu && ( +
+ +
+ )} +
+ ) : ( + + )}
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 1c0fca1ca..3f128b7b4 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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} /> - handleAction(onCancelScheduled!)} - disabled={!onCancelScheduled} - /> void; isScheduledView?: boolean; onLoadMoreScheduled?: () => void; - onCancelScheduled?: (email: Email) => void | Promise; onCancelScheduledForEdit?: (email: Email) => void | Promise; onRescheduleScheduled?: (email: Email) => void | Promise; } @@ -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)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d64b145ab..e1b483c10 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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')} > {showToolbarLabels && {t('send_now')}} - - @@ -4409,18 +4403,25 @@ export function EmailViewer({ {/* Scheduled Banner */} {isScheduled && (
-
+
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
-
+
{canCancelScheduled && ( <> - - + - diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 37fdfa334..b0678299e 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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 ? ( + } + 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) => ( - - onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) - } - onToggleExpand={handleToggleExpand} - isCollapsed={isCollapsed} - onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} - colorful={colorfulSidebarIcons} - onContextMenu={isActive ? handleMailboxContextMenu : undefined} - /> + + + 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}`)} + ))} - {isActive && showScheduledMailbox && ( - } - 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) => ( + + {node.role === 'drafts' && renderScheduledRow('scheduled')} + ))} - {showScheduledMailbox && ( - } - label={t('scheduled')} - depth={0} - isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} - total={scheduledTotal} - onClick={() => onMailboxSelect?.('__scheduled__')} - isCollapsed={isCollapsed} - /> - )} + {!ownTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')} )} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index cedabd39b..3455234cc 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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); diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts new file mode 100644 index 000000000..2c076fa01 --- /dev/null +++ b/stores/__tests__/settings-store-sync.test.ts @@ -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 }, + }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index dfc80b74a..92a6f8b81 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1236,6 +1236,7 @@ export const useAuthStore = create()( accountStore.removeAccount(accountId); } + await useSettingsStore.getState().flushSync(); useSettingsStore.getState().disableSync(); await authHooks.onAfterLogout.emit({ @@ -1406,6 +1407,7 @@ export const useAuthStore = create()( 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()( snapshotAccount(state.activeAccountId); } clearAllStores(); + await useSettingsStore.getState().flushSync(); useSettingsStore.getState().disableSync(); } diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 033688492..3e55d655e 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -26,10 +26,43 @@ let syncEnabled = false; let syncUsername: string | null = null; let syncServerUrl: string | null = null; let syncTimeout: ReturnType | null = null; +let pendingSync: SettingsSyncJob | null = null; let isLoadingFromServer = false; const SYNC_DEBOUNCE_MS = 2000; +interface SettingsSyncJob { + username: string; + serverUrl: string; + settings: Record; +} + +async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise { + 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; disableSync: () => void; loadFromServer: (username: string, serverUrl: string) => Promise; } @@ -967,6 +1001,16 @@ export const useSettingsStore = create()( 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()( 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 => { - 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); }