import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { QRCodeSVG } from "qrcode.react"; import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { TypedPasswordInput } from "../../components/TypedPasswordInput"; import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker"; import { useT } from "../../i18n"; import { api, ApiError } from "../../lib/api"; import type { CalendarOut, ReminderPref } from "../../lib/types"; import { useMe } from "../../lib/auth"; import { useTheme } from "../../lib/theme"; import { setTimeFormat } from "../../lib/timeFormat"; import { PluginSlot } from "../../plugins/PluginSlot"; import { usePluginSettingsSections } from "../../plugins/manifest"; import { AccountWizard } from "../wizard/AccountWizard"; const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const; type Section = (typeof SECTIONS)[number]; export function Settings() { const t = useT(); const params = useParams(); const [searchParams] = useSearchParams(); // Where the back button returns to — set by whoever opened Settings (Mail or a // plugin like Calendar via ?from=). Only accept internal paths; default to Mail. const navigate = useNavigate(); const rawFrom = searchParams.get("from") ?? ""; const from = rawFrom.startsWith("/") ? rawFrom : "/mail"; const fromQuery = `?from=${encodeURIComponent(from)}`; // The add-account wizard renders inside this layout (route /settings/accounts/new), // with the Accounts nav item highlighted. const addingAccount = useLocation().pathname.endsWith("/accounts/new"); // Plugins contributing `settings.page` each get their own nav entry, keyed by // plugin name, rather than being stacked at the bottom of Accounts. const pluginSections = usePluginSettingsSections(); const isPluginSection = pluginSections.some((b) => b.plugin === params.section); const section = ( addingAccount ? "accounts" : SECTIONS.includes(params.section as Section) || isPluginSection ? params.section : "profile" ) as Section; return (
← {t("nav.back")} {t("settings.title")}
{addingAccount && navigate("/settings/accounts")} />} {!addingAccount && section === "profile" && } {!addingAccount && section === "security" && } {!addingAccount && section === "accounts" && } {section === "calendars" && } {section === "appearance" && } {section === "integrations" && } {/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section per plugin, selected from the nav like any built-in section. */} {isPluginSection && (
)}
); } function ProfileSection() { const t = useT(); const { data } = useMe(); return (

{t("settings.profile")}

); } // Whoever controls the recovery address can take the account over with a reset // link, so adding, changing or removing it asks for the current password. function RecoveryEmailRow({ current }: { current: string | null }) { const t = useT(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const save = useMutation({ mutationFn: () => api.setRecoveryEmail(password, email.trim() || null), onSuccess: (user) => { queryClient.setQueryData(["me"], user); setEditing(false); setPassword(""); }, }); if (!editing) { return (
{t("settings.recoveryEmail")} {current ?? "—"}

{t("settings.recoveryEmailHint")}

); } const field = "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none"; return (
{ e.preventDefault(); save.mutate(); }} > {save.error instanceof ApiError &&

{save.error.message}

}
); } function Row({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function SecuritySection() { const t = useT(); const queryClient = useQueryClient(); const { data: me } = useMe(); const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null); const [code, setCode] = useState(""); const [disabling, setDisabling] = useState(false); const [disablePw, setDisablePw] = useState(""); const [error, setError] = useState(""); const sessions = useQuery({ queryKey: ["sessions"], queryFn: api.sessions }); const enabled = Boolean(me?.totp_enabled); const startSetup = async () => { const res = await api.totpSetup(); setSetup({ secret: res.secret, uri: res.provisioning_uri }); }; const confirm = async () => { setError(""); try { await api.totpEnable(code); setSetup(null); queryClient.invalidateQueries({ queryKey: ["me"] }); } catch (e) { setError(e instanceof ApiError ? e.message : String(e)); } }; const disable = async () => { setError(""); try { await api.totpDisable(disablePw); setDisabling(false); setDisablePw(""); queryClient.invalidateQueries({ queryKey: ["me"] }); } catch (e) { setError(e instanceof ApiError ? e.message : String(e)); } }; const revoke = useMutation({ mutationFn: (sid: string) => api.revokeSession(sid), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }), }); return (

{t("settings.twoFactor")}

{enabled ? (

{t("settings.twoFactorOn")}

{disabling ? (

{t("settings.disable2faPrompt")}

setDisablePw(e.target.value)} autoFocus />
) : ( )}
) : setup ? (

{t("settings.scanQr")}

{t("settings.secret")}:
{setup.secret}

otpauth URI
{setup.uri}
setCode(e.target.value)} />
) : ( )} {error &&

{error}

}

