Files
inbuxa-webmail/web/src/store/settings.ts
T
jcoffey-dev 4d18d94b63 Subscribe to a calendar published at a URL
A timetable, a rota, a public holiday list: the calendars people are given
as a link, which ihasmail could not show at all.

Nothing is stored. The document is fetched when the calendar is opened and
parsed in the browser; the server keeps no copy, no cache and no schedule,
which is what lets an immutable container serve this. There is no timer
either -- there is nowhere to run one -- so the guarantee is that a
subscription is as current as the last time somebody looked, which is also
when it matters. That is said plainly rather than implied.

The fetch has to happen on the server: a calendar URL belongs to whoever
published it and almost none of them send CORS headers. That makes it the
second place this app knocks on a door somebody else chose, so the guard
the image proxy has always had was lifted out and both now call it. A
second SSRF implementation is how one of them ends up missing a case; this
way there is one, and the extraction is covered by the image proxy's own
tests still passing unchanged.

webcal: is understood, because that is how these are published, and it is
read as https: rather than waved past the checks -- a webcal URL pointing
at loopback is refused exactly like an http one.

Recurrence is deliberately not expanded. RRULE is a small language with a
lot of edge cases, and a subscription quietly showing the wrong dates would
be worse than one showing the first occurrence and saying so.

The parser is a subscription parser rather than an importer: a subscribed
calendar is read-only and redrawn from scratch each refresh, so nothing has
to round-trip or survive an edit, which is most of what makes a full
iCalendar implementation large. What it does have to do is never mis-state
a time -- a DATE is built in local time rather than at UTC midnight, which
would land on the day before for anyone west of Greenwich -- and never hang
on a document somebody else wrote.

Events go through instancesIn like the birthdays, so no view has to know
they are not real calendars, and the calendar they hang off reports no
write rights, so everything that asks before offering an edit declines on
its own. A subscription that cannot be read says so in the sidebar rather
than drawing an empty calendar, which looks like a calendar with nothing
in it.
2026-09-02 00:52:06 -07:00

591 lines
23 KiB
TypeScript

