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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user