{t("settings.sessions")}

); } function AccountsSection() { const t = useT(); const navigate = useNavigate(); const queryClient = useQueryClient(); const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>( null, ); // Which account is being deleted — drives the blocking spinner overlay until the // (slow) server cascade finishes and the refetched list no longer includes it. const [deletingId, setDeletingId] = useState(null); const remove = useMutation({ mutationFn: (id: string) => api.deleteAccount(id), onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ["accounts"] }); setDeletingId(null); }, }); const downloadAll = useMutation({ mutationFn: (id: string) => api.updateAccount(id, { sync_window: "all" }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }), }); const close = () => { setEditing(null); queryClient.invalidateQueries({ queryKey: ["accounts"] }); }; return (

{t("settings.accounts")}

{deletingId && (
{t("settings.removingAccount")}
)}
); } function AccountEditor({ accountId, onCancel, onSaved, }: { accountId: string; onCancel: () => void; onSaved: () => void; }) { const t = useT(); const queryClient = useQueryClient(); const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) }); const [displayName, setDisplayName] = useState(""); const [color, setColor] = useState("#888888"); const [showServer, setShowServer] = useState(false); const [imapHost, setImapHost] = useState(""); const [imapPort, setImapPort] = useState("993"); const [imapTls, setImapTls] = useState("ssl"); const [smtpHost, setSmtpHost] = useState(""); const [smtpPort, setSmtpPort] = useState("465"); const [smtpTls, setSmtpTls] = useState("ssl"); const [password, setPassword] = useState(""); const [error, setError] = useState(""); useEffect(() => { const d = detail.data; if (!d) return; setDisplayName(d.display_name); setColor(d.color); setImapHost(String(d.imap_config.host ?? "")); setImapPort(String(d.imap_config.port ?? "993")); setImapTls(String(d.imap_config.tls ?? "ssl")); setSmtpHost(String(d.smtp_config.host ?? "")); setSmtpPort(String(d.smtp_config.port ?? "465")); setSmtpTls(String(d.smtp_config.tls ?? "ssl")); }, [detail.data]); const save = useMutation({ mutationFn: () => { const patch: Parameters[1] = { display_name: displayName, color, }; if (showServer) { patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" }; patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" }; patch.password = password; } return api.updateAccount(accountId, patch); }, onSuccess: (updated) => { queryClient.setQueryData(["account", accountId], updated); onSaved(); }, onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"), }); if (!detail.data) return
{t("common.loading")}
; const canSave = !save.isPending && (!showServer || password.length > 0); return (
setColor(e.target.value)} aria-label={t("settings.color")} /> setDisplayName(e.target.value)} />
{!showServer ? ( ) : (

{t("settings.serverSettingsHint")}

setPassword(e.target.value)} />
)} {error &&

{error}

}
); } function SignatureEditor({ accountId, onCancel, onSaved, }: { accountId: string; onCancel: () => void; onSaved: () => void; }) { const t = useT(); const queryClient = useQueryClient(); const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId), }); const [signature, setSignature] = useState(""); const [error, setError] = useState(""); useEffect(() => { if (detail.data) setSignature(detail.data.signature); }, [detail.data]); const save = useMutation({ mutationFn: () => api.updateAccount(accountId, { signature }), // Keep the cached detail current: the editor seeds from it only once on mount, // so a stale entry would reopen the editor with the old (e.g. empty) signature. onSuccess: (updated) => { queryClient.setQueryData(["account", accountId], updated); onSaved(); }, onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"), }); if (!detail.data) return
{t("common.loading")}
; return (
{t("settings.signature")} — {detail.data.email}
{error &&

{error}

}
); } function ServerRow({ label, host, port, tls, onHost, onPort, onTls, }: { label: string; host: string; port: string; tls: string; onHost: (v: string) => void; onPort: (v: string) => void; onTls: (v: string) => void; }) { const t = useT(); return (
{label} onHost(e.target.value)} /> onPort(e.target.value)} />
); } // Spelling and grammar check in the compose window (LanguageTool on the server). function SpellcheckSetting() { const t = useT(); const queryClient = useQueryClient(); const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings }); // Also tells us whether this server has a checker at all (503 when not). const languages = useQuery({ queryKey: ["spellcheck-languages"], queryFn: api.spellcheckLanguages, retry: false, staleTime: 3_600_000, }); const patch = useMutation({ mutationFn: api.patchSettings, onSuccess: (data) => queryClient.setQueryData(["settings"], data), }); const on = settings.data?.spellcheck_enabled ?? true; const unavailable = languages.isError; return (
{t("settings.spellcheck")}
{([true, false] as const).map((value) => ( ))}
{unavailable ? (

{t("settings.spellcheckUnavailable")}

) : ( <> )}
); } function AppearanceSection() { const t = useT(); const queryClient = useQueryClient(); const { theme, setTheme } = useTheme(); const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const patch = useMutation({ mutationFn: api.patchSettings, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), }); return (

{t("settings.appearance")}

{t("settings.theme")}
{(["light", "dark", "system"] as const).map((value) => ( ))}
{t("display.readingPane")}
{/* Saved per device, like the theme: the right choice depends on the screen. */}

{t("display.phoneNote")}

); } const DEFAULT_JITSI = "https://meet.jit.si/"; function IntegrationsSection() { const t = useT(); const queryClient = useQueryClient(); const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const patch = useMutation({ mutationFn: api.patchSettings, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), }); const [jitsi, setJitsi] = useState(null); const value = jitsi ?? settings.data?.jitsi_server ?? ""; return (

{t("settings.integrations")}

{t("settings.jitsi")}

{t("settings.jitsiHint")}

setJitsi(e.target.value)} onBlur={(e) => patch.mutate({ jitsi_server: e.target.value.trim() })} />
); } const CAL_COLORS: [string, string][] = [ ["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"], ["Purple", "#a855f7"], ["Teal", "#14b8a6"], ["Pink", "#ec4899"], ["Gray", "#6b7280"], ]; function BellIcon({ className }: { className?: string }) { return ( ); } // A compact colour picker showing circular swatches only (no text). function ColorSelect({ value, onChange }: { value: string; onChange: (hex: string) => void }) { const [open, setOpen] = useState(false); const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value) ? CAL_COLORS : [["Custom", value], ...CAL_COLORS]; return (
{open && ( <>
setOpen(false)} />
{palette.map(([name, hex]) => (
)}
); } function ConfirmDialog({ title, message, confirmLabel, onConfirm, onCancel, }: { title: string; message: string; confirmLabel: string; onConfirm: () => void; onCancel: () => void; }) { const t = useT(); return (
e.stopPropagation()} >

{title}

{message}

); } function SettingsToggle({ checked, onChange, disabled = false, }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; }) { return ( ); } const TZ_RAW: string[] = (() => { const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] }; try { return intl.supportedValuesOf ? intl.supportedValuesOf("timeZone") : []; } catch { return []; } })(); const TZ_ZONES = TZ_RAW.length ? TZ_RAW : ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"]; // The GMT offset code (e.g. "GMT+2") for a zone, as shown next to its name. function tzGmt(zone: string): string { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "shortOffset", }).formatToParts(new Date()); return parts.find((p) => p.type === "timeZoneName")?.value ?? ""; } catch { return ""; } } function tzLongName(zone: string): string { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "long", }).formatToParts(new Date()); return parts.find((p) => p.type === "timeZoneName")?.value ?? zone; } catch { return zone; } } // [zone, "Long Name (GMT+2)"] sorted by offset then label, computed once. const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => { const gmt = tzGmt(z); return [z, gmt ? `${tzLongName(z)} (${gmt})` : tzLongName(z)]; }).sort((a, b) => a[1].localeCompare(b[1])); const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]; function reminderText(m: number): string { if (m === 0) return "At time of event"; if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`; if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`; return `${m} minutes before`; } function CalendarsSection() { const t = useT(); const [searchParams] = useSearchParams(); const [tab, setTab] = useState<"calendars" | "options">("calendars"); const [view, setView] = useState( searchParams.get("create") === "1" ? "new" : null, ); const calendars = useQuery({ queryKey: ["calendars"], queryFn: api.calendars }); const list = calendars.data ?? []; return (
{(["calendars", "options"] as const).map((tb) => ( ))}
{tab === "options" ? ( ) : view ? ( setView(null)} /> ) : ( setView("new")} onEdit={setView} /> )}
); } function CalendarList({ calendars, onCreate, onEdit, }: { calendars: CalendarOut[]; onCreate: () => void; onEdit: (c: CalendarOut) => void; }) { const t = useT(); const queryClient = useQueryClient(); const [subUrl, setSubUrl] = useState(""); const subscribe = useMutation({ mutationFn: () => api.subscribeCalendar({ url: subUrl.trim() }), onSuccess: () => { setSubUrl(""); queryClient.invalidateQueries({ queryKey: ["calendars"] }); }, }); const [removeSub, setRemoveSub] = useState(null); const removeCal = useMutation({ mutationFn: (id: string) => api.deleteCalendar(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }), }); const subscriptions = calendars.filter((c) => c.subscription_url !== null); const accounts: [string, string][] = [ ["Google", "google"], ["iCloud", "icloud"], ["CalDAV", "caldav"], ]; // CalDAV account connect (iCloud / generic). Google needs OAuth — left disabled. const davAccounts = useQuery({ queryKey: ["dav-accounts"], queryFn: api.davAccounts }); const [connecting, setConnecting] = useState<"icloud" | "caldav" | null>(null); const [davUser, setDavUser] = useState(""); const [davPass, setDavPass] = useState(""); const [davUrl, setDavUrl] = useState(""); const refreshCalendars = () => { queryClient.invalidateQueries({ queryKey: ["calendars"] }); queryClient.invalidateQueries({ queryKey: ["dav-accounts"] }); }; const closeConnect = () => { setConnecting(null); setDavUser(""); setDavPass(""); setDavUrl(""); }; const connect = useMutation({ mutationFn: () => api.davConnect({ provider: connecting ?? "caldav", server_url: davUrl.trim(), username: davUser.trim(), password: davPass, }), onSuccess: () => { closeConnect(); refreshCalendars(); }, }); const disconnect = useMutation({ mutationFn: (accountId: string) => api.davDeleteAccount(accountId), onSuccess: refreshCalendars, }); return (