import { useEffect, useState } from "react";
import { create } from "zustand";
import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette";
import type { SortLevel, SortPreset } from "@/lib/listSort";
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe";
import { resolveUiLanguage } from "@/lib/languages";
import { loadLanguage } from "@/lib/i18n";
/**
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
* theme rather than an accent because it changes the backgrounds, borders and
* text as well as the highlight colour — an accent could not.
*/
export type Theme = "system" | "light" | "dark" | "ihasmail";
export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts";
export type ComposeFormat = "html" | "text";
export type ReadReceiptPolicy = "ask" | "never";
/** How prominent a label is in the sidebar. */
export type LabelVisibility = "always" | "unread" | "hidden";
export interface Label {
/** The IMAP keyword itself, which is what actually rides on the message. */
keyword: string;
name: string;
color: string;
/**
* The keyword of the label this one sits under, if any.
*
* Nesting is display only. The keywords stay flat on the message, which is
* what keeps them readable by every other client -- a label moved under
* another one does not rewrite anything in the mailbox.
*/
parent?: string;
/** Absent means "always", so a settings file written before this parses unchanged. */
visibility?: LabelVisibility;
}
/** A calendar subscribed to by URL, read-only and redrawn on every refresh. */
export interface IcalSubscription {
id: string;
url: string;
name: string;
color: string;
}
export interface Template {
id: string;
name: string;
subject: string;
html: string;
}
export interface Settings {
/**
* Kept, and kept correct, for a device still running a build that only knows
* this field. It cannot express "Gruvbox", but it can express light or dark,
* which is the half that stops an older device showing a theme nobody chose.
*/
theme: Theme;
/** The colours. */
palette: PaletteId;
/** Light, dark, or whatever the system says. */
mode: Mode;
accent: string;
density: Density;
readingPane: ReadingPane;
conversationMode: boolean;
showPreview: boolean;
showAvatars: boolean;
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
/**
* Shared calendars and address books the reader has added, as
* `accountId:collectionId`.
*
* JMAP keeps this on the collection itself, in `isSubscribed`, and that is
* still tried first -- a preference the server holds is one every client
* sees. But subscribing writes to the *owner's* account, and Stalwart 0.16.19
* refuses that for an address book shared read-only: "You are not allowed to
* modify this address book." It accepts the same write on a shared calendar,
* which is the inconsistency this list exists to paper over.
*
* So where the server will not remember, ihasmail does, in the settings that
* already follow the reader between devices.
*/
addedShares: string[];
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
signatureAboveQuote: boolean;
includeQuote: boolean;
requestReadReceipt: boolean;
/**
* What to do when a sender asks for a read receipt. There is deliberately no
* "always": an automatic receipt confirms to whoever asked that the address
* is live and when it was read, which is exactly what a sender who should
* not have that is fishing for. RFC 8098 asks that a person decide each one.
*/
readReceiptPolicy: ReadReceiptPolicy;
confirmDelete: boolean;
/**
* What dragging a message row sideways does, on a touchscreen.
*
* Two settings rather than one "swipe actions" toggle because the pair is
* the choice: which hand-side gets the destructive one is personal, and the
* usual complaint about swipe gestures is not that they exist but that the
* app picked the wrong ones. "none" turns a direction off; turning both off
* turns the gesture off.
*
* They follow the account rather than the device: someone who has decided
* that a left swipe deletes has decided it for their phone and their tablet
* both, and the setting is meaningless on the desktop that would otherwise
* be the odd one out.
*/
swipeRight: SwipeAction;
swipeLeft: SwipeAction;
desktopNotifications: boolean;
notificationSound: boolean;
attachmentReminder: boolean;
weekStart: 0 | 1 | 6;
/** "" = follow the mail server's locale, then the browser's. */
locale: string;
/**
* The language the interface is written in, and what `<html lang>` says.
*
* Separate from `locale` above, which is a *formatting* choice — what
* calendar, clock and numerals to use. They are genuinely different
* questions: German dates with an English interface is a real preference,
* and so is the reverse. Folding them together would silently rewrite
* everybody's date format the first time they picked a language.
*
* Absent means English, for a new account and for every existing one whose
* settings file predates this. The browser's `Accept-Language` is
* deliberately not consulted as the stored default: a served locale should
* be something the reader chose, not something guessed on their behalf and
* then written down as though they had.
*/
uiLanguage: string;
dateFormat: DateFormat;
timeFormat: TimeFormat;
calendarDefaultView: "month" | "week" | "day" | "agenda";
workDayStart: number;
workDayEnd: number;
defaultEventDuration: number; // minutes
defaultAlertMinutes: number;
timeZone: string | null; // null = browser
labelsSidebar: boolean;
/**
* Birthdays from the address book, shown as a calendar of their own.
* Off by default: it is derived data, and a calendar that fills itself with
* dates nobody put there is a surprise rather than a feature.
*/
birthdayCalendar: boolean;
/**
* Calendars subscribed to by URL. The subscription is the setting; the
* events themselves are fetched on demand and never stored, so this follows
* the account the way every other preference does and costs nothing to sync.
*/
icalSubscriptions: IcalSubscription[];
/** What order the message list is in. See lib/listSort.ts. */
listSortPreset: SortPreset;
listSortLevels: SortLevel[];
/**
* Which folders it covers. Inbox-only is the useful default rather than a
* timid one: unread-first is what people want where they triage, and
* confusing in Sent, where everything is read.
*/
listSortScope: "inbox" | "all";
fontSize: "small" | "medium" | "large";
templates: Template[];
labels: Label[];
/**
* Folder colours, by mailbox id. Local to this browser, like every other
* colour here: JMAP has nowhere on a Mailbox to keep one.
*/
folderColors: Record<string, string>;
sidebarCollapsed: boolean;
showHiddenFolders: boolean;
trustedImageSenders: string[];
/**
* The three warnings, each off until switched on. A client that starts by
* interrupting is one people learn to click through, and a warning clicked
* through without reading costs the same attention and buys nothing.
*/
externalSenderBanner: boolean;
externalRecipientConfirm: boolean;
/**
* Domains that count as inside, *in addition to* the account's own identity
* domains, which are always internal and are not configuration.
*/
internalDomains: string[];
/** People on a message before sending asks. 0 is off. */
replyAllThreshold: number;
externalLinkWarning: boolean;
trustedLinkDomains: string[];
archiveOnReply: boolean;
autoAdvance: "newer" | "older" | "list";
spellcheck: boolean;
sendAndArchive: boolean;
/** Width (px) of the message list when the reading pane is on the right. */
listPaneWidth: number;
/** Height (px) of the message list when the reading pane is below. */
listPaneHeight: number;
/** Outlook-style colour categories for calendar events. */
eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */
defaultIdentityByAccount: Record<string, string>;
/**
* Identities kept out of the compose picker, by id.
*
* An account with alias domains can have every address twice over while only
* a handful are ever sent from, which makes the picker useless (#73). This
* hides them from the picker only — the identity still exists on the server,
* still receives, and is still listed and editable in Settings, exactly as an
* unsubscribed folder still exists.
*
* A flat list rather than keyed by account: identity ids are unique, and an
* id belonging to another account simply never matches.
*/
hiddenIdentities: string[];
}
export const DEFAULT_SETTINGS: Settings = {
/**
* ihasmail's own palette is what a new account gets, so the app looks like
* itself before anyone has chosen anything. It is only a default: a stored
* theme always wins, so nobody who has picked one — including everyone
* already using ihasmail, whose choice is saved even if they never changed
* it — is moved off it.
*/
theme: "ihasmail",
palette: "ihasmail",
mode: "dark",
accent: "teal",
density: "cozy",
readingPane: "right",
conversationMode: true,
showPreview: true,
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
addedShares: [],
imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
signatureAboveQuote: true,
includeQuote: true,
requestReadReceipt: false,
readReceiptPolicy: "ask",
confirmDelete: false,
/*
* Right archives and left deletes, which is what the mail apps a phone came
* with already do. A default nobody has to learn beats a better one they do.
*/
swipeRight: "archive",
swipeLeft: "delete",
desktopNotifications: false,
notificationSound: false,
attachmentReminder: true,
weekStart: 1,
locale: "",
uiLanguage: "en",
dateFormat: "auto",
timeFormat: "auto",
calendarDefaultView: "week",
workDayStart: 8,
workDayEnd: 18,
defaultEventDuration: 60,
defaultAlertMinutes: 10,
timeZone: null,
labelsSidebar: true,
birthdayCalendar: false,
icalSubscriptions: [],
listSortPreset: "newest",
listSortLevels: [],
listSortScope: "inbox",
fontSize: "medium",
templates: [],
labels: [],
folderColors: {},
sidebarCollapsed: false,
showHiddenFolders: false,
trustedImageSenders: [],
externalSenderBanner: false,
externalRecipientConfirm: false,
internalDomains: [],
replyAllThreshold: 0,
externalLinkWarning: false,
trustedLinkDomains: [],
archiveOnReply: false,
autoAdvance: "list",
spellcheck: true,
sendAndArchive: false,
listPaneWidth: 520,
listPaneHeight: 340,
eventCategories: [
{ name: "Important", color: "#dc2626" },
{ name: "Work", color: "#2563eb" },
{ name: "Personal", color: "#16a34a" },
{ name: "Travel", color: "#ea580c" },
{ name: "Family", color: "#9333ea" },
],
defaultIdentityByAccount: {},
hiddenIdentities: [],
};
/**
* Settings that describe *this screen or this browser*, and so stay in
* localStorage: a list-pane width picked on a 27" monitor is wrong on a
* laptop, and the notification toggles track a permission the browser grants
* per-device, so syncing them would claim something untrue elsewhere.
*
* Everything else follows the account (issue #54). The list is written as the
* exceptions rather than the rule so that a setting added later syncs by
* default, which is what someone adding one almost always wants.
*/
export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
"density",
"fontSize",
"sidebarCollapsed",
"desktopNotifications",
"notificationSound",
"listPaneWidth",
"listPaneHeight",
]);
/** The part of the settings that is written to the account's settings file. */
export function syncedPart(s: Settings): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(s) as Array<keyof Settings>) {
if (!DEVICE_KEYS.has(key)) out[key] = s[key];
}
return out;
}
/**
* What of a settings file we are willing to apply: known keys only, and never
* a device one — an older ihasmail wrote the whole object up, and that file
* should not now drag another machine's pane width across.
*/
export function acceptRemote(remote: Record<string, unknown>): Partial<Settings> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(remote)) {
if (!(key in DEFAULT_SETTINGS)) continue;
if (DEVICE_KEYS.has(key as keyof Settings)) continue;
if (value === undefined) continue;
out[key] = value;
}
/*
* A file written before palettes existed carries `theme` and neither
* `palette` nor `mode`, so it is read through the old enum. Settings live in
* the account's own Files and are opened by whatever version happens to run
* next, so this is not a one-release migration -- it has to keep working.
*
* Only when the new fields are absent: a file that has both is newer, and
* its `theme` is the derived copy rather than the choice.
*/
if (out.palette === undefined && out.mode === undefined && typeof remote.theme === "string") {
const migrated = migrateTheme(remote.theme);
out.palette = migrated.palette;
out.mode = migrated.mode;
}
return out as Partial<Settings>;
}
/**
* The settings file laid over the ones in hand, minus anything still queued.
*
* A change that has not been written up yet is newer than the file by
* definition, so it wins. Picking a language is where this showed: that
* remounts the tree, the remount re-reads the file, and the file still holds
* the language from before the click, so the click came undone. Reported as
* "sometimes it takes several clicks" — the click that stuck was the one made
* after the previous write had landed.
*/
export function mergeRemote(
current: Settings,
remote: Record<string, unknown>,
held: ReadonlySet<string> = new Set(),
): Settings {
const incoming = acceptRemote(remote);
for (const key of held) delete incoming[key as keyof Settings];
return { ...current, ...incoming };
}
interface SettingsState {
settings: Settings;
update(patch: Partial<Settings>): void;
reset(): void;
exportJson(): string;
importJson(json: string): boolean;
/** Apply the account's settings file over the cached ones. */
hydrate(remote: Record<string, unknown>): void;
}
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
/**
* Whether the first frame is this account's settings or merely the defaults.
*
* False after every deploy, because deploys sign everyone out and sign-out
* clears the cache -- and false on any untrusted device, where the cache is
* never read. In that state `uiLanguage` starts as English and only becomes
* the account's choice once the settings file lands, which is why the
* authenticated tree waits for it.
*/
export const PAINTED_FROM_CACHE = hasCachedJson("settings");
applyDateTimePrefs(initialSettings);
export const useSettings = create<SettingsState>((set, get) => ({
settings: initialSettings,
update(patch) {
/*
* `theme` is derived, never chosen: whatever set the palette or the mode --
* the toggle, Appearance, an imported file -- the legacy field is brought
* back into line here rather than at the call sites, so a fourth way to
* change the theme cannot forget to update it and strand an older device
* on a theme nobody picked.
*/
const merged = { ...get().settings, ...patch };
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) };
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
applyLang(settings);
// Dragging a splitter changes a device key on every frame and must not put
// a request in the air; anything else is queued and coalesced.
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
queueSettingsPush(syncedPart(settings));
}
},
reset() {
saveJson("settings", DEFAULT_SETTINGS);
set({ settings: DEFAULT_SETTINGS });
applyTheme(DEFAULT_SETTINGS);
applyDateTimePrefs(DEFAULT_SETTINGS);
applyLang(DEFAULT_SETTINGS);
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
},
exportJson() {
return JSON.stringify(get().settings, null, 2);
},
importJson(json) {
try {
const parsed = JSON.parse(json) as Partial<Settings>;
get().update(parsed);
return true;
} catch {
return false;
}
},
hydrate(remote) {
const settings = mergeRemote(get().settings, remote, pendingSettingsKeys());
// Cache it, so the next first frame on this browser is already right.
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
applyLang(settings);
},
}));
function applyDateTimePrefs(s: Settings): void {
// The interface language feeds the automatic locale, so month and weekday
// names follow the language somebody chose rather than staying English.
setUiLanguageForFormatting(resolveUiLanguage(s.uiLanguage));
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
}
/**
* Put the served language on `<html lang>`.
*
* Chrome offers to translate when the language it detects does not match the
* one the page declares, so a `lang` that is briefly wrong is enough to raise
* the prompt on a page that was already correct — and accepting that prompt is
* what rewrites the DOM under React and crashes the component tree
* (facebook/react#11538).
*
* So this is not done in an effect after mount. It runs where `applyTheme`
* runs: at module load, from the localStorage cache, before `createRoot()` has
* rendered anything and therefore before first paint. `index.html` ships
* `lang="en"` statically, so the very first bytes are already right for the
* default and this only ever corrects a reader who chose otherwise.
*
* There is no server-rendered alternative to reach for. ihasmail serves a
* static shell and keeps no account state; the settings file lives in the
* reader's own JMAP Files on the mail server, so the only way to read it
* before the page existed would be to authenticate to Stalwart on every page
* load, which is the thing the whole design avoids.
*/
export function applyLang(s: Settings = useSettings.getState().settings): void {
const tag = resolveUiLanguage(s.uiLanguage);
document.documentElement.lang = tag;
/*
* The catalogue is fetched, so it lands a beat after the attribute. That
* order is deliberate: `lang` is what stops Chrome offering to translate,
* and it should not wait on a network request to say something it already
* knows. English needs no fetch at all and resolves immediately.
*/
void loadLanguage(tag);
}
/** Background of each theme, for the browser chrome (`theme-color`). */
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
export function applyTheme(s: Settings = useSettings.getState().settings): void {
const root = document.documentElement;
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
const mode = effectiveMode(s.mode, prefersDark);
/*
* Two attributes, because they answer two questions. `data-theme` is the
* mode, and every dark-only rule in the stylesheet keys off it without
* knowing any palette exists; `data-palette` layers the colours on top. The
* accent variants out-specify both, which is what lets an accent still apply
* over any palette.
*/
root.dataset.theme = mode;
if (s.palette && s.palette !== "default") root.dataset.palette = s.palette;
else delete root.dataset.palette;
root.dataset.density = s.density;
root.dataset.accent = s.accent;
root.dataset.fontsize = s.fontSize;
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
if (meta) meta.content = paletteThemeColor(s.palette, mode);
}
/**
* The browser chrome colour, read from the palette's own background so it does
* not have to be listed twice and cannot drift from it.
*/
function paletteThemeColor(palette: PaletteId, mode: "light" | "dark"): string {
if (typeof getComputedStyle === "function") {
const value = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim();
if (value) return value;
}
return mode === "dark" ? THEME_COLOR.dark : THEME_COLOR.light;
}
/** Whether a theme paints dark, resolving "system" against the OS. */
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
}
if (typeof window !== "undefined") {
applyTheme();
// Before `createRoot().render()` in main.tsx, which imports this module on
// the way in -- so the language is declared before React has produced a
// single node, let alone painted one.
applyLang();
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
}
/**
* The theme actually on screen, which is not the same as the setting: "system"
* resolves to whatever the OS is doing right now, and follows it as it changes.
*/
export function useEffectiveTheme(): "light" | "dark" {
const mode = useSettings((s) => s.settings.mode);
const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!mq) return;
const onChange = () => setSystemDark(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return effectiveMode(mode, systemDark);
}
export const settings = () => useSettings.getState().settings;
/**
* Primitive that changes whenever a date/time preference does, so memoised
* components that render dates re-render when the format is switched.
*/
export const dateTimeKey = (s: Settings): string => `${s.locale}|${s.dateFormat}|${s.timeFormat}`;