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,193 @@
|
||||
import type { EmailFilter, EmailFilterCondition, Mailbox } from "@/jmap/types";
|
||||
|
||||
export interface ParsedQuery {
|
||||
text: string[];
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
hasAttachment?: boolean;
|
||||
unread?: boolean;
|
||||
read?: boolean;
|
||||
starred?: boolean;
|
||||
in?: string;
|
||||
label?: string[];
|
||||
before?: string;
|
||||
after?: string;
|
||||
larger?: number;
|
||||
smaller?: number;
|
||||
notLabel?: string[];
|
||||
}
|
||||
|
||||
const SIZE_RE = /^(\d+(?:\.\d+)?)\s*([kmg]?b?)$/i;
|
||||
function parseSize(s: string): number | undefined {
|
||||
const m = SIZE_RE.exec(s.trim());
|
||||
if (!m) return undefined;
|
||||
const n = Number(m[1]);
|
||||
const unit = (m[2] ?? "").toLowerCase();
|
||||
const mult = unit.startsWith("k") ? 1024 : unit.startsWith("m") ? 1024 ** 2 : unit.startsWith("g") ? 1024 ** 3 : 1;
|
||||
return Math.round(n * mult);
|
||||
}
|
||||
|
||||
function parseDate(s: string, endOfDay = false): string | undefined {
|
||||
const t = s.trim();
|
||||
let d: Date | null = null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t) || /^\d{4}\/\d{2}\/\d{2}$/.test(t)) {
|
||||
const [y, m, dd] = t.split(/[-/]/).map(Number) as [number, number, number];
|
||||
d = new Date(y, m - 1, dd);
|
||||
} else if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(t)) {
|
||||
const [m, dd, y] = t.split("/").map(Number) as [number, number, number];
|
||||
d = new Date(y, m - 1, dd);
|
||||
} else {
|
||||
const rel = /^(\d+)([dwmy])$/.exec(t);
|
||||
if (rel) {
|
||||
d = new Date();
|
||||
const n = Number(rel[1]);
|
||||
if (rel[2] === "d") d.setDate(d.getDate() - n);
|
||||
if (rel[2] === "w") d.setDate(d.getDate() - n * 7);
|
||||
if (rel[2] === "m") d.setMonth(d.getMonth() - n);
|
||||
if (rel[2] === "y") d.setFullYear(d.getFullYear() - n);
|
||||
} else {
|
||||
const p = new Date(t);
|
||||
if (!Number.isNaN(p.getTime())) d = p;
|
||||
}
|
||||
}
|
||||
if (!d || Number.isNaN(d.getTime())) return undefined;
|
||||
if (endOfDay) d.setHours(23, 59, 59, 999);
|
||||
else d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
/** Tokenize respecting quotes. */
|
||||
function tokenize(q: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /(\S+?:"[^"]*"|"[^"]*"|\S+)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(q))) out.push(m[1]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseQuery(q: string): ParsedQuery {
|
||||
const p: ParsedQuery = { text: [] };
|
||||
for (const tok of tokenize(q)) {
|
||||
const idx = tok.indexOf(":");
|
||||
const key = idx > 0 ? tok.slice(0, idx).toLowerCase() : "";
|
||||
let val = idx > 0 ? tok.slice(idx + 1) : tok;
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
const neg = key.startsWith("-");
|
||||
const k = neg ? key.slice(1) : key;
|
||||
switch (k) {
|
||||
case "from":
|
||||
p.from = val;
|
||||
break;
|
||||
case "to":
|
||||
p.to = val;
|
||||
break;
|
||||
case "cc":
|
||||
p.cc = val;
|
||||
break;
|
||||
case "subject":
|
||||
p.subject = val;
|
||||
break;
|
||||
case "body":
|
||||
p.body = val;
|
||||
break;
|
||||
case "has":
|
||||
if (val === "attachment") p.hasAttachment = true;
|
||||
if (val === "star" || val === "flag") p.starred = true;
|
||||
break;
|
||||
case "is":
|
||||
if (val === "unread") p.unread = true;
|
||||
if (val === "read") p.read = true;
|
||||
if (val === "starred" || val === "flagged") p.starred = true;
|
||||
break;
|
||||
case "in":
|
||||
case "folder":
|
||||
p.in = val;
|
||||
break;
|
||||
case "label":
|
||||
case "keyword":
|
||||
if (neg) (p.notLabel ??= []).push(val);
|
||||
else (p.label ??= []).push(val);
|
||||
break;
|
||||
case "before":
|
||||
p.before = parseDate(val);
|
||||
break;
|
||||
case "after":
|
||||
case "since":
|
||||
p.after = parseDate(val);
|
||||
break;
|
||||
case "newer":
|
||||
case "newer_than":
|
||||
p.after = parseDate(val);
|
||||
break;
|
||||
case "older":
|
||||
case "older_than":
|
||||
p.before = parseDate(val);
|
||||
break;
|
||||
case "larger":
|
||||
case "size":
|
||||
p.larger = parseSize(val);
|
||||
break;
|
||||
case "smaller":
|
||||
p.smaller = parseSize(val);
|
||||
break;
|
||||
default:
|
||||
p.text.push(val);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function buildFilter(p: ParsedQuery, mailboxes: Record<string, Mailbox>, currentMailbox?: string | null): EmailFilter {
|
||||
const conds: EmailFilterCondition[] = [];
|
||||
const c: EmailFilterCondition = {};
|
||||
if (p.text.length) c.text = p.text.join(" ");
|
||||
if (p.from) c.from = p.from;
|
||||
if (p.to) c.to = p.to;
|
||||
if (p.cc) c.cc = p.cc;
|
||||
if (p.subject) c.subject = p.subject;
|
||||
if (p.body) c.body = p.body;
|
||||
if (p.hasAttachment) c.hasAttachment = true;
|
||||
if (p.unread) c.notKeyword = "$seen";
|
||||
if (p.read) c.hasKeyword = "$seen";
|
||||
if (p.before) c.before = p.before;
|
||||
if (p.after) c.after = p.after;
|
||||
if (p.larger != null) c.minSize = p.larger;
|
||||
if (p.smaller != null) c.maxSize = p.smaller;
|
||||
if (p.in) {
|
||||
const mb = resolveMailbox(p.in, mailboxes);
|
||||
if (mb) c.inMailbox = mb.id;
|
||||
} else if (currentMailbox) {
|
||||
c.inMailbox = currentMailbox;
|
||||
}
|
||||
conds.push(c);
|
||||
if (p.starred) conds.push({ hasKeyword: "$flagged" });
|
||||
for (const l of p.label ?? []) conds.push({ hasKeyword: l.startsWith("$") ? l : l });
|
||||
for (const l of p.notLabel ?? []) conds.push({ notKeyword: l });
|
||||
if (conds.length === 1) return conds[0]!;
|
||||
return { operator: "AND", conditions: conds };
|
||||
}
|
||||
|
||||
export function resolveMailbox(name: string, mailboxes: Record<string, Mailbox>): Mailbox | undefined {
|
||||
const n = name.toLowerCase();
|
||||
const list = Object.values(mailboxes);
|
||||
const byRole = list.find((m) => m.role === n || (n === "spam" && m.role === "junk") || (n === "starred" && m.role === "flagged") || (n === "anywhere" && m.role === "all"));
|
||||
if (byRole) return byRole;
|
||||
if (n === "anywhere" || n === "all") return undefined;
|
||||
return list.find((m) => m.name.toLowerCase() === n) ?? list.find((m) => m.name.toLowerCase().includes(n));
|
||||
}
|
||||
|
||||
export function describeFilter(p: ParsedQuery): string {
|
||||
const parts: string[] = [];
|
||||
if (p.text.length) parts.push(`"${p.text.join(" ")}"`);
|
||||
if (p.from) parts.push(`from ${p.from}`);
|
||||
if (p.to) parts.push(`to ${p.to}`);
|
||||
if (p.subject) parts.push(`subject ${p.subject}`);
|
||||
if (p.hasAttachment) parts.push("has attachment");
|
||||
if (p.unread) parts.push("unread");
|
||||
if (p.starred) parts.push("starred");
|
||||
if (p.in) parts.push(`in ${p.in}`);
|
||||
return parts.join(", ") || "all mail";
|
||||
}
|
||||
Reference in New Issue
Block a user