{t("settings.calendars")}

{calendars .filter((c) => c.subscription_url === null) .map((c) => (
))}

{t("settings.addAccount")}

{t("settings.addAccountHint")}

{(davAccounts.data ?? []).length > 0 && (
{(davAccounts.data ?? []).map((a) => (
{a.username || a.provider}
{a.provider === "icloud" ? "iCloud" : "CalDAV"} ·{" "} {t("settings.calendarCount", { count: a.calendars.length })}
))}
)}
{accounts.map(([label, key]) => ( ))}
{connecting && (
{t("settings.connectTitle", { provider: connecting === "icloud" ? "iCloud" : "CalDAV" })}
{connecting === "caldav" && ( setDavUrl(e.target.value)} /> )} setDavUser(e.target.value)} autoFocus /> setDavPass(e.target.value)} /> {connecting === "icloud" && (

{t("settings.icloudHint")}

)}
{connect.isError && (

{(() => { const code = connect.error instanceof ApiError ? connect.error.message : ""; const keys = { auth_failed: "settings.connectAuthFailed", private_host: "settings.connectPrivateHost", unreachable: "settings.connectUnreachable", no_calendars: "settings.connectNoCalendars", server_url_required: "settings.connectNeedsUrl", } as const; const key = keys[code as keyof typeof keys]; return t(key ?? "settings.connectError"); })()}

)}
)}

