src/App.tsx
import { readNotifications, saveNotifications } from "./notifications";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { inboxTab, inboxTabs, type InboxTab } from "../shared/inbox-tabs";
import type { Draft, Mail, State } from "../shared/types";
import { emptyComposePreferences, type ComposePreferences } from "../shared/mail-features";
import { initialCompose } from "./compose/ComposePreferences";
import { ApiError, coalesceReload } from "./api";
import { applyAppearance, hexColor, readAppearance, resolveScheme, saveAppearance } from "./appearance";
import type { ThemePreview } from "./themes";
import { ComposePanel, sendCompose, type Compose } from "./compose/ComposePanel";
import { DraftPicker } from "./compose/DraftPicker";
import { DraftSession, type DraftProgress } from "./compose/draft-session";
import { ConnectDialog, ProviderChoices, type ImapProvider } from "./dialogs/ConnectDialog";
import { LabelForm } from "./dialogs/LabelForm";
import { ShortcutsDialog } from "./dialogs/ShortcutsDialog";
import { SnoozeDialog } from "./dialogs/SnoozeDialog";
import { TeachDialog } from "./dialogs/TeachDialog";
import { TrashDialog } from "./dialogs/TrashDialog";
import { CommandPalette, type PaletteCommand } from "./dialogs/CommandPalette";
import { interactionActions, matches, read as readInteraction, save as saveInteraction, shortcutLabel } from "./interaction";
import { InboxTabs } from "./mail/InboxTabs";
import { ListEmpty } from "./mail/ListEmpty";
import { MailActions } from "./mail/MailActions";
import { MailFilters } from "./mail/MailFilters";
import { MailList } from "./mail/MailList";
import { Reader, ReaderEmpty } from "./mail/Reader";
import { SenderPanel } from "./mail/SenderPanel";
import { tabName, viewNames, viewOrder, type View } from "./mail/format";
import { customLabels, densityShortcut, filterMail, matchesLabel, markMailRead } from "./mail-ui";
import { reuseMailState } from "./mail-state";
import { compileMailSearch } from "./mail-search";
import { useMailNavigation, type MailLocation } from "./navigation";
import { collectPrivateNames, redactPrivateText } from "./privacy";
import { SettingsPage } from "./settings/SettingsPage";
import { PaneHeader, type Corner } from "./shell/PaneHeader";
import { Sidebar } from "./shell/Sidebar";
import { UpdateNotice } from "./shell/UpdateNotice";
import { ContextMenu, mailMenuRequest, type MenuAction } from "./ui/ContextMenu";
import { Dialog } from "./ui/Dialog";
import { MailPrivacy, PrivacyContext, Redact } from "./ui/Privacy";
import { drawsWindowControls, systemCaption } from "./ui/TrafficLights";
import { useMedia } from "./ui/useMedia";
import { CredentialFallbackNotice } from "./shell/CredentialFallbackNotice";
import { ToastStack, useToasts } from "./ui/Toasts";
import { ArrowLeft, ArrowRight, Close, Eye, EyeOff, PanelRight, Search, SidebarIcon } from "./ui/icons";
// Direction the list pane enters from: tabs slide sideways, mail opens upward.
type Motion = "forward" | "back" | "open" | "close";
const empty: State = {
accounts: [],
messages: [],
labels: [],
rules: [],
googleConfigured: false,
googleClientId: null,
jevConfigured: false,
jevProvider: "typesafe",
aiConfigured: false,
lastSync: null,
syncing: false,
};
export function App() {
const [state, setState] = useState(empty);
const navigation = useMailNavigation();
const { page, settingsSection, view, tab, accountFilter, labelFilter, selectedId, query, opened, readFilter, relevanceFilter, sort } = navigation.location;
const setSelectedId = (selectedId: string | null) =>
navigation.update((current) => ({ ...current, selectedId }));
const setOpened = (opened: boolean) =>
navigation.update((current) => ({ ...current, opened }));
const [interaction, setInteraction] = useState(readInteraction);
const shortcut = (action: typeof interactionActions[number]["id"]) => shortcutLabel(interaction.shortcuts[action]);
const [searching, setSearching] = useState(false);
const settings = page === "settings";
const setSettings = (next: boolean | ((current: boolean) => boolean)) => {
const value = typeof next === "function" ? next(settings) : next;
navigate({ page: value ? "settings" : "mail", opened: false });
};
const setSettingsSection = (settingsSection: MailLocation["settingsSection"]) => navigate({ settingsSection });
const setLabelFilter = (labelFilter: string) => navigation.update(current => ({ ...current, labelFilter }));
const [labelMail, setLabelMail] = useState<Mail | null>(null);
const [contextMenu, setContextMenu] = useState<ReturnType<typeof mailMenuRequest> | null>(null);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
const [connecting, setConnecting] = useState<ImapProvider | "choose" | null>(null);
const [compose, setCompose] = useState<Compose | null>(null);
const [draftPicker, setDraftPicker] = useState(false);
const [draftProgress, setDraftProgress] = useState<DraftProgress>({ phase: "idle", message: "Draft saves on this device as you type." });
const draftSession = useRef<DraftSession | null>(null);
const draftLock = useRef(false);
const draftTask = useRef<Promise<void> | undefined>(undefined);
const [updateInstalling, setUpdateInstalling] = useState(false);
const sendTask = useRef<Promise<void> | undefined>(undefined);
const [teach, setTeach] = useState<Mail | null>(null);
const [snooze, setSnooze] = useState<Mail | null>(null);
const [trashMail, setTrashMail] = useState<Mail | null>(null);
const [shortcuts, setShortcuts] = useState(false);
const [palette, setPalette] = useState(false);
const [settingsModal, setSettingsModal] = useState(false);
useEffect(() => { if (updateInstalling) setPalette(false); }, [updateInstalling]);
const navigationBlocked = !!(compose || draftPicker || teach || snooze || trashMail || shortcuts || labelMail || connecting || updateInstalling || settingsModal);
const navigate = (patch: Partial<MailLocation>, search = false) => {
if (navigationBlocked) return false;
navigation.visit(patch, search);
return true;
};
const travel = (direction: -1 | 1) => {
if (navigationBlocked || contextMenu || palette) return;
const active = document.activeElement;
const restoreFocus = active === document.body || active instanceof HTMLElement && !!active.closest(".row, .message");
navigation.travel(direction, (target) => {
const accountExists = target.accountFilter === "all" ||
state.accounts.some((account) => account.id === target.accountFilter);
const mailExists = state.messages.some((mail) => mail.id === target.selectedId &&
state.accounts.some((account) => account.id === mail.accountId));
const mail = state.messages.find((mail) => mail.id === target.selectedId);
if (target.opened && mailExists && mail) markRead(mail);
if (restoreFocus && target.opened !== opened) {
readerFocusPending.current = target.opened && mailExists;
listFocusPending.current = !target.opened && opened;
}
setSearching(!!target.query);
return { ...target, accountFilter: accountExists ? target.accountFilter : "all",
selectedId: mailExists ? target.selectedId : null, opened: target.opened && mailExists };
});
};
useEffect(() => {
const native = !!window.desktop?.onNavigation &&
["win32", "linux"].includes(window.desktop.platform);
const unsubscribe = native ? window.desktop?.onNavigation((direction) =>
travel(direction === "back" ? -1 : 1)) : undefined;
const mouse = (event: MouseEvent) => {
if (event.button !== 3 && event.button !== 4) return;
event.preventDefault();
const element = event.target instanceof Element ? event.target : null;
const guarded = element?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="dialog"], [role="menu"], [data-update-command], .update-notice, .credential-fallback-notice');
if (!native && !guarded && event.type === "mouseup") travel(event.button === 3 ? -1 : 1);
};
window.addEventListener("mousedown", mouse, true);
window.addEventListener("mouseup", mouse, true);
window.addEventListener("auxclick", mouse, true);
return () => {
unsubscribe?.();
window.removeEventListener("mousedown", mouse, true);
window.removeEventListener("mouseup", mouse, true);
window.removeEventListener("auxclick", mouse, true);
};
});
const [motion, setMotion] = useState<Motion>("forward");
// Privacy blurs email addresses; re-enabling it hides revealed ones again.
const [privacy, setPrivacy] = useState(
() => localStorage.getItem("privacy") === "on",
);
const [revealed, setRevealed] = useState<Set<string>>(() => new Set());
// Clicking an address toggles just that one; the eye hides every revealed one.
const toggleAddress = useCallback((address: string) =>
setRevealed((current) => {
const next = new Set(current);
if (!next.delete(address)) next.add(address);
return next;
}), []);
const togglePrivacy = () => {
localStorage.setItem("privacy", privacy ? "off" : "on");
if (!privacy) setRevealed(new Set());
setPrivacy(!privacy);
};
const [maximized, setMaximized] = useState(false);
useEffect(() => window.desktop?.onWindowState(setMaximized), []);
const [windowSide, setWindowSide] = useState<"left" | "right">(() =>
localStorage.getItem("window-controls") === "right" ? "right" : "left",
);
const placeWindowControls = (side: "left" | "right") => {
localStorage.setItem("window-controls", side);
setWindowSide(side);
};
const listRef = useRef<HTMLDivElement>(null);
const readerRef = useRef<HTMLDivElement>(null);
const readerFocusPending = useRef(false);
const listFocusPending = useRef(false);
// The sidebar stays open or closed between launches.
const [sidebarOpen, setSidebarOpen] = useState(() => localStorage.getItem("sidebar") !== "closed");
const toggleSidebar = () => {
localStorage.setItem("sidebar", sidebarOpen ? "closed" : "open");
setSidebarOpen(!sidebarOpen);
};
// The Filters popover; its button stays lit while a filter or sort differs from its default.
const [filtersOpen, setFiltersOpen] = useState(false);
const [appearance, setAppearance] = useState(readAppearance);
const [themePreview, setThemePreview] = useState<ThemePreview | null>(null);
const prefersLight = useMedia("(prefers-color-scheme: light)", false);
const scheme = resolveScheme(appearance.mode, prefersLight);
useLayoutEffect(() => {
applyAppearance(appearance, scheme, document.documentElement, themePreview);
// System caption buttons take the pane surface and text color beneath them.
if (!systemCaption) return;
const tokens = window.getComputedStyle(document.documentElement);
const color = hexColor(tokens.getPropertyValue("--surface"));
const symbolColor = hexColor(tokens.getPropertyValue("--text"));
if (color && symbolColor) window.desktop?.setOverlay?.({ color, symbolColor });
}, [appearance, scheme, themePreview]);
// Panes that need room drop out as the window narrows: the sender panel, then
// the reader beside the list (mail then opens in place), then the sidebar.
const roomy = useMedia("(min-width: 760px)");
const splitRoom = useMedia("(min-width: 980px)");
const wide = useMedia("(min-width: 1180px)");
const wider = useMedia("(min-width: 1440px)");
const split = appearance.reading === "split" && splitRoom;
const senderRoom = split ? wider : wide;
const sidebarShown = sidebarOpen && roomy;
const [senderPanel, setSenderPanel] = useState(() => localStorage.getItem("sender-panel") !== "closed");
const toggleSenderPanel = () => {
localStorage.setItem("sender-panel", senderPanel ? "closed" : "open");
setSenderPanel(!senderPanel);
};
const contextShown = !settings && senderPanel && senderRoom;
const [density, setDensity] = useState<"comfortable" | "compact">(
"comfortable",
);
const [busy, setBusy] = useState(false);
const [syncing, setSyncing] = useState(false);
const [notifications, setNotifications] = useState(readNotifications);
const { toasts, push: pushToast, dismiss: dismissToast } = useToasts(notifications);
const setNotice = useCallback((text: string) => { if (text) pushToast(text, "error"); }, [pushToast]);
const arrivals = useRef<number | undefined>(undefined);
const [loaded, setLoaded] = useState(false);
const [recent, setRecent] = useState<string[]>([]);
const csrf = useRef("");
const pendingReads = useRef(new Set<string>());
const [reading, setReading] = useState(new Set<string>());
const trashLock = useRef(false);
const loadRevision = useRef(0);
const syncLock = useRef(false);
const sendLock = useRef(false);
const searchRef = useRef<HTMLInputElement>(null);
const api = useCallback(
async <T,>(url: string, method = "GET", body?: unknown): Promise<T> => {
const res = await fetch(`/api${url}`, {
method,
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf.current,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await res.json()) as T & { error?: string };
if (!res.ok) throw new ApiError(res.status, data.error || "Request failed. Try again.");
return data;
},
[],
);
const openCompose = (value: Compose | Draft) => {
const session = new DraftSession(api, value, (progress) => {
if (draftSession.current === session) {
setDraftProgress(progress);
setCompose(session.content);
}
});
draftSession.current = session;
setCompose(session.content);
setDraftProgress({ phase: "idle", message: "Draft saves on this device as you type." });
session.change(session.content);
};
const editCompose = (value: Compose) => {
const session = draftSession.current;
if (!session || sendLock.current || draftLock.current || updateInstalling) return;
if (value.accountId !== session.content.accountId) {
if (session.dirty) return;
if (session.stored && !confirm("Keep the existing draft and start a copy from the selected account?")) return;
openCompose(value);
} else {
session.change(value);
setCompose(session.content);
}
};
const runDraftAction = (task: (session: DraftSession) => Promise<void>) => {
const session = draftSession.current;
if (!session || busy || sendLock.current || draftLock.current || updateInstalling) return;
draftLock.current = true;
setBusy(true);
draftTask.current = task(session).catch(() => {
setNotice("Draft action could not be confirmed. Your text is still open. Check the draft save status before closing.");
}).finally(() => { draftLock.current = false; setBusy(false); });
};
const closeCompose = () => runDraftAction(async (session) => {
await session.flush();
draftSession.current = null;
setCompose(null);
});
const reload = useMemo(() => coalesceReload(async () => {
const revision = ++loadRevision.current;
const data = await api<State & { csrf: string }>("/state");
csrf.current = data.csrf;
if (revision !== loadRevision.current) return data;
if (data.unreadArrivals !== undefined) {
if (arrivals.current !== undefined && data.unreadArrivals > arrivals.current) {
const count = data.unreadArrivals - arrivals.current;
pushToast(`${count} new unread ${count === 1 ? "email" : "emails"}.`, "info", `arrival:${revision}`);
}
arrivals.current = data.unreadArrivals;
}
setState((current) => reuseMailState(current, data));
setSyncing(data.syncing || syncLock.current);
setLoaded(true);
return data;
}), [api, pushToast]);
const sync = useCallback(async () => {
if (syncLock.current) return;
syncLock.current = true;
setSyncing(true);
try {
const result = await api<{ errors: string[] }>("/sync", "POST");
if (result.errors.length) setNotice(result.errors.join("\n"));
} catch (error) {
setNotice(error instanceof Error ? error.message : "Sync failed.");
} finally {
syncLock.current = false;
try { await reload(); }
catch (error) {
setSyncing(false);
setNotice(error instanceof Error ? error.message : "Sync failed.");
}
}
}, [api, reload]);
useEffect(() => {
if (!syncing || window.desktop?.onMailChanged) return;
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
try { await reload(); }
catch (error) { if (!stopped) setNotice(error instanceof Error ? error.message : "Sync failed."); }
if (!stopped) timer = setTimeout(() => void poll(), 1000);
};
timer = setTimeout(() => void poll(), 1000);
return () => { stopped = true; clearTimeout(timer); };
}, [syncing, reload]);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get("error")) {
setNotice(params.get("error")!);
setSettings(true);
}
if (params.has("connected") || params.has("error"))
history.replaceState({}, "", "/");
void reload()
.then((data) => {
if (data.accounts.length && !params.has("connected")) void sync();
})
.catch((error) => setNotice(error.message));
}, [reload, sync]);
useEffect(() => {
if (window.desktop || !state.accounts.length) return;
const timer = setInterval(() => void sync(), 300000);
return () => clearInterval(timer);
}, [state.accounts.length, sync]);
useEffect(
() =>
window.desktop?.onAccountConnected((error) => {
if (error) setNotice(error);
else {
setConnecting(current => current === "choose" ? null : current);
void reload()
.catch((error) => setNotice(error.message));
}
}),
[reload],
);
useEffect(
() =>
window.desktop?.onMailChanged(() => {
void reload().catch((error) => setNotice(error.message));
}),
[reload],
);
useEffect(() => window.desktop?.updates?.onDrain(async () => {
await sendTask.current;
await draftTask.current;
await draftSession.current?.flush();
// Let the send result update the draft/unload guard before native close.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}), []);
const showSearch = () => {
if (settings) setSettings(false);
setSearching(true);
requestAnimationFrame(() => searchRef.current?.focus());
};
const closeSearch = () => {
if (navigationBlocked) return;
setSearching(false);
navigate({ query: "", opened: false }, true);
};
const switchView = (next: View) => {
if (next !== view)
setMotion(
viewOrder.indexOf(next) > viewOrder.indexOf(view) ? "forward" : "back",
);
navigate({ view: next, opened: false, selectedId: null });
};
const switchTab = (next: InboxTab) => {
const order = inboxTabs.map(({ id }) => id);
if (view === "inbox" && next !== tab)
setMotion(order.indexOf(next) > order.indexOf(tab) ? "forward" : "back");
navigate({ view: "inbox", tab: next, opened: false, selectedId: null });
};
const nextTab = () => {
const index = inboxTabs.findIndex(({ id }) => id === tab);
switchTab(inboxTabs[(index + 1) % inboxTabs.length].id);
};
const hasListControls = accountFilter !== "all" || labelFilter !== "all" || !!query ||
readFilter !== "all" || relevanceFilter !== "all" || sort !== "newest";
const resetListControls = () => {
if (navigate({ accountFilter: "all", labelFilter: "all", query: "", readFilter: "all",
relevanceFilter: "all", sort: "newest", selectedId: null, opened: false })) setSearching(false);
};
const labels = state.labels.filter((label) =>
accountFilter === "all" || label.accountId === accountFilter);
const activeLabel = labels.find((label) => `${label.accountId}:${label.id}` === labelFilter);
useEffect(() => {
if (labelFilter !== "all" && !activeLabel) setLabelFilter("all");
}, [labelFilter, activeLabel]);
const now = Date.now();
const isSnoozed = (m: Mail) =>
m.snoozedUntil !== null && m.snoozedUntil > now;
const tabs = useMemo(() => new Map(state.messages.map((mail) => [mail.id, inboxTab(mail, state.rules)])),
[state.messages, state.rules]);
const tabOf = (mail: Mail) => tabs.get(mail.id) ?? inboxTab(mail, state.rules);
const search = useMemo(() => compileMailSearch(query, state.accounts), [query, state.accounts]);
const filtered = filterMail(state.messages, navigation.location, activeLabel, now, undefined, tabOf, search.matches);
const selected = opened
? state.messages.find((m) => m.id === selectedId &&
state.accounts.some((account) => account.id === m.accountId))
: filtered.find((m) => m.id === selectedId) || filtered[0];
const accountOf = (m: Mail) =>
state.accounts.find((a) => a.id === m.accountId);
const selectedAccount = selected && accountOf(selected);
// Inbox total for the sidebar and unread counts for each category tab.
const inboxMail = state.messages.filter((m) => !m.archived && !isSnoozed(m) &&
matchesLabel(m, activeLabel) && (accountFilter === "all" || accountFilter === m.accountId));
const tabUnread = new Map<InboxTab, number>();
for (const m of inboxMail) if (m.unread) tabUnread.set(tabOf(m), (tabUnread.get(tabOf(m)) ?? 0) + 1);
const snoozedCount = state.messages.filter(
(m) => !m.archived && isSnoozed(m) && matchesLabel(m, activeLabel),
).length;
const perform = async (task: () => Promise<unknown>, success?: string) => {
if (busy) return;
setBusy(true);
try {
await task();
if (success) {
pushToast(success, "success");
try { await reload(); }
catch { setNotice(`${success} Inbox refresh failed. Refresh the inbox separately.`); }
} else await reload();
} catch (error) {
setNotice(error instanceof Error ? error.message : "Action failed.");
} finally {
setBusy(false);
}
};
const changeMessage = (
mail: Mail,
action: "read" | "archive" | "snooze" | "unsnooze" | "pin" | "unpin",
until?: number,
) =>
perform(() =>
api(`/messages/${encodeURIComponent(mail.id)}`, "PATCH", {
action,
until,
}),
action === "archive" ? "Email archived." : action === "unsnooze" ? "Email returned to inbox." : undefined,
);
const markRead = (mail: Mail) => {
void markMailRead(mail, pendingReads.current, async () => {
setReading(new Set(pendingReads.current));
await api(`/messages/${encodeURIComponent(mail.id)}`, "PATCH", { action: "read" });
// Ignore state snapshots requested before the provider confirmed this read.
++loadRevision.current;
setState((current) => ({ ...current, messages: current.messages.map((message) =>
message.id === mail.id && message.accountId === mail.accountId && message.remoteId === mail.remoteId
? { ...message, unread: false, labelIds: message.labelIds?.filter((id) => id !== "UNREAD") }
: message) }));
void reload().catch(() => setNotice("Email marked as read. Inbox refresh failed. Refresh the inbox separately."));
}).catch((error: unknown) => setNotice(
error instanceof Error ? `${error.message} Use Mark as read or reopen the email to retry.`
: "Could not mark this email as read. Use Mark as read or reopen it to retry.",
)).finally(() => setReading(new Set(pendingReads.current)));
};
const trashUnavailable = (mail: Mail) => mail.archived && accountOf(mail)?.provider !== "gmail" && !mail.messageId
? "Move to Trash is unavailable for this archived email because its Message-ID is missing. Move it in your provider."
: undefined;
const moveToTrash = async (mail: Mail) => {
if (busy || syncing || trashLock.current || trashUnavailable(mail)) return;
trashLock.current = true;
setBusy(true);
try {
await api(`/messages/${encodeURIComponent(mail.id)}`, "PATCH", { action: "trash" });
++loadRevision.current;
setState((current) => ({ ...current, messages: current.messages.filter((message) =>
!(message.id === mail.id && message.accountId === mail.accountId && message.remoteId === mail.remoteId)) }));
setTrashMail(null);
if (selectedId === mail.id) setOpened(false);
pushToast("Email moved to Trash.", "success");
try { await reload(); }
catch { setNotice("Email moved to Trash. Inbox refresh failed. Refresh the inbox separately."); }
} catch (error) {
setNotice(`${error instanceof Error ? error.message : "Move to Trash could not be confirmed."} Check your provider before trying again.`);
} finally {
trashLock.current = false;
setBusy(false);
}
};
const openMail = (mail: Mail, context: Partial<MailLocation> = {}) => {
if (navigationBlocked || settings) return;
navigation.update((current) => ({ ...current, ...context, selectedId: mail.id, opened: true }));
setRecent((ids) =>
[mail.id, ...ids.filter((id) => id !== mail.id)].slice(0, 6),
);
markRead(mail);
};
const openReaderMail = (mail: Mail | undefined) => {
if (!mail || navigationBlocked || contextMenu) return;
readerFocusPending.current = true;
openMail(mail);
};
const jumpTo = (mail: Mail) => {
openMail(mail, {
view: mail.archived ? "archive" : isSnoozed(mail) ? "snoozed" : "inbox", tab: tabOf(mail),
accountFilter: "all", labelFilter: "all", query: "", readFilter: "all", relevanceFilter: "all",
});
};
const startCompose = (reply?: Mail) => {
if (!state.accounts.length) {
setConnecting("choose");
return;
}
if (compose || draftPicker || updateInstalling) return;
const accountId = reply?.accountId ||
(accountFilter !== "all" ? accountFilter : state.accounts[0].id);
openCompose(initialCompose(accountId, state.composePreferences ?? emptyComposePreferences, {
accountId:
reply?.accountId ||
(accountFilter !== "all" ? accountFilter : state.accounts[0].id),
to: reply?.replyTo || reply?.address || "",
subject: reply
? /^re:/i.test(reply.subject)
? reply.subject
: `Re: ${reply.subject}`
: "",
body: "",
inReplyTo: reply?.messageId,
threadId: reply?.threadId,
references: reply?.messageId
? [
...(reply.references || [])
.filter((v) => v !== reply.messageId)
.slice(-99),
reply.messageId,
]
: reply?.references,
}));
};
const archive = (mail: Mail) => {
void changeMessage(mail, "archive");
if (selectedId === mail.id) setOpened(false);
};
const neighbours = opened && selected
? filterMail(state.messages, navigation.location, activeLabel, now, selected.id, tabOf, search.matches) : filtered;
const neighbourIndex = neighbours.findIndex((mail) => mail.id === selected?.id);
const previousMail = neighbours[neighbourIndex - 1];
const nextMail = neighbours[neighbourIndex + 1];
// One list of actions feeds the native menu on the desktop and the drawn menu elsewhere.
const mailMenuActions = (mail: Mail): MenuAction[] => [
{ id: "open", label: "Open", key: shortcut("open"), run: () => openMail(mail) },
{ id: "read", label: reading.has(mail.id) ? "Marking as read…" : "Mark as read", disabled: !mail.unread || reading.has(mail.id), run: () => markRead(mail) },
{ id: "pin", label: mail.pinned ? "Unpin email" : "Pin email", disabled: busy, run: () => void changeMessage(mail, mail.pinned ? "unpin" : "pin") },
{ id: "reply", label: "Reply", key: shortcut("reply"), disabled: busy, separator: true, run: () => startCompose(mail) },
{ id: "archive", label: "Archive", key: shortcut("done"), disabled: busy || syncing || mail.archived, separator: true, run: () => archive(mail) },
{ id: "snooze", label: "Snooze", key: shortcut("snooze"), disabled: busy || syncing || mail.archived, run: () => setSnooze(mail) },
...(isSnoozed(mail) ? [{ id: "unsnooze", label: "Return to inbox", disabled: busy || syncing, run: () => void changeMessage(mail, "unsnooze") }] : []),
{ id: "trash", label: "Move to Trash", disabled: busy || syncing || !!trashUnavailable(mail), run: () => setTrashMail(mail) },
{ id: "teach", label: "Teach sorting", key: shortcut("teach"), disabled: busy || syncing, separator: true, run: () => setTeach(mail) },
];
const showMailMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>, mail: Mail) => {
setSelectedId(mail.id);
const request = mailMenuRequest(event, mail.id);
const native = window.desktop?.mailMenu;
if (!native) { setContextMenu(request); return; }
const actions = mailMenuActions(mail);
void native(actions.map(({ id, label, key, disabled, separator }) => ({ id, label, enabled: !disabled, accelerator: key, separator })),
{ x: Math.round(request.x), y: Math.round(request.y) })
.then((chosen) => actions.find((action) => action.id === chosen)?.run());
};
const mailMenuKey = (event: ReactKeyboardEvent<HTMLElement>, mail: Mail) => {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.stopPropagation();
showMailMenu(event, mail);
}
};
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (settingsModal || updateInstalling || draftPicker || contextMenu || event.defaultPrevented || event.isComposing ||
event.repeat && !["ArrowUp", "ArrowDown"].includes(event.key)) return;
const target = event.target;
if (event.key.toLowerCase() === "k" && !event.altKey && !event.shiftKey &&
(window.desktop?.platform === "darwin" ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)) {
if (!navigationBlocked) { event.preventDefault(); setFiltersOpen(false); setPalette((value) => !value); }
return;
}
if (palette) return;
if (settings && target instanceof HTMLElement && target.closest("[data-interaction-settings]") &&
event.key === "Escape" && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey &&
!compose && !teach && !snooze && !trashMail && !shortcuts && !labelMail && !connecting) {
event.preventDefault(); setSettings(false); return;
}
if (target instanceof HTMLElement && (target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) return;
const nextDensity = densityShortcut(event);
if (nextDensity && !settings && !compose && !teach && !snooze && !trashMail && !shortcuts && !labelMail && !connecting) {
event.preventDefault(); setDensity(nextDensity); return;
}
if (target instanceof HTMLElement && target.tagName === "BUTTON" && target.getAttribute("role") === "combobox") return;
if (event.key === "Escape" && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (compose) return;
if (trashMail) { if (!trashLock.current) setTrashMail(null); return; }
setOpened(false); setTeach(null); setSnooze(null); setShortcuts(false);
setLabelMail(null); setSettings(false);
return;
}
if (compose || teach || snooze || trashMail || shortcuts || labelMail || connecting) return;
const action = interactionActions.find(({ id }) => matches(event, id, interaction, window.desktop?.platform))?.id;
if (action === "back" || action === "forward") {
event.preventDefault(); travel(action === "back" ? -1 : 1); return;
}
if (target instanceof HTMLElement && target.closest("#sidebar")) return;
const inbox = !settings;
if (inbox && !opened && ["ArrowDown", "ArrowUp"].includes(event.key) &&
!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey &&
(!(target instanceof HTMLElement) || !target.closest("button, a, [role=button]") || target.closest(".row"))) {
event.preventDefault();
const next = event.key === "ArrowDown" ? nextMail : previousMail;
if (next) setSelectedId(next.id);
return;
}
if (action === "search") { event.preventDefault(); showSearch(); }
else if (action === "settings") setSettings((value) => !value);
else if (action === "help") setShortcuts(true);
else if (action === "compose") startCompose();
else if (action === "privacy") togglePrivacy();
else if (inbox && action === "density") setDensity((value) => value === "comfortable" ? "compact" : "comfortable");
else if (inbox && action === "important") switchView("inbox");
else if (inbox && action === "other") nextTab();
else if (target instanceof HTMLElement && target.closest(".row-actions") &&
["next", "previous", "open", "done", "reply", "snooze", "teach"].includes(action ?? "")) return;
else if (inbox && (action === "next" || action === "previous")) {
event.preventDefault();
const next = action === "next" ? nextMail : previousMail;
if (next) { if (opened) openReaderMail(next); else setSelectedId(next.id); }
} else if (inbox && action === "open" && selected &&
(!(target instanceof HTMLElement) || !target.closest("button, a, [role=button]") || target.closest(".row"))) {
event.preventDefault(); openMail(selected);
} else if (inbox && action === "done" && selected && !busy) archive(selected);
else if (inbox && action === "reply" && selected) startCompose(selected);
else if (inbox && action === "snooze" && selected) setSnooze(selected);
else if (inbox && action === "teach" && selected) setTeach(selected);
else if (inbox && !event.ctrlKey && !event.metaKey && !event.altKey &&
!(target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'))) {
if (event.key === "Tab" && target === document.body) {
event.preventDefault(); nextTab();
} else if (!event.shiftKey && /^[0-9]$/.test(event.key)) {
const account = state.accounts[Number(event.key) - 1];
const nextAccount = event.key === "0" ? "all" : account?.id;
if (nextAccount && nextAccount !== accountFilter) navigate({ accountFilter: nextAccount, labelFilter: "all", selectedId: null, opened: false });
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
});
useEffect(() => {
const handle = (e: BeforeUnloadEvent) => {
if (sendLock.current || draftLock.current || draftSession.current?.dirty || compose?.sendUnknown)
e.preventDefault();
};
window.addEventListener("beforeunload", handle);
return () => window.removeEventListener("beforeunload", handle);
}, [compose]);
const senderMail = selected
? state.messages
.filter((m) => m.address === selected.address)
.sort((a, b) => b.date - a.date)
: [];
const recentMail = recent
.map((id) => state.messages.find((m) => m.id === id))
.filter((m): m is Mail => !!m);
// Pick the entrance once per pane so re-renders never restart its animation.
// Beside the reader, the list keeps its pane while mail opens and closes.
const paneKey = opened && selected && !split
? `mail:${selected.id}`
: `list:${view}:${tab}:${accountFilter}:${labelFilter}:${readFilter}:${relevanceFilter}:${sort}`;
const pane = useRef({ key: paneKey, motion });
if (pane.current.key !== paneKey)
pane.current = {
key: paneKey,
motion: paneKey.startsWith("mail:")
? "open"
: pane.current.key.startsWith("mail:")
? "close"
: motion,
};
const readerKey = opened && selected ? selected.id : null;
useLayoutEffect(() => {
if (!opened) {
if (listFocusPending.current) {
listFocusPending.current = false;
document.getElementById(`mail-${selected?.id}`)?.focus({ preventScroll: true });
}
return;
}
const region = split ? readerRef.current : listRef.current;
region?.scrollTo({ top: 0, behavior: "instant" });
if (readerFocusPending.current) {
readerFocusPending.current = false;
region?.querySelector<HTMLElement>(".message")?.focus({ preventScroll: true });
}
}, [readerKey, opened, split]);
useEffect(closeContextMenu, [paneKey, readerKey, settings, closeContextMenu]);
const menuMail = contextMenu && state.messages.find((mail) => mail.id === contextMenu.mailId);
const privacyContext = useMemo(() => ({ on: privacy, revealed, toggle: toggleAddress,
accounts: state.accounts, names: collectPrivateNames(state.accounts) }),
[privacy, revealed, toggleAddress, state.accounts]);
useEffect(() => {
if (contextMenu && !menuMail) closeContextMenu();
}, [contextMenu, menuMail, closeContextMenu]);
const filtersActive = readFilter !== "all" || relevanceFilter !== "all" ||
sort !== "newest" || labelFilter !== "all";
// The header in each window corner carries the window controls. macOS draws its
// own lights over the top-left header, which only reserves room for them.
const corner = (pane: "sidebar" | "main" | "reader" | "context"): Corner => {
const left = sidebarShown ? "sidebar" : "main";
const right = settings ? "main" : contextShown ? "context" : split ? "reader" : "main";
if (window.desktop?.platform === "darwin") return pane === left ? "reserve" : null;
// The system caption buttons sit over the top-right corner.
if (systemCaption) return pane === right ? "caption" : null;
if (!drawsWindowControls) return null;
return windowSide === "left" ? (pane === left ? "left" : null) : (pane === right ? "right" : null);
};
const sidebarToggle = !sidebarOpen && roomy && (
<button
className="icon"
id="sidebar-toggle"
aria-label="Show sidebar"
aria-expanded={false}
aria-controls="sidebar"
onClick={toggleSidebar}
>
<SidebarIcon color="currentColor" size={16} />
</button>
);
const privacyButton = (
<button
className={privacy ? "icon on" : "icon"}
aria-label={privacy ? "Show personal information" : "Hide personal information"}
aria-pressed={privacy}
title={privacy ? `Personal information hidden (${shortcut("privacy")})` : `Hide personal information (${shortcut("privacy")})`}
onClick={togglePrivacy}
>
{privacy ? <EyeOff color="currentColor" size={15} /> : <Eye color="currentColor" size={15} />}
</button>
);
const senderToggle = senderRoom && (
<button
className={senderPanel ? "icon on" : "icon"}
aria-label="Sender details"
aria-pressed={senderPanel}
title={senderPanel ? "Hide sender details" : "Show sender details"}
onClick={toggleSenderPanel}
>
<PanelRight color="currentColor" size={15} />
</button>
);
const actions = selected && (
<MailActions
mail={selected}
opened={opened}
reading={reading.has(selected.id)}
busy={busy}
syncing={syncing}
snoozed={isSnoozed(selected)}
trashUnavailable={trashUnavailable(selected)}
shortcut={shortcut}
onTrash={() => setTrashMail(selected)}
onRead={() => markRead(selected)}
onReply={() => startCompose(selected)}
onArchive={() => archive(selected)}
onSnooze={() => setSnooze(selected)}
onUnsnooze={() => void changeMessage(selected, "unsnooze")}
onPin={() => void changeMessage(selected, selected.pinned ? "unpin" : "pin")}
onClose={() => setOpened(false)}
onPrevious={() => openReaderMail(previousMail)}
onNext={() => openReaderMail(nextMail)}
previousDisabled={navigationBlocked || !!contextMenu || !previousMail}
nextDisabled={navigationBlocked || !!contextMenu || !nextMail}
/>
);
const reader = opened && selected && (
<Reader
mail={selected}
provider={selectedAccount?.provider}
labels={state.labels}
busy={busy || syncing}
api={api}
onLabels={() => setLabelMail(selected)}
onMenu={showMailMenu}
onMenuKey={mailMenuKey}
/>
);
const historyControls = (
<nav className="mail-navigation" aria-label="Mail history">
<button type="button" disabled={navigationBlocked || !!contextMenu || !navigation.canBack} onClick={() => travel(-1)}>
<ArrowLeft size={15} />Back
</button>
<button type="button" disabled={navigationBlocked || !!contextMenu || !navigation.canForward} onClick={() => travel(1)}>
Forward<ArrowRight size={15} />
</button>
</nav>
);
const paletteActions = {
important: () => switchView("inbox"), search: showSearch, compose: () => startCompose(),
settings: () => setSettings(true), privacy: togglePrivacy, help: () => setShortcuts(true),
density: () => setDensity((value) => value === "comfortable" ? "compact" : "comfortable"),
};
const commands: PaletteCommand[] = [
...interactionActions.flatMap(({ id, label }) => id in paletteActions ? [{ id, label, shortcut: shortcut(id),
run: paletteActions[id as keyof typeof paletteActions] }] : []),
{ id: "archive-view", label: "Show Archive", run: () => switchView("archive") },
{ id: "snoozed-view", label: "Show Snoozed", run: () => switchView("snoozed") },
{ id: "pinned-view", label: "Show pinned emails in this view", run: () => {
navigate({ page: "mail", query: "is:pinned", selectedId: null, opened: false });
setSearching(true);
} },
{ id: "sync", label: "Sync mail", disabled: syncing || !state.accounts.length, run: () => void sync() },
...(!settings && selected ? mailMenuActions(selected).map(({ id, label, key, disabled, run }) =>
({ id: `mail-${id}`, label, shortcut: key, disabled, run })) : []),
];
return (
<PrivacyContext.Provider
value={privacyContext}
>
<div
className={`app${window.desktop ? " desktop" : ""}${privacy ? " private" : ""}`}
data-platform={window.desktop?.platform}
>
<ToastStack toasts={toasts} onDismiss={dismissToast} position={notifications.position} />
{palette && !updateInstalling && <CommandPalette commands={commands} onClose={() => setPalette(false)} />}
<div className="app-update-control" style={{ right: systemCaption ? 154 : drawsWindowControls && windowSide === "right" ? 106 : 12 }}>
<UpdateNotice bridge={window.desktop?.updates} onInstalling={setUpdateInstalling} />
</div>
<div className="frame" inert={updateInstalling || palette}>
<Sidebar
open={sidebarShown}
onToggle={toggleSidebar}
corner={corner("sidebar")}
maximized={maximized}
settings={settings}
mail={{
view,
onView: switchView,
inboxCount: inboxMail.length,
snoozedCount,
accounts: state.accounts,
accountFilter,
onAccount: (id) => navigate({ accountFilter: id, labelFilter: "all", selectedId: null, opened: false }),
privacy,
blocked: navigationBlocked,
busy,
onAddAccount: () => setConnecting("choose"),
onCompose: () => startCompose(),
onDrafts: () => setDraftPicker(true),
onSettings: () => setSettings(true),
onCommands: () => { if (!navigationBlocked) setPalette(true); },
shortcut,
syncing,
lastSync: state.lastSync,
onSync: () => void sync(),
}}
sections={{
current: settingsSection,
onSection: setSettingsSection,
onClose: () => setSettings(false),
}}
/>
{settings ? (
<div className="settings-pane">
<PaneHeader className="main-head update-reserved" corner={corner("main")} maximized={maximized} lead={sidebarToggle}>
<h1 className="pane-title">Settings</h1>
<div className="pane-tools">{privacyButton}</div>
</PaneHeader>
<CredentialFallbackNotice mode={window.desktop?.credentialFallback} />
{historyControls}
<SettingsPage
notifications={notifications}
onNotifications={(value) => {
setNotifications(value);
if (!saveNotifications(value)) setNotice("Notification settings could not be saved on this device. Your selection applies until the app closes.");
}}
onNotificationPreview={() => pushToast("This is a sample in-app notification.", "info")}
state={state}
busy={busy}
perform={perform}
api={api}
onConnectAccount={setConnecting}
onNotice={(text) => pushToast(text, "info")}
onComposePreferences={async (value) => {
const saved = await api<ComposePreferences>("/compose-preferences", "PUT", value);
setState((current) => ({ ...current, composePreferences: saved }));
pushToast("Templates and signatures saved.", "success");
}}
windowSide={windowSide}
onWindowSide={placeWindowControls}
onWindowControls={(controls) => {
if (compose || sendLock.current) setNotice("Send or discard your draft before switching window controls.");
else window.desktop?.setWindowControls?.(controls);
}}
interaction={interaction}
onInteraction={(value) => {
setInteraction(value);
if (!saveInteraction(value)) setNotice("Interaction settings could not be saved on this device.");
}}
section={settingsSection}
appearance={appearance}
scheme={themePreview?.scheme ?? scheme}
onAppearance={(value) => {
if (!saveAppearance(value)) {
setNotice("Appearance could not be saved on this device.");
return false;
}
setAppearance(value);
return true;
}}
onThemePreview={setThemePreview}
onModalChange={setSettingsModal}
/>
</div>
) : (
<main className={split ? "workspace split" : "workspace"}>
<section className="list-pane">
<PaneHeader className={!contextShown && !split ? "main-head update-reserved" : "main-head"} corner={corner("main")} maximized={maximized} lead={sidebarToggle}>
<h1 className="pane-title">{viewNames[view]}</h1>
<div className="head-search">
{searching ? (
<div className="search" role="search">
<Search color="currentColor" size={14} />
<input
ref={searchRef}
aria-label="Search all synced mail"
placeholder="Search mail"
aria-describedby="mail-search-help"
aria-invalid={!!search.error}
value={query}
onKeyDown={(e) => {
if (e.key === "Escape") closeSearch();
}}
onChange={(e) => {
navigate({ query: e.target.value, opened: false, selectedId: null }, true);
}}
/>
<button aria-label="Close search" onClick={closeSearch}>
<Close color="currentColor" size={13} />
</button>
</div>
) : (
<button
className="search search-closed"
aria-label="Search mail"
title={`Search (${shortcut("search")})`}
onClick={showSearch}
>
<Search color="currentColor" size={14} />
<span>Search mail</span>
<kbd>{shortcut("search")}</kbd>
</button>
)}
</div>
<div className="pane-tools">
<MailFilters
open={filtersOpen}
active={filtersActive}
location={navigation.location}
labels={labels}
accounts={state.accounts}
blocked={navigationBlocked}
showReset={hasListControls}
labelName={(name) => privacy ? redactPrivateText(name, collectPrivateNames(state.accounts, opened ? selected || {} : {})) : name}
onOpen={setFiltersOpen}
onChange={(patch) => navigate({ ...patch, selectedId: null, opened: false })}
onReset={resetListControls}
/>
{privacyButton}
{!split && senderToggle}
</div>
</PaneHeader>
{historyControls}
{searching && <p id="mail-search-help" className="search-help" role={search.error ? "alert" : undefined}>
{search.error || 'Search cached mail in this view: from:, to:, subject:, body:, account:, is:unread, is:pinned, before:YYYY-MM-DD, after:YYYY-MM-DD. Use quotes for phrases and - to exclude.'}
</p>}
{view === "inbox" && !query && (
<InboxTabs tab={tab} unread={tabUnread} onTab={switchTab} />
)}
<CredentialFallbackNotice mode={window.desktop?.credentialFallback} />
{!(split && opened) && actions}
<div
ref={listRef}
className={`list ${density}`}
aria-label={`${viewNames[view]} mail`}
>
<div key={paneKey} className={`pane ${pane.current.motion}`}>
{reader && !split ? reader : filtered.length ? (
<MailList
messages={filtered}
listRef={listRef}
focusSelection={!navigationBlocked && !contextMenu}
selectedId={selected?.id}
density={density}
narrow={split}
sort={sort}
opening={interaction.opening}
accounts={state.accounts}
onOpen={openMail}
onSelect={setSelectedId}
onMenu={showMailMenu}
onMenuKey={mailMenuKey}
rowActions={(mail) => <MailActions
row mail={mail} reading={reading.has(mail.id)} busy={busy} syncing={syncing}
snoozed={isSnoozed(mail)} trashUnavailable={trashUnavailable(mail)} shortcut={shortcut}
onRead={() => markRead(mail)} onReply={() => startCompose(mail)}
onArchive={() => archive(mail)} onTrash={() => setTrashMail(mail)}
onSnooze={() => setSnooze(mail)} onUnsnooze={() => void changeMessage(mail, "unsnooze")}
onPin={() => void changeMessage(mail, mail.pinned ? "unpin" : "pin")}
/>}
/>
) : (
<ListEmpty
loaded={loaded}
hasAccounts={state.accounts.length > 0}
filtered={hasListControls}
title={view === "snoozed" ? "No snoozed mail" : view === "archive" ? "No archived mail" : `Nothing in ${tabName(tab)}`}
viewName={viewNames[view]}
syncing={syncing}
onConnect={() => setConnecting("choose")}
onReset={resetListControls}
onSync={() => void sync()}
/>
)}
</div>
</div>
</section>
{split && (
<section className="reader-pane" aria-label="Reading pane">
<PaneHeader className={contextShown ? "reader-head" : "reader-head update-reserved"} corner={corner("reader")} maximized={maximized}>
{opened && actions}
<div className="pane-tools">{senderToggle}</div>
</PaneHeader>
<div ref={readerRef} className="reader-scroll">
<div key={selected && opened ? selected.id : "empty"} className="pane open">
{reader || <ReaderEmpty selected={!!selected} openKey={shortcut("open")} />}
</div>
</div>
</section>
)}
{contextShown && (
<aside className="context" aria-label="Sender context">
<PaneHeader className="context-head update-reserved" corner={corner("context")} maximized={maximized} />
<SenderPanel
selected={selected}
account={selectedAccount}
tab={selected ? tabName(tabOf(selected)) : ""}
senderMail={senderMail}
recentMail={recentMail}
onJump={jumpTo}
/>
</aside>
)}
</main>
)}
</div>
{contextMenu && menuMail && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
anchor={contextMenu.anchor}
onClose={closeContextMenu}
actions={mailMenuActions(menuMail)}
/>
)}
{labelMail && (
<MailPrivacy mail={labelMail}>
<Dialog title="Gmail labels" onClose={() => { if (!busy) setLabelMail(null); }}>
<LabelForm
key={labelMail.id}
mail={labelMail}
labels={customLabels(labelMail, state.labels)}
busy={busy || syncing}
onSave={(labelIds) => void perform(async () => {
await api(`/messages/${encodeURIComponent(labelMail.id)}`, "PATCH", { action: "labels", labelIds });
setLabelMail(null);
}, "Labels updated.")}
/>
</Dialog>
</MailPrivacy>
)}
{connecting === "choose" ? (
<Dialog title="Connect account" onClose={() => setConnecting(null)}>
<p className="dialog-copy">Choose your email provider.</p>
<ProviderChoices googleConfigured={state.googleConfigured} busy={busy} onChoose={setConnecting} />
</Dialog>
) : connecting && (
<ConnectDialog
key={connecting}
provider={connecting}
busy={busy}
onClose={() => setConnecting(null)}
onBack={() => setConnecting("choose")}
onConnect={async (input) => {
await api("/accounts/imap", "POST", input);
setConnecting(null);
setNotice("Account connected. Syncing your inbox.");
try {
await reload();
void sync();
} catch {
setNotice("Account connected, but refreshing the inbox failed. Reload the app to sync.");
}
}}
/>
)}
{draftPicker && (
<DraftPicker accounts={state.accounts} api={api}
onClose={() => setDraftPicker(false)}
onOpen={(draft) => { setDraftPicker(false); openCompose(draft); }} />
)}
{compose && (
<ComposePanel
draft={compose}
setDraft={editCompose}
accounts={state.accounts}
preferences={state.composePreferences ?? emptyComposePreferences}
busy={busy || updateInstalling}
progress={draftProgress}
onClose={closeCompose}
onDiscard={() => runDraftAction(async (session) => {
await session.discard();
draftSession.current = null;
setCompose(null);
})}
onRetry={() => runDraftAction(async (session) => {
await session.retry();
setCompose(session.content);
})}
onSaveCopy={() => {
if (busy || sendLock.current || draftLock.current || updateInstalling) return;
if (confirm("Save a separate copy? The previous saved draft may still exist. Neither copy will be sent automatically.")) openCompose(draftSession.current?.content || compose);
}}
onSend={() => {
const session = draftSession.current;
if (!session || busy || draftLock.current || sendLock.current || updateInstalling) return;
const snapshot = session.content;
sendTask.current = sendCompose(snapshot, {
lock: sendLock,
prepare: async () => {
session.change({ ...snapshot, sendUnknown: true });
setCompose(session.content);
await session.flush();
},
send: (draft) => {
pushToast("Sending email…", "info");
return api("/send", "POST", draft);
},
complete: () => session.discard(),
reload,
setDraft: (value) => {
if (value === null) draftSession.current = null;
setCompose(value);
},
setBusy,
setNotice: (text) => pushToast(text, text === "Email sent." ? "success" : "error"),
confirm: (message) => confirm(message),
});
}}
/>
)}
{teach && (
<TeachDialog
mail={teach}
busy={busy}
onClose={() => setTeach(null)}
onSave={(input) =>
void perform(async () => {
await api("/rules", "POST", input);
setTeach(null);
}, "Sorting updated for this sender.")
}
/>
)}
{snooze && (
<SnoozeDialog
mail={snooze}
busy={busy}
onClose={() => setSnooze(null)}
onSnooze={(until) => void perform(async () => {
await api(`/messages/${encodeURIComponent(snooze.id)}`, "PATCH", { action: "snooze", until });
setSnooze(null);
if (selectedId === snooze.id) setOpened(false);
}, `Email snoozed until ${new Date(until).toLocaleString()}.`)}
onUnsnooze={() => void perform(async () => {
await api(`/messages/${encodeURIComponent(snooze.id)}`, "PATCH", { action: "unsnooze" });
setSnooze(null);
}, "Email returned to inbox.")}
/>
)}
{trashMail && (
<TrashDialog
busy={busy}
syncing={syncing}
onConfirm={() => void moveToTrash(trashMail)}
onClose={() => { if (!trashLock.current) setTrashMail(null); }}
/>
)}
{shortcuts && <ShortcutsDialog shortcut={shortcut} onClose={() => setShortcuts(false)} />}
</div>
</PrivacyContext.Provider>
);
}