Initial commit: webmail client (M0/M1)

Three-pane webmail client.
- Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP
  sync, threading, search, drafts/outbox with undo, attachments, 2FA.
- Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane
  UI with rich-text composer, light/dark themes, resizable panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from "react";
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs,
// except Escape. Supports the `g i` chord (go to inbox).
export interface Shortcuts {
onSearch?: () => void;
onCompose?: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onArchive?: () => void;
onGoInbox?: () => void;
onEscape?: () => void;
onNext?: () => void;
onPrev?: () => void;
}
function isTyping(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
}
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
const ref = useRef(shortcuts);
ref.current = shortcuts;
const pendingG = useRef(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const s = ref.current;
if (e.key === "Escape") {
s.onEscape?.();
return;
}
if (isTyping(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (pendingG.current) {
pendingG.current = false;
if (e.key === "i") {
e.preventDefault();
s.onGoInbox?.();
return;
}
}
switch (e.key) {
case "/":
e.preventDefault();
s.onSearch?.();
break;
case "c":
s.onCompose?.();
break;
case "r":
s.onReply?.();
break;
case "a":
s.onReplyAll?.();
break;
case "f":
s.onForward?.();
break;
case "e":
s.onArchive?.();
break;
case "j":
s.onNext?.();
break;
case "k":
s.onPrev?.();
break;
case "g":
pendingG.current = true;
window.setTimeout(() => (pendingG.current = false), 800);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}