ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+173
View File
@@ -0,0 +1,173 @@
import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage";
export type Theme = "system" | "light" | "dark";
export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts";
export type ComposeFormat = "html" | "text";
export interface Template {
id: string;
name: string;
subject: string;
html: string;
}
export interface Settings {
theme: Theme;
accent: string;
density: Density;
readingPane: ReadingPane;
conversationMode: boolean;
showPreview: boolean;
showAvatars: boolean;
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
imagePolicy: ImagePolicy;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
signatureAboveQuote: boolean;
includeQuote: boolean;
requestReadReceipt: boolean;
confirmDelete: boolean;
desktopNotifications: boolean;
notificationSound: boolean;
attachmentReminder: boolean;
weekStart: 0 | 1 | 6;
timeFormat: "12" | "24" | "auto";
calendarDefaultView: "month" | "week" | "day" | "agenda";
workDayStart: number;
workDayEnd: number;
defaultEventDuration: number; // minutes
defaultAlertMinutes: number;
timeZone: string | null; // null = browser
language: string;
labelsSidebar: boolean;
fontSize: "small" | "medium" | "large";
templates: Template[];
labels: Array<{ keyword: string; name: string; color: string }>;
sidebarCollapsed: boolean;
showHiddenFolders: boolean;
trustedImageSenders: 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>;
}
export const DEFAULT_SETTINGS: Settings = {
theme: "system",
accent: "teal",
density: "cozy",
readingPane: "right",
conversationMode: true,
showPreview: true,
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
imagePolicy: "ask",
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
signatureAboveQuote: true,
includeQuote: true,
requestReadReceipt: false,
confirmDelete: false,
desktopNotifications: false,
notificationSound: false,
attachmentReminder: true,
weekStart: 1,
timeFormat: "auto",
calendarDefaultView: "week",
workDayStart: 8,
workDayEnd: 18,
defaultEventDuration: 60,
defaultAlertMinutes: 10,
timeZone: null,
language: "en",
labelsSidebar: true,
fontSize: "medium",
templates: [],
labels: [],
sidebarCollapsed: false,
showHiddenFolders: false,
trustedImageSenders: [],
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: {},
};
interface SettingsState {
settings: Settings;
update(patch: Partial<Settings>): void;
reset(): void;
exportJson(): string;
importJson(json: string): boolean;
}
export const useSettings = create<SettingsState>((set, get) => ({
settings: loadJson<Settings>("settings", DEFAULT_SETTINGS),
update(patch) {
const settings = { ...get().settings, ...patch };
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
},
reset() {
saveJson("settings", DEFAULT_SETTINGS);
set({ settings: DEFAULT_SETTINGS });
applyTheme(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;
}
},
}));
export function applyTheme(s: Settings = useSettings.getState().settings): void {
const root = document.documentElement;
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
root.dataset.theme = dark ? "dark" : "light";
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 = dark ? "#0b1220" : "#ffffff";
}
if (typeof window !== "undefined") {
applyTheme();
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
}
export const settings = () => useSettings.getState().settings;