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
+250
View File
@@ -0,0 +1,250 @@
export const DAY_MS = 86_400_000;
export function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
export function endOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
export function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export function addMonths(d: Date, n: number): Date {
const x = new Date(d);
const day = x.getDate();
x.setDate(1);
x.setMonth(x.getMonth() + n);
const dim = daysInMonth(x.getFullYear(), x.getMonth());
x.setDate(Math.min(day, dim));
return x;
}
export function addMinutes(d: Date, n: number): Date {
return new Date(d.getTime() + n * 60_000);
}
export function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
/** weekStart: 0 = Sunday, 1 = Monday */
export function startOfWeek(d: Date, weekStart = 1): Date {
const x = startOfDay(d);
const diff = (x.getDay() - weekStart + 7) % 7;
return addDays(x, -diff);
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function isToday(d: Date): boolean {
return isSameDay(d, new Date());
}
/** 6x7 grid of dates covering the month view. */
export function monthGrid(anchor: Date, weekStart = 1): Date[] {
const first = startOfWeek(startOfMonth(anchor), weekStart);
const out: Date[] = [];
for (let i = 0; i < 42; i++) out.push(addDays(first, i));
return out;
}
export function weekDays(anchor: Date, weekStart = 1, count = 7): Date[] {
const first = startOfWeek(anchor, weekStart);
const out: Date[] = [];
for (let i = 0; i < count; i++) out.push(addDays(first, i));
return out;
}
function pad(n: number, w = 2): string {
return String(n).padStart(w, "0");
}
/** Format a Date's wall-clock (browser local) as JSCalendar LocalDateTime. */
export function toLocalDateTime(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function toLocalDateOnly(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Date → "YYYY-MM-DDTHH:MM:SSZ" (JMAP UTCDate, no millis). */
export function toUTCDate(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function parseLocalDateTime(s: string): { y: number; mo: number; d: number; h: number; mi: number; se: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s);
if (!m) return null;
return { y: +m[1]!, mo: +m[2]! - 1, d: +m[3]!, h: +(m[4] ?? 0), mi: +(m[5] ?? 0), se: +(m[6] ?? 0) };
}
const dtfCache = new Map<string, Intl.DateTimeFormat>();
function dtf(tz: string): Intl.DateTimeFormat | null {
let f = dtfCache.get(tz);
if (f) return f;
try {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
dtfCache.set(tz, f);
return f;
} catch {
return null;
}
}
/** Offset (ms) of timezone `tz` at instant `date`. */
export function tzOffsetMs(date: Date, tz: string): number {
const f = dtf(tz);
if (!f) return -date.getTimezoneOffset() * 60_000;
const parts = f.formatToParts(date);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0");
const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
return asUTC - Math.floor(date.getTime() / 1000) * 1000;
}
/** Interpret a JSCalendar LocalDateTime in timezone `tz` (or browser local if null) as an instant. */
export function zonedToDate(local: string, tz: string | null | undefined): Date {
const p = parseLocalDateTime(local);
if (!p) return new Date(NaN);
if (!tz) {
return new Date(p.y, p.mo, p.d, p.h, p.mi, p.se);
}
const asUTC = Date.UTC(p.y, p.mo, p.d, p.h, p.mi, p.se);
// Two-pass offset resolution handles DST edges reasonably.
let off = tzOffsetMs(new Date(asUTC), tz);
off = tzOffsetMs(new Date(asUTC - off), tz);
return new Date(asUTC - off);
}
/** Format an instant as LocalDateTime in timezone `tz` (browser local if null). */
export function dateToZonedLocal(d: Date, tz: string | null | undefined): string {
if (!tz) return toLocalDateTime(d);
const f = dtf(tz);
if (!f) return toLocalDateTime(d);
const parts = f.formatToParts(d);
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00";
return `${get("year")}-${get("month")}-${get("day")}T${String(Number(get("hour")) % 24).padStart(2, "0")}:${get("minute")}:${get("second")}`;
}
/** Parse ISO 8601 duration (e.g. "P1DT2H30M") into seconds. */
export function parseDuration(dur: string | null | undefined): number {
if (!dur) return 0;
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(dur);
if (!m) return 0;
const sign = m[1] === "-" ? -1 : 1;
const w = Number(m[2] ?? 0), d = Number(m[3] ?? 0), h = Number(m[4] ?? 0), mi = Number(m[5] ?? 0), s = Number(m[6] ?? 0);
return sign * (w * 7 * 86400 + d * 86400 + h * 3600 + mi * 60 + s);
}
export function formatDuration(seconds: number): string {
const neg = seconds < 0;
let s = Math.abs(Math.round(seconds));
const d = Math.floor(s / 86400);
s -= d * 86400;
const h = Math.floor(s / 3600);
s -= h * 3600;
const m = Math.floor(s / 60);
s -= m * 60;
let out = "P";
if (d) out += `${d}D`;
if (h || m || s) {
out += "T";
if (h) out += `${h}H`;
if (m) out += `${m}M`;
if (s) out += `${s}S`;
}
if (out === "P") out = "PT0S";
return (neg ? "-" : "") + out;
}
export function humanDuration(seconds: number): string {
const abs = Math.abs(seconds);
if (abs === 0) return "at time of event";
const parts: string[] = [];
const d = Math.floor(abs / 86400);
const h = Math.floor((abs % 86400) / 3600);
const m = Math.floor((abs % 3600) / 60);
if (d) parts.push(`${d} day${d === 1 ? "" : "s"}`);
if (h) parts.push(`${h} hour${h === 1 ? "" : "s"}`);
if (m) parts.push(`${m} minute${m === 1 ? "" : "s"}`);
return parts.join(" ") || `${abs} seconds`;
}
export const browserTimeZone = (() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
})();
export function listTimeZones(): string[] {
try {
const sv = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
if (sv) return sv("timeZone");
} catch {
/* ignore */
}
return ["UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Tokyo", "Asia/Kolkata", "Australia/Sydney"];
}
export function formatTimeRange(start: Date, end: Date, allDay: boolean): string {
if (allDay) {
const lastDay = new Date(end.getTime() - 1);
if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
}
const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (isSameDay(start, end)) {
return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} ${t(end)}`;
}
return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
}
/** For <input type="datetime-local"> */
export function toInputDateTime(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function fromInputDateTime(s: string): Date {
const p = parseLocalDateTime(s);
if (!p) return new Date(NaN);
return new Date(p.y, p.mo, p.d, p.h, p.mi, 0);
}
export function roundToNext(d: Date, minutes: number): Date {
const x = new Date(d);
x.setSeconds(0, 0);
const m = x.getMinutes();
const r = Math.ceil(m / minutes) * minutes;
x.setMinutes(r);
return x;
}