{t("settings.subscriptions")}

{t("settings.subscriptionsHint")}

{subscriptions.length > 0 && (
{subscriptions.map((c) => (
{c.name}
{c.subscription_url}
))}
)}
setSubUrl(e.target.value)} />
{subscribe.isError && (

{t("settings.subscribeError")}

)}
{removeSub && ( setRemoveSub(null)} onConfirm={() => { removeCal.mutate(removeSub.id); setRemoveSub(null); }} /> )}
); } function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBack: () => void }) { const t = useT(); const queryClient = useQueryClient(); const [calId, setCalId] = useState(calendar?.id ?? null); // Whether we opened an existing calendar to edit — fixed for the form's lifetime, so // auto-creating on a Publish toggle doesn't flip the "Create calendar" heading. const startedNew = calendar === null; const [name, setName] = useState(calendar?.name ?? ""); const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]); const [publishToken, setPublishToken] = useState(calendar?.publish_token ?? null); const [publishBusy, setPublishBusy] = useState(calendar?.publish_busy ?? false); const fullDetails = publishToken !== null && !publishBusy; const freeBusy = publishToken !== null && publishBusy; const done = () => { queryClient.invalidateQueries({ queryKey: ["calendars"] }); onBack(); }; const save = useMutation({ mutationFn: async () => { if (calId) await api.patchCalendar(calId, { name: name.trim(), color }); else await api.createCalendar({ name: name.trim(), color }); }, onSuccess: done, }); const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done }); const [confirmDel, setConfirmDel] = useState(false); // Turning on a Publish option auto-creates the calendar first (create mode) so a // shareable .ics link can be shown immediately, then flips the public feed on/off. const setPublish = useMutation({ mutationFn: async (mode: "off" | "full" | "busy") => { let id = calId; if (!id) { const created = await api.createCalendar({ name: name.trim(), color }); id = created.id; setCalId(id); } const res = await api.patchCalendar(id, { publish: mode }); return { mode, token: res.publish_token }; }, onSuccess: ({ mode, token }) => { setPublishToken(mode === "off" ? null : token); setPublishBusy(mode === "busy"); queryClient.invalidateQueries({ queryKey: ["calendars"] }); }, }); const publicUrl = publishToken ? api.publicCalendarUrl(publishToken) : ""; const linkRow = (
{publicUrl}
); return (

{startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}

{t("settings.color")}

{t("settings.publish")}

{t("settings.publishHint")}

{t("settings.fullEventDetails")} setPublish.mutate(v ? "full" : "off")} />
{fullDetails && linkRow}
{t("settings.freeBusy")} setPublish.mutate(v ? "busy" : "off")} />
{freeBusy && linkRow}
{!startedNew && ( )}
{confirmDel && ( setConfirmDel(false)} onConfirm={() => { setConfirmDel(false); del.mutate(); }} /> )}
); } const REM_SELECT_CLASS = "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm text-app-fg outline-none focus:border-app-accent"; function ReminderList({ label, value, onChange, }: { label: string; value: ReminderPref[]; onChange: (v: ReminderPref[]) => void; }) { const t = useT(); const update = (i: number, patch: Partial) => onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); return (
{label}
{value.map((r, i) => (
))}
); } function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) { const t = useT(); const queryClient = useQueryClient(); const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const patch = useMutation({ mutationFn: api.patchSettings, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), }); const s = settings.data; const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; // When no default is set, fall back to "Personal" (the bootstrapped calendar) so the // dropdown shows Personal rather than a generic placeholder; else the first calendar. const preferredDefault = calendars.find((c) => c.name === "Personal") ?? calendars[0]; const defaultCalValue = s?.default_calendar && calendars.some((c) => c.id === s.default_calendar) ? s.default_calendar : (preferredDefault?.id ?? ""); // Detect and persist the browser time zone on first visit (backend default is UTC). useEffect(() => { if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [s?.tz]); return (

{t("settings.calendarDisplay")}

{t("settings.default")}

patch.mutate({ default_reminders_timed: v })} /> patch.mutate({ default_reminders_allday: v })} />

{t("settings.invitations")}

{t("settings.inviteWhen")}
{( [ ["add_update", t("settings.inviteAddUpdate")], ["update_only", t("settings.inviteUpdateOnly")], ["none", t("settings.inviteNone")], ] as const ).map(([val, txt]) => ( ))}
{t("settings.alsoFile")} patch.mutate({ invitation_file: v })} />
); }