Files
webmail-client/frontend/src/lib/ws.ts
T
lukasandClaude Opus 5.5 0ddc0cb001 Admin user management, password reset by email, and the Ultimo Email name
Branding: the page titles and app name are now "Ultimo Email".

Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
  only, nothing listed until something is typed). A user opens a window to
  change their quota, block/unblock them, or delete them; block and delete ask
  for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
  message and revokes every session; a new user.signed_out event sends the
  user's open tabs to the sign-in screen at once. Any "not signed in" answer
  now does the same, not only /auth/me. Deleting removes mail accounts (sync
  stops), plugin rows (cleared inside the user's RLS context, since plugin
  tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
  password (vault-encrypted, never sent back), From address and the public
  address reset links point to, plus a "Send test" that reports the server's
  error.

Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
  answers "If this account exists you will receive an email to your recovery
  email address"; the mail goes out after the response, so neither the reply
  nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
  page to set a new password. With 2-step verification on, the code is checked
  before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
  confirmed with the current password, which must be typed — autofill and
  paste are refused (TypedPasswordInput).

Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:16:27 +02:00

162 lines
5.6 KiB
TypeScript

// WebSocket layer (SPEC §9.2). Validates frames with zod and invalidates
// TanStack Query caches keyed on ids in the payload.
import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { signedOut } from "./auth";
const frameSchema = z.object({
event: z.string(),
payload: z.record(z.unknown()),
});
const ALLOWED_EVENTS = new Set([
"message.new",
"message.changed",
"message.deleted",
"message.sent",
"flags.changed",
"thread.updated",
"sync.progress",
"account.state_changed",
"draft.saved",
"outbox.state_changed",
"user.signed_out",
]);
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
// Plugins receive plugin.{name}.* events plus any core event they subscribe to.
export type WsListener = (event: string, payload: Record<string, unknown>) => void;
const wsListeners = new Set<WsListener>();
export function subscribeWs(listener: WsListener): () => void {
wsListeners.add(listener);
return () => {
wsListeners.delete(listener);
};
}
export function connectWs(queryClient: QueryClient): () => void {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${window.location.host}/api/ws`;
let socket: WebSocket | null = null;
let ping: number | undefined;
let reconnectTimer: number | undefined;
let attempts = 0;
let stopped = false;
// Refetch the live-updated lists — used on every (re)connect so a dropped socket
// (server restart, sleep, network blip) self-heals and catches up on missed events.
const resync = () => {
for (const key of [
["threads"],
["accounts"],
["folder-counts"],
["quota"],
["drafts-remote"],
]) {
queryClient.invalidateQueries({ queryKey: key });
}
};
const handleMessage = (event: MessageEvent) => {
let parsed: unknown;
try {
parsed = JSON.parse(event.data);
} catch {
return;
}
const result = frameSchema.safeParse(parsed);
if (!result.success) return;
const { event: name, payload } = result.data;
const isCore = ALLOWED_EVENTS.has(name);
// Fan out core + plugin.* events to plugin subscribers (a failing listener
// must not break the others or the cache invalidation below).
if (isCore || name.startsWith("plugin.")) {
for (const listener of wsListeners) {
try {
listener(name, payload);
} catch (err) {
console.error("ws listener failed", err);
}
}
}
if (!isCore) return;
if (name === "user.signed_out") {
// An administrator blocked or deleted this account: leave now, rather
// than keep showing mail that was already loaded.
signedOut(queryClient);
return;
}
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
// Backfill progress counters live on the account row (drives the banner).
if (name === "sync.progress") {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
}
if (typeof payload.thread_id === "string") {
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
}
}
if (name === "flags.changed") {
// Read/unread changes affect the Inbox unread badge.
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}
if (name === "account.state_changed") {
// Adding/deleting an account changes threads and (Gmail) labels wholesale —
// refetch the list, chips, and sidebar so nothing stale lingers.
queryClient.invalidateQueries({ queryKey: ["accounts"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
window.dispatchEvent(new Event("webmail:labels-changed"));
}
if (name === "message.sent") {
// The sent message's draft is deleted server-side; refresh the Drafts list.
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}
};
const connect = () => {
socket = new WebSocket(url);
socket.onopen = () => {
attempts = 0;
ping = window.setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
resync(); // catch up on anything published while we were disconnected
};
socket.onmessage = handleMessage;
socket.onclose = () => {
if (ping !== undefined) window.clearInterval(ping);
if (stopped) return;
// Exponential backoff, capped at 30s, so a downed server is retried politely.
const delay = Math.min(30000, 1000 * 2 ** attempts);
attempts += 1;
reconnectTimer = window.setTimeout(connect, delay);
};
socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect
};
connect();
return () => {
stopped = true;
if (ping !== undefined) window.clearInterval(ping);
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
socket?.close();
};
}