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
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useLocation, useSearch } from "wouter";
import { Search, SlidersHorizontal, X } from "lucide-react";
import { useMail } from "@/store/mail";
import { keyboard } from "@/lib/keyboard";
export function SearchBar() {
const [location, navigate] = useLocation();
const search = useSearch();
const [q, setQ] = useState("");
const [adv, setAdv] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const mailboxes = useMail((s) => s.mailboxes);
const [advFields, setAdvFields] = useState({ from: "", to: "", subject: "", words: "", hasAttachment: false, unread: false, folder: "", after: "", before: "" });
// Sync from URL when on /search
useEffect(() => {
if (location.startsWith("/search")) {
const params = new URLSearchParams(search);
setQ(params.get("q") ?? "");
} else setQ("");
}, [location, search]);
useEffect(() => keyboard.pushScope("search", [{ keys: "/", description: "Search mail", group: "Navigation", handler: () => inputRef.current?.focus() }]), []);
const submit = (e?: FormEvent) => {
e?.preventDefault();
const query = q.trim();
if (!query) return;
setAdv(false);
navigate(`/search?q=${encodeURIComponent(query)}`);
inputRef.current?.blur();
};
const applyAdvanced = () => {
const parts: string[] = [];
const f = advFields;
if (f.from) parts.push(`from:${quote(f.from)}`);
if (f.to) parts.push(`to:${quote(f.to)}`);
if (f.subject) parts.push(`subject:${quote(f.subject)}`);
if (f.words) parts.push(f.words);
if (f.hasAttachment) parts.push("has:attachment");
if (f.unread) parts.push("is:unread");
if (f.folder) parts.push(`in:${quote(f.folder)}`);
if (f.after) parts.push(`after:${f.after}`);
if (f.before) parts.push(`before:${f.before}`);
const query = parts.join(" ");
setQ(query);
setAdv(false);
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form className="searchbar" role="search" onSubmit={submit}>
<div className="search-input">
<Search size={18} className="muted" />
<input ref={inputRef} type="search" placeholder="Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search mail" enterKeyHint="search" />
{q && (
<button type="button" className="icon-btn sm" aria-label="Clear" onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
<X size={16} />
</button>
)}
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label="Advanced search" title="Advanced search" onClick={() => setAdv((v) => !v)}>
<SlidersHorizontal size={16} />
</button>
</div>
{adv && (
<div className="search-panel">
<div className="grid">
<label className="field"><span className="label">From</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
<label className="field"><span className="label">To</span><input className="input sm" value={advFields.to} onChange={(e) => setAdvFields({ ...advFields, to: e.target.value })} /></label>
<label className="field"><span className="label">Subject</span><input className="input sm" value={advFields.subject} onChange={(e) => setAdvFields({ ...advFields, subject: e.target.value })} /></label>
<label className="field"><span className="label">Has the words</span><input className="input sm" value={advFields.words} onChange={(e) => setAdvFields({ ...advFields, words: e.target.value })} /></label>
<label className="field"><span className="label">Folder</span>
<select className="select" style={{ height: 32 }} value={advFields.folder} onChange={(e) => setAdvFields({ ...advFields, folder: e.target.value })}>
<option value="">All mail</option>
{Object.values(mailboxes).sort((a, b) => a.name.localeCompare(b.name)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
</label>
<div className="field"><span className="label">Date</span>
<div className="row"><input className="input sm" type="date" value={advFields.after} onChange={(e) => setAdvFields({ ...advFields, after: e.target.value })} /><span className="muted">to</span><input className="input sm" type="date" value={advFields.before} onChange={(e) => setAdvFields({ ...advFields, before: e.target.value })} /></div>
</div>
</div>
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
<div className="row gap-16">
<label className="check"><input type="checkbox" checked={advFields.hasAttachment} onChange={(e) => setAdvFields({ ...advFields, hasAttachment: e.target.checked })} /> Has attachment</label>
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> Unread only</label>
</div>
<div className="row">
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>Cancel</button>
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>Search</button>
</div>
</div>
</div>
)}
</form>
);
}
function quote(s: string): string {
return /\s/.test(s) ? `"${s.replace(/"/g, "")}"` : s;
}