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 c17887e48e
162 changed files with 20398 additions and 1072 deletions
+33
View File
@@ -0,0 +1,33 @@
import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
export function AboutSettings() {
const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {});
return (
<div>
<h1>About ihasmail</h1>
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">Stalwart Mail Server</a>, built on JMAP.</p>
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src="/img/logo.png" alt="ihasmail" width={96} />
<div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
<div className="hint">GPL-3.0-or-later · <a href="https://github.com/LINUXexpert-org/ihasmail" target="_blank" rel="noreferrer">github.com/LINUXexpert-org/ihasmail</a></div>
</div>
</div>
<h2>Server</h2>
<table className="sessions-table">
<tbody>
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
</tbody>
</table>
<h2>Server capabilities</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
const ACCENTS = [
{ id: "teal", color: "#0f766e" },
{ id: "blue", color: "#2563eb" },
{ id: "purple", color: "#7c3aed" },
{ id: "rose", color: "#e11d48" },
{ id: "orange", color: "#ea580c" },
{ id: "green", color: "#16a34a" },
];
export function AppearanceSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
return (
<div>
<h1>Appearance</h1>
<p className="lead">Make ihasmail yours.</p>
<h2>Theme</h2>
<div className="theme-grid">
{(["system", "light", "dark"] as const).map((t) => (
<button key={t} className={`theme-card ${s.theme === t ? "active" : ""}`} onClick={() => update({ theme: t })}>
<div className="preview" style={{ background: t === "dark" ? "#0b1220" : t === "light" ? "#f6f8fa" : "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" }} />
{t === "system" ? "Match system" : t === "light" ? "Light" : "Dark"}
</button>
))}
</div>
<h2>Accent color</h2>
<div className="swatches">
{ACCENTS.map((a) => (
<button key={a.id} className={`swatch ${s.accent === a.id ? "active" : ""}`} style={{ background: a.color }} onClick={() => update({ accent: a.id })} aria-label={a.id} title={a.id} />
))}
</div>
<h2>Density & text</h2>
<div className="field-row">
<div className="field">
<label>Display density</label>
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<option value="comfortable">Comfortable</option>
<option value="cozy">Cozy (default)</option>
<option value="compact">Compact</option>
</select>
</div>
<div className="field">
<label>Text size</label>
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</div>
</div>
<h2>Sidebar</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label="Show labels in the sidebar" />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label="Show unsubscribed (hidden) folders" />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label="Collapse sidebar to icons" />
</div>
);
}
@@ -0,0 +1,88 @@
import { useSettings } from "@/store/settings";
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { Plus, Trash2 } from "lucide-react";
export function CalendarSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
return (
<div>
<h1>Calendar & contacts</h1>
<p className="lead">Defaults for the calendar views and new events.</p>
<div className="field-row">
<div className="field">
<label>Default view</label>
<select className="select" value={s.calendarDefaultView} onChange={(e) => update({ calendarDefaultView: e.target.value as typeof s.calendarDefaultView })}>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
<option value="agenda">Agenda</option>
</select>
</div>
<div className="field">
<label>Default event length</label>
<select className="select" value={String(s.defaultEventDuration)} onChange={(e) => update({ defaultEventDuration: Number(e.target.value) })}>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="45">45 minutes</option>
<option value="60">1 hour</option>
<option value="90">1.5 hours</option>
<option value="120">2 hours</option>
</select>
</div>
<div className="field">
<label>Default reminder</label>
<select className="select" value={String(s.defaultAlertMinutes)} onChange={(e) => update({ defaultAlertMinutes: Number(e.target.value) })}>
<option value="-1">None</option>
<option value="0">At time of event</option>
<option value="5">5 minutes before</option>
<option value="10">10 minutes before</option>
<option value="15">15 minutes before</option>
<option value="30">30 minutes before</option>
<option value="60">1 hour before</option>
<option value="1440">1 day before</option>
</select>
</div>
</div>
<h2>Colour categories</h2>
<p className="hint">Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.</p>
{s.eventCategories.map((c, i) => (
<div key={c.name} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
<h3>{c.name}</h3>
<button className="icon-btn sm" title="Rename" onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label="Delete category" onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
</div>
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> New category</button>
<h2>Working hours</h2>
<div className="field-row">
<div className="field">
<label>Working hours start</label>
<select className="select" value={String(s.workDayStart)} onChange={(e) => update({ workDayStart: Number(e.target.value) })}>
{[...Array(24)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select>
</div>
<div className="field">
<label>Working hours end</label>
<select className="select" value={String(s.workDayEnd)} onChange={(e) => update({ workDayEnd: Number(e.target.value) })}>
{[...Array(25)].map((_, h) => <option key={h} value={h}>{`${h}:00`}</option>)}
</select>
</div>
<div className="field">
<label>Week starts on</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">Monday</option>
<option value="0">Sunday</option>
<option value="6">Saturday</option>
</select>
</div>
</div>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Code, Plus, Trash2, Wand2, Play, AlertTriangle, Power } from "lucide-react";
import { useSieve } from "@/store/sieve";
import { useMail } from "@/store/mail";
import { describeRule, newRule, rulesToSieve, type SieveRule } from "@/lib/sieve";
import { RuleDialog } from "./RuleDialog";
import { saveAndApply } from "../mail/FilterFromMessage";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { Switch, Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
import type { SieveScript } from "@/jmap/types";
export function FiltersSettings() {
const sieve = useSieve();
const [tab, setTab] = useState<"rules" | "scripts">("rules");
useEffect(() => {
if (sieve.available && !sieve.scripts.length && !sieve.loading) void sieve.load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sieve.available]);
if (!sieve.available) {
return (
<div>
<h1>Filters & rules</h1>
<p className="lead">Sieve filtering is not available for this account.</p>
</div>
);
}
return (
<div>
<h1>Filters & rules</h1>
<p className="lead">Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.</p>
<div className="view-switch" style={{ marginBottom: 16 }}>
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
</div>
{sieve.loading && !sieve.scripts.length ? <Spinner /> : tab === "rules" ? <RulesEditor /> : <ScriptsEditor />}
</div>
);
}
function RulesEditor() {
const sieve = useSieve();
const { script, rules, content } = sieve.rules();
const [local, setLocal] = useState<SieveRule[] | null>(null);
const [editing, setEditing] = useState<SieveRule | null>(null);
const [saving, setSaving] = useState(false);
const list = local ?? rules ?? [];
const dirty = local !== null;
const inbox = useMail((s) => { const id = s.roleId("inbox"); return id ? s.mailboxes[id] : undefined; });
const activeIsOther = script && script.name !== "ihasmail" && script.isActive;
const save = async (next: SieveRule[]) => {
setSaving(true);
try {
await sieve.saveRules(next);
setLocal(null);
toast.success("Filters saved");
} catch (err) {
toast.error(`Could not save filters: ${(err as Error).message}`);
} finally {
setSaving(false);
}
};
if (rules === null) {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Your active script {script?.name} was written by hand.</b></div>
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>Scripts</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>Start with rules</button>
</div>
);
}
return (
<div>
{activeIsOther && <div className="warn-box mb-16">Another script ({script?.name}) is active. Saving rules here will activate the ihasmail script instead.</div>}
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>No filters yet</h3><p>Create a rule to move newsletters to a folder, flag important senders, or forward mail.</p></div>}
{list.map((r, i) => (
<div key={r.id} className={`rule-card ${r.enabled ? "" : "disabled"}`}>
<div className="row">
<Switch checked={r.enabled} onChange={(v) => setLocal(list.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)))} />
<div className="grow" style={{ cursor: "pointer", minWidth: 0 }} onClick={() => setEditing(r)}>
<div style={{ fontWeight: 600 }}>{r.name}</div>
<div className="hint truncate">{describeRule(r)}</div>
</div>
<button className="icon-btn sm" disabled={i === 0} aria-label="Move up" onClick={() => { const n = [...list]; [n[i - 1], n[i]] = [n[i]!, n[i - 1]!]; setLocal(n); }}><ArrowUp size={16} /></button>
<button className="icon-btn sm" disabled={i === list.length - 1} aria-label="Move down" onClick={() => { const n = [...list]; [n[i + 1], n[i]] = [n[i]!, n[i + 1]!]; setLocal(n); }}><ArrowDown size={16} /></button>
<button className="icon-btn sm danger" aria-label="Delete rule" onClick={() => setLocal(list.filter((x) => x.id !== r.id))}><Trash2 size={16} /></button>
</div>
</div>
))}
<div className="row" style={{ marginTop: 12 }}>
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
<span className="spacer" />
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>Discard changes</button>}
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
</div>
{content && (
<details style={{ marginTop: 20 }}>
<summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary>
<pre className="code" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
</details>
)}
{editing && (
<RuleDialog
rule={editing}
onClose={() => setEditing(null)}
applyMailbox={inbox ? { id: inbox.id, name: inbox.name } : null}
onSave={(r, applyNow) => {
const exists = list.some((x) => x.id === r.id);
const next = exists ? list.map((x) => (x.id === r.id ? r : x)) : [...list, r];
setEditing(null);
if (applyNow && inbox) {
// Save immediately so the rule is live, then apply it to the Inbox.
setLocal(null);
void saveAndApply(r, next.filter((x) => x.id !== r.id), inbox.id);
} else setLocal(next);
}}
/>
)}
</div>
);
}
function ScriptsEditor() {
const sieve = useSieve();
const [sel, setSel] = useState<SieveScript | null>(null);
const [content, setContent] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [validation, setValidation] = useState<string | null>(null);
const open = async (s: SieveScript | null) => {
setSel(s);
setValidation(null);
if (s) {
setName(s.name);
setContent(await sieve.getContent(s.id));
} else {
setName("");
setContent('require ["fileinto"];\n\n');
}
};
const save = async (activate: boolean) => {
if (!name.trim()) {
toast.error("Script name is required");
return;
}
setBusy(true);
try {
const err = await sieve.validate(content);
setValidation(err);
if (err) {
toast.error("Script has errors");
return;
}
await sieve.saveScript(sel?.id ?? null, name.trim(), content, activate);
toast.success("Script saved");
setSel(null);
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
if (sel !== null || name !== "" || content !== "") {
if (sel !== null || name !== "" || content !== "") {
return (
<div>
<div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
<div className="field">
<label>Sieve source</label>
<textarea className="code" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
</div>
{validation && <div className="error-box mb-16">{validation}</div>}
<div className="row">
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>Cancel</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> Validate</button>
<span className="spacer" />
<button className="btn" disabled={busy} onClick={() => void save(false)}>Save</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>Save & activate</button>
</div>
</div>
);
}
}
return (
<div>
<p className="hint">Advanced: manage raw Sieve scripts. Only one script can be active at a time.</p>
{sieve.scripts.map((s) => (
<div key={s.id} className="card">
<div className="card-head">
<h3>{s.name} {s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>Edit</button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
<button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</div>
))}
<button className="btn" onClick={async () => { const n = await promptDialog({ title: "New script", placeholder: "Script name" }); if (n) { setName(n); setContent('require ["fileinto"];\n\n'); } }}><Plus size={16} /> New script</button>
</div>
);
}
@@ -0,0 +1,62 @@
import { useMemo, useState } from "react";
import { Eye, EyeOff, Folder, Pencil, Plus, Share2, Trash2, Inbox } from "lucide-react";
import { useMail } from "@/store/mail";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
export function FoldersSettings() {
const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath);
const [share, setShare] = useState<Mailbox | null>(null);
const list = useMemo(() => Object.values(mailboxes).map((m) => ({ m, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
const quotas = useMail((s) => s.quotas);
const q = quotas.find((x) => x.resourceType === "octets");
const create = async () => {
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for subfolders, e.g. Work/Invoices)" });
if (!name?.trim()) return;
try {
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
let parentId: string | null = null;
for (const part of parts) {
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
}
toast.success("Folder created");
} catch (err) {
toast.error((err as Error).message);
}
};
return (
<div>
<h1>Folders</h1>
<p className="lead">Create, rename, hide and share folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
<table className="sessions-table">
<thead><tr><th>Folder</th><th>Messages</th><th>Unread</th><th /></tr></thead>
<tbody>
{list.map(({ m, path }) => (
<tr key={m.id}>
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">hidden</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
<td>{m.totalEmails.toLocaleString()}</td>
<td>{m.unreadEmails.toLocaleString()}</td>
<td>
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
<button className="icon-btn sm" title="Rename" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
<button className="icon-btn sm" title="Share" onClick={() => setShare(m)}><Share2 size={16} /></button>
<button className="icon-btn sm danger" title="Delete" disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{share && <ShareDialog kind="Mailbox" id={share.id} name={share.name} shareWith={share.shareWith ?? null} onClose={() => setShare(null)} />}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
export function GeneralSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const reset = useSettings((st) => st.reset);
const exportJson = useSettings((st) => st.exportJson);
const importJson = useSettings((st) => st.importJson);
return (
<div>
<h1>General</h1>
<p className="lead">Reading, sending and list behaviour. Settings are stored in this browser.</p>
<h2>Reading</h2>
<div className="field-row">
<div className="field">
<label>Reading pane</label>
<select className="select" value={s.readingPane} onChange={(e) => update({ readingPane: e.target.value as typeof s.readingPane })}>
<option value="right">Right of the list</option>
<option value="bottom">Below the list</option>
<option value="off">Off (open messages full width)</option>
</select>
</div>
<div className="field">
<label>Mark as read</label>
<select className="select" value={String(s.markReadDelay)} onChange={(e) => update({ markReadDelay: Number(e.target.value) })}>
<option value="0">Immediately when opened</option>
<option value="2">After 2 seconds</option>
<option value="5">After 5 seconds</option>
<option value="-1">Never automatically</option>
</select>
</div>
<div className="field">
<label>After archiving or deleting</label>
<select className="select" value={s.autoAdvance} onChange={(e) => update({ autoAdvance: e.target.value as typeof s.autoAdvance })}>
<option value="list">Go back to the list</option>
<option value="older">Open the next (older) conversation</option>
<option value="newer">Open the previous (newer) conversation</option>
</select>
</div>
<div className="field">
<label>Remote images</label>
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
<option value="ask">Ask before showing (recommended)</option>
<option value="contacts">Show automatically from my contacts</option>
<option value="always">Always show</option>
</select>
</div>
</div>
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label="Conversation view" hint="Group messages from the same thread together." />
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label="Show message snippets" hint="Preview the first line of each message in the list." />
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label="Show sender avatars" />
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label="Confirm before deleting" />
<h2>Composing</h2>
<div className="field-row">
<div className="field">
<label>Default format</label>
<select className="select" value={s.composeFormat} onChange={(e) => update({ composeFormat: e.target.value as typeof s.composeFormat })}>
<option value="html">Rich text (HTML)</option>
<option value="text">Plain text</option>
</select>
</div>
<div className="field">
<label>Undo send window</label>
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="8">8 seconds</option>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
</select>
</div>
</div>
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label="Quote original message in replies" />
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label="Place signature above quoted text" />
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label="Always request read receipts" />
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
<h2>Locale</h2>
<div className="field-row">
<div className="field">
<label>Time zone</label>
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
<option value="">Browser default ({browserTimeZone})</option>
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
<div className="field">
<label>Week starts on</label>
<select className="select" value={String(s.weekStart)} onChange={(e) => update({ weekStart: Number(e.target.value) as 0 | 1 | 6 })}>
<option value="1">Monday</option>
<option value="0">Sunday</option>
<option value="6">Saturday</option>
</select>
</div>
</div>
<h2>Backup</h2>
<div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
<label className="btn">
Import settings
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? "Settings imported" : "Invalid settings file"); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>Reset to defaults</button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Trash2, Star } from "lucide-react";
import { useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
import type { Identity } from "@/jmap/types";
import { Dialog, confirmDialog } from "@/ui/dialog";
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
import { toast } from "@/ui/toast";
import { parseAddressList, formatAddressList } from "@/lib/address";
import { htmlToText } from "@/lib/text";
import { sanitizeEditorHtml } from "@/lib/html";
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
import { buildMarkerSignature, compactHtml, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
export function IdentitiesSettings() {
const identities = useMail((s) => s.identities);
const load = useMail((s) => s.loadIdentities);
const accountId = useMail((s) => s.accountId);
const setDefault = useMail((s) => s.setDefaultIdentity);
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<h1>Identities & signatures</h1>
<p className="lead">Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.</p>
{identities.map((i) => (
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
<div className="card-head">
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>Default</span>}</h3>
{i.id !== defaultId && (
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
)}
{i.mayDelete && (
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
)}
</div>
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
</div>
))}
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
</div>
);
}
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
const [name, setName] = useState(identity.name ?? "");
const [email, setEmail] = useState(identity.email ?? "");
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
const [html, setHtml] = useState(identity.htmlSignature || (identity.textSignature ? identity.textSignature.replace(/\n/g, "<br>") : ""));
const [busy, setBusy] = useState(false);
const ref = useRef<RichEditorHandle>(null);
const compact = compactHtml(sanitizeEditorHtml(html));
const sigLen = compact.length;
const tooLong = sigLen > SIGNATURE_LIMIT || htmlToText(compact).length > SIGNATURE_LIMIT;
const save = async () => {
setBusy(true);
try {
// 1) pasted pictures → stored files, 2) strip cruft, 3) fall back to a stored full copy.
const externalized = await externalizeDataImages(sanitizeEditorHtml(html));
const clean = compactHtml(externalized);
let htmlSignature = clean;
let textSignature = htmlToText(clean);
if (clean.length > SIGNATURE_LIMIT || textSignature.length > SIGNATURE_LIMIT) {
const blobId = await storeSignatureHtml(clean);
({ htmlSignature, textSignature } = buildMarkerSignature(blobId, clean));
}
const patch: Partial<Identity> = {
name,
replyTo: replyTo.trim() ? parseAddressList(replyTo) : null,
htmlSignature,
textSignature,
};
if (!identity.id) patch.email = email.trim();
await useMail.getState().saveIdentity(identity.id ?? null, patch);
toast.success("Identity saved");
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<div className="field-row">
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>Email address</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
</div>
<div className="field"><label>Reply-To (optional)</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="[email protected]" /><span className="hint">Replies to mail sent from this identity go here instead of the From address.</span></div>
<div className="field">
<label>Signature</label>
<div style={{ border: `1px solid ${tooLong ? "var(--danger)" : "var(--border-strong)"}`, borderRadius: 8, minHeight: 180, display: "flex", flexDirection: "column" }}>
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder="Your signature…" showToolbar imageUpload={uploadSignatureImage} />
</div>
<div className="row" style={{ justifyContent: "space-between" }}>
<span className="hint">Images are stored in your Files (folder ihasmail) and embedded when you send.</span>
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
</div>
{tooLong && <div className="warn-box mt-8">This signature is larger than the server's {SIGNATURE_LIMIT}-character limit. ihasmail will keep the full version in your Files and store a short text fallback on the server other mail clients will see the plain-text version.</div>}
</div>
</Dialog>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useSettings } from "@/store/settings";
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
export function LabelsSettings() {
const labels = useSettings((s) => s.settings.labels);
const update = useSettings((s) => s.update);
const [editing, setEditing] = useState<string | null>(null);
const add = async () => {
const name = await promptDialog({ title: "New label", placeholder: "Label name" });
if (!name?.trim()) return;
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
if (labels.some((l) => l.keyword === keyword)) return;
update({ labels: [...labels, { keyword, name: name.trim(), color: CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]! }] });
};
return (
<div>
<h1>Labels</h1>
<p className="lead">Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.</p>
{labels.map((l) => (
<div key={l.keyword} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: l.color, width: 14, height: 14 }} />
{editing === l.keyword ? (
<input className="input sm" autoFocus defaultValue={l.name} onBlur={(e) => { update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, name: e.target.value || x.name } : x)) }); setEditing(null); }} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} style={{ width: 240 }} />
) : (
<h3 style={{ cursor: "text" }} onClick={() => setEditing(l.keyword)}>{l.name} <span className="hint" style={{ fontWeight: 400 }}>({l.keyword})</span></h3>
)}
<button className="icon-btn sm danger" aria-label="Delete label" onClick={() => update({ labels: labels.filter((x) => x.keyword !== l.keyword) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}>
<ColorSwatches value={l.color} onChange={(c) => update({ labels: labels.map((x) => (x.keyword === l.keyword ? { ...x, color: c } : x)) })} />
</div>
</div>
))}
<button className="btn" onClick={() => void add()}><Plus size={16} /> New label</button>
<p className="hint mt-8">Tip: press <kbd className="kbd">l</kbd> on a conversation to apply labels. Search with <code>label:name</code>.</p>
</div>
);
}
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
import { useSession } from "@/store/session";
export function NotificationsSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const pushConnected = useSession((st) => st.pushConnected);
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
useEffect(() => {
if ("Notification" in window) setPerm(Notification.permission);
}, [s.desktopNotifications]);
return (
<div>
<h1>Notifications</h1>
<p className="lead">Live updates are delivered via JMAP push ({pushConnected ? "connected" : "reconnecting…"}).</p>
<Switch
checked={s.desktopNotifications}
onChange={async (v) => {
if (v) {
const p = await requestNotificationPermission();
setPerm(p);
if (p !== "granted") return;
}
update({ desktopNotifications: v });
}}
label="Desktop notifications for new mail"
hint={perm === "denied" ? "Notifications are blocked in your browser settings." : perm === "unsupported" ? "Not supported in this browser." : "Shows a system notification when new mail arrives in your Inbox while the tab is in the background."}
disabled={perm === "denied" || perm === "unsupported"}
/>
<Switch checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label="Play a sound for new mail" />
<div className="row mt-16">
<button className="btn" onClick={() => { showNotification("ihasmail test", { body: "This is what a new-mail notification looks like." }); playNewMailSound(); }}>Test notification</button>
</div>
<p className="hint mt-8">The tab title and favicon always show your unread Inbox count.</p>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useMail } from "@/store/mail";
import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type SieveTest } from "@/lib/sieve";
import { Dialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Id } from "@/jmap/types";
export interface RuleDialogProps {
rule: SieveRule;
onClose: () => void;
/** Called with the rule and whether the user asked to apply it to existing messages now. */
onSave: (r: SieveRule, applyNow: boolean) => void;
/** When set, offers "Also apply to existing messages in <folder>". */
applyMailbox?: { id: Id; name: string } | null;
title?: string;
saveLabel?: string;
}
export function RuleDialog({ rule, onClose, onSave, applyMailbox, title, saveLabel }: RuleDialogProps) {
const [r, setR] = useState<SieveRule>(rule);
const [applyNow, setApplyNow] = useState(Boolean(applyMailbox));
const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath);
const folders = useMemo(() => Object.values(mailboxes).map((m) => ({ id: m.id, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
const setTest = (i: number, t: SieveTest) => setR({ ...r, tests: r.tests.map((x, j) => (j === i ? t : x)) });
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
return (
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
{applyMailbox && (
<label className="check left" style={{ marginRight: "auto" }}>
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
</label>
)}
<button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
<div className="row" style={{ marginBottom: 8 }}>
<span className="label">When</span>
<select className="select" style={{ width: "auto" }} value={r.join} onChange={(e) => setR({ ...r, join: e.target.value as "allof" | "anyof" })}>
<option value="allof">all of the following match</option>
<option value="anyof">any of the following match</option>
</select>
</div>
{r.tests.map((t, i) => (
<div key={i} className="rule-row">
<select className="select" value={t.type === "true" ? "true" : t.type === "size" ? "size" : t.type === "body" ? "body" : t.type === "address" ? "address" : HEADER_CHOICES.some((h) => h.value === t.header) ? t.header : "__custom__"} onChange={(e) => {
const v = e.target.value;
if (v === "size") setTest(i, { type: "size", op: "over", value: 1024 * 1024 });
else if (v === "body") setTest(i, { type: "body", op: "contains", value: "" });
else if (v === "true") setTest(i, { type: "true" });
else if (v === "address") setTest(i, { type: "address", header: "from", part: "domain", op: "is", value: "" });
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
}}>
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
<option value="address">Sender domain</option>
<option value="size">Message size</option>
<option value="body">Body text</option>
<option value="true">Always (all messages)</option>
</select>
{t.type === "header" && !HEADER_CHOICES.some((h) => h.value === t.header && h.value !== "__custom__") ? (
<input className="input" placeholder="Header name" value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
) : t.type === "size" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">is larger than</option><option value="under">is smaller than</option></select>
) : t.type === "body" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">contains</option><option value="notcontains">does not contain</option></select>
) : t.type === "true" ? <span /> : (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
{t.type === "size" ? (
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">KB</span></div>
) : t.type === "true" ? <span /> : t.type === "header" && (t.op === "exists" || t.op === "notexists") ? <span /> : (
<input className="input" placeholder={t.type === "address" ? "example.com" : "value"} value={(t as { value: string }).value} onChange={(e) => setTest(i, { ...t, value: e.target.value } as SieveTest)} />
)}
<button className="icon-btn sm danger" aria-label="Remove condition" onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">Then</span></div>
{r.actions.map((a, i) => (
<div key={i} className="rule-row actions">
<select className="select" value={a.type} onChange={(e) => {
const v = e.target.value as SieveAction["type"];
const next: SieveAction = v === "fileinto" ? { type: "fileinto", mailbox: folders[0]?.path ?? "INBOX" } : v === "redirect" ? { type: "redirect", address: "" } : v === "reject" ? { type: "reject", reason: "" } : v === "addflag" ? { type: "addflag", flag: "" } : ({ type: v } as SieveAction);
setAction(i, next);
}}>
<option value="fileinto">Move to folder</option>
<option value="markread">Mark as read</option>
<option value="flag">Star</option>
<option value="addflag">Add label / keyword</option>
<option value="redirect">Forward to</option>
<option value="keep">Keep in Inbox</option>
<option value="discard">Delete</option>
<option value="reject">Reject with message</option>
<option value="stop">Stop processing more rules</option>
</select>
{a.type === "fileinto" ? (
<div className="row">
<select
className="select"
value={a.mailbox}
onChange={async (e) => {
const v = e.target.value;
if (v === "__new__") {
// Create a folder on the fly ("Parent/Child" creates nested folders).
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
if (!name?.trim()) return;
try {
const mail = useMail.getState();
const parts = name.split("/").map((x) => x.trim()).filter(Boolean);
let parentId: string | null = null;
for (const part of parts) {
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
parentId = existing ? existing.id : await mail.createMailbox(part, parentId);
}
const path = useMail.getState().mailboxPath(parentId!);
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
toast.success(`Folder “${path}” created`);
} catch (err) {
toast.error((err as Error).message);
}
return;
}
setAction(i, { ...a, mailbox: v, mailboxId: folders.find((f) => f.path === v)?.id });
}}
>
{folders.map((f) => <option key={f.id} value={f.path}>{f.path}</option>)}
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
<option value="__new__"> New folder</option>
</select>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "redirect" ? (
<div className="row">
<input className="input" type="email" placeholder="[email protected]" value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "reject" ? (
<input className="input" placeholder="Reason" value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
) : a.type === "addflag" || a.type === "setflag" || a.type === "removeflag" ? (
<input className="input" placeholder="keyword (e.g. $important, work)" value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
) : <span />}
<button className="icon-btn sm danger" aria-label="Remove action" onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
</Dialog>
);
}
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { apiFetch } from "@/jmap/client";
import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog } from "@/ui/dialog";
interface SessionRow {
id: string;
username: string;
createdAt: number;
lastSeenAt: number;
expiresAt: number;
remember: boolean;
userAgent: string;
ip: string;
}
export function SecuritySettings() {
const [rows, setRows] = useState<SessionRow[] | null>(null);
const [current, setCurrent] = useState<string>("");
const session = useSession((s) => s.session);
const logout = useSession((s) => s.logout);
const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([]));
useEffect(() => {
void load();
}, []);
return (
<div>
<h1>Security & sessions</h1>
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
<h2>Active webmail sessions</h2>
{rows === null ? <p className="hint">Loading…</p> : (
<table className="sessions-table">
<thead><tr><th>Device</th><th>IP</th><th>Last active</th><th>Expires</th><th /></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
<td className="mono small">{r.ip}</td>
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
<td>{formatFullDate(new Date(r.expiresAt).toISOString())}{r.remember ? " (remembered)" : ""}</td>
<td />
</tr>
))}
</tbody>
</table>
)}
<div className="row mt-16">
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
</div>
<h2>Password & two-factor</h2>
<p className="hint">Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.</p>
</div>
);
}
function shortUa(ua: string): string {
const browser = /Firefox\/(\d+)/.exec(ua) ? `Firefox ${/Firefox\/(\d+)/.exec(ua)![1]}` : /Edg\/(\d+)/.exec(ua) ? `Edge ${/Edg\/(\d+)/.exec(ua)![1]}` : /Chrome\/(\d+)/.exec(ua) ? `Chrome ${/Chrome\/(\d+)/.exec(ua)![1]}` : /Safari\/(\d+)/.exec(ua) ? "Safari" : "Browser";
const os = /Windows/.test(ua) ? "Windows" : /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS" : /Mac OS/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : "";
return `${browser}${os ? ` on ${os}` : ""}`;
}
+62
View File
@@ -0,0 +1,62 @@
import { lazy, Suspense, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { ArrowLeft, Bell, Filter, Folder, Info, Keyboard, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
import { Spinner } from "@/ui/misc";
import { GeneralSettings } from "./GeneralSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { IdentitiesSettings } from "./IdentitiesSettings";
import { FoldersSettings } from "./FoldersSettings";
import { LabelsSettings } from "./LabelsSettings";
import { TemplatesSettings } from "./TemplatesSettings";
import { NotificationsSettings } from "./NotificationsSettings";
import { SecuritySettings } from "./SecuritySettings";
import { AboutSettings } from "./AboutSettings";
import { ShortcutsSettings } from "./ShortcutsSettings";
import { CalendarSettings } from "./CalendarSettings";
const FiltersSettings = lazy(() => import("./FiltersSettings").then((m) => ({ default: m.FiltersSettings })));
const VacationSettings = lazy(() => import("./VacationSettings").then((m) => ({ default: m.VacationSettings })));
const SECTIONS: Array<{ id: string; label: string; icon: ReactNode; el: ReactNode }> = [
{ id: "general", label: "General", icon: <SettingsIcon size={18} />, el: <GeneralSettings /> },
{ id: "appearance", label: "Appearance", icon: <Palette size={18} />, el: <AppearanceSettings /> },
{ id: "identities", label: "Identities & signatures", icon: <PenLine size={18} />, el: <IdentitiesSettings /> },
{ id: "filters", label: "Filters & rules", icon: <Filter size={18} />, el: <FiltersSettings /> },
{ id: "vacation", label: "Out of office", icon: <Plane size={18} />, el: <VacationSettings /> },
{ id: "folders", label: "Folders", icon: <Folder size={18} />, el: <FoldersSettings /> },
{ id: "labels", label: "Labels", icon: <Tag size={18} />, el: <LabelsSettings /> },
{ id: "templates", label: "Templates", icon: <LayoutTemplate size={18} />, el: <TemplatesSettings /> },
{ id: "calendar", label: "Calendar & contacts", icon: <Calendar size={18} />, el: <CalendarSettings /> },
{ id: "notifications", label: "Notifications", icon: <Bell size={18} />, el: <NotificationsSettings /> },
{ id: "security", label: "Security & sessions", icon: <ShieldCheck size={18} />, el: <SecuritySettings /> },
{ id: "shortcuts", label: "Keyboard shortcuts", icon: <Keyboard size={18} />, el: <ShortcutsSettings /> },
{ id: "about", label: "About", icon: <Info size={18} />, el: <AboutSettings /> },
];
export function SettingsView({ section }: { section?: string }) {
const [, navigate] = useLocation();
const current = SECTIONS.find((s) => s.id === section);
return (
<div className={`settings-layout ${section ? "section" : "root"}`}>
<nav className="settings-nav" aria-label="Settings">
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Settings</span></div>
{SECTIONS.map((s) => (
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
{s.icon}
<span className="nav-label">{s.label}</span>
</Link>
))}
<div className="nav-section" style={{ paddingLeft: 8 }}><span>Shortcuts</span></div>
<Link href="/contacts" className="nav-item"><Users size={18} /><span className="nav-label">Address books</span></Link>
</nav>
<div className="settings-content">
{section && (
<button className="btn btn-ghost btn-sm" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/settings")}>
<ArrowLeft size={16} /> All settings
</button>
)}
<Suspense fallback={<Spinner />}>{current ? current.el : <GeneralSettings />}</Suspense>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail";
import { useCalendar } from "@/store/calendar";
import { client } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
type Kind = "Mailbox" | "Calendar" | "AddressBook";
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
Mailbox: [
{ key: "mayReadItems", label: "Read" },
{ key: "mayAddItems", label: "Add" },
{ key: "mayRemoveItems", label: "Remove" },
{ key: "maySetSeen", label: "Mark read" },
{ key: "maySetKeywords", label: "Flag" },
{ key: "mayCreateChild", label: "Create subfolders" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "maySubmit", label: "Send" },
],
Calendar: [
{ key: "mayReadFreeBusy", label: "See free/busy" },
{ key: "mayReadItems", label: "Read events" },
{ key: "mayWriteAll", label: "Edit all" },
{ key: "mayWriteOwn", label: "Edit own" },
{ key: "mayUpdatePrivate", label: "Private props" },
{ key: "mayRSVP", label: "RSVP" },
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
AddressBook: [
{ key: "mayRead", label: "Read" },
{ key: "mayWrite", label: "Write" },
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
};
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
};
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
const principals = useContacts((s) => s.principals);
const loadPrincipals = useContacts((s) => s.loadPrincipals);
const [rights, setRights] = useState<Record<Id, Record<string, boolean>>>(() => ({ ...((shareWith ?? {}) as Record<Id, Record<string, boolean>>) }));
const [pick, setPick] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
void loadPrincipals();
}, [loadPrincipals]);
const available = principals.filter((p) => !rights[p.id]);
const add = (p: Principal, preset: "reader" | "editor") => {
const r: Record<string, boolean> = {};
for (const k of PRESETS[kind][preset]) r[k] = true;
setRights({ ...rights, [p.id]: r });
setPick("");
};
const save = async () => {
setBusy(true);
try {
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
toast.success("Sharing updated");
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks();
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
{!principals.length ? (
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
) : (
<>
<div className="row" style={{ marginBottom: 12 }}>
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
<option value="">Add a person or group</option>
{available.map((p) => (
<option key={p.id} value={p.id}>{p.name}{p.email ? ` <${p.email}>` : ""}{p.type !== "individual" ? ` (${p.type})` : ""}</option>
))}
</select>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
</div>
{Object.entries(rights).map(([pid, r]) => {
const p = principals.find((x) => x.id === pid);
return (
<div key={pid} className="card">
<div className="card-head">
<h3>{p?.name ?? pid}{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
</div>
<div className="row wrap" style={{ marginTop: 8 }}>
{RIGHTS[kind].map((rt) => (
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
<span className="small">{rt.label}</span>
</label>
))}
</div>
</div>
);
})}
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
</>
)}
</Dialog>
);
}
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { keyboard } from "@/lib/keyboard";
import { Kbd } from "@/ui/misc";
export function ShortcutsSettings() {
const list = useMemo(() => keyboard.list(), []);
const groups = useMemo(() => {
const g = new Map<string, typeof list>();
for (const b of list) {
const arr = g.get(b.group) ?? [];
arr.push(b);
g.set(b.group, arr);
}
return [...g.entries()];
}, [list]);
return (
<div>
<h1>Keyboard shortcuts</h1>
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
<div className="shortcut-grid">
{groups.map(([group, items]) => (
<div key={group}>
<h3>{group}</h3>
{items.map((b) => (
<div key={b.keys} className="shortcut-row"><span>{b.description}</span><Kbd keys={b.keys} /></div>
))}
</div>
))}
{!groups.length && <p className="hint">Open the Mail view to see all shortcuts.</p>}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useSettings, type Template } from "@/store/settings";
import { Dialog } from "@/ui/dialog";
import { RichEditor } from "../compose/RichEditor";
import { htmlToText } from "@/lib/text";
export function TemplatesSettings() {
const templates = useSettings((s) => s.settings.templates);
const update = useSettings((s) => s.update);
const [editing, setEditing] = useState<Template | null>(null);
return (
<div>
<h1>Templates</h1>
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
{templates.map((t) => (
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
<div className="card-head">
<h3>{t.name}</h3>
<button className="icon-btn sm danger" aria-label="Delete template" onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
</div>
{t.subject && <div className="hint">Subject: {t.subject}</div>}
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
</div>
))}
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
{editing && (
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>Cancel</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>Save</button></>}>
<div className="field-row">
<div className="field"><label>Name</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>Subject (optional)</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
</div>
<div className="field">
<label>Body</label>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder="Template text…" />
</div>
</div>
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { useMail } from "@/store/mail";
import { Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
import { client, CAP } from "@/jmap/client";
export function VacationSettings() {
const vacation = useMail((s) => s.vacation);
const load = useMail((s) => s.loadVacation);
const save = useMail((s) => s.saveVacation);
const [enabled, setEnabled] = useState(false);
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [busy, setBusy] = useState(false);
const available = client.hasCapability(CAP.vacation);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!vacation) return;
setEnabled(vacation.isEnabled);
setSubject(vacation.subject ?? "");
setBody(vacation.textBody ?? "");
setFrom(vacation.fromDate ? toInputDateTime(new Date(vacation.fromDate)) : "");
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
}, [vacation]);
if (!available) return <div><h1>Out of office</h1><p className="lead">Vacation responses are not available for this account.</p></div>;
const submit = async () => {
setBusy(true);
try {
await save({
isEnabled: enabled,
subject: subject || null,
textBody: body || null,
htmlBody: null,
fromDate: from ? toUTCDate(fromInputDateTime(from)) : null,
toDate: to ? toUTCDate(fromInputDateTime(to)) : null,
});
toast.success(enabled ? "Auto-reply is on" : "Auto-reply saved");
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div>
<h1>Out of office</h1>
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
<div className="field-row mt-16">
<div className="field"><label>Starts (optional)</label><input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} /></div>
<div className="field"><label>Ends (optional)</label><input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} /></div>
</div>
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until and will reply when I'm back." /></div>
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>Save</button>
</div>
);
}