Initial release: JMAP Webmail Client

A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
Designed for Stalwart Mail Server.

Features:
- Full email operations (compose, reply, forward, threading)
- Real-time push notifications
- Dark/light theme support
- Mobile responsive design
- Keyboard shortcuts
- Drag-and-drop organization
- i18n (English/French)
- Security-first (external content blocked, HTML sanitization)
This commit is contained in:
Matthieu MALVACHE
2025-12-10 17:54:22 +01:00
committed by Matthieu MALVACHE
commit cf21a84263
79 changed files with 21821 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
interface Position {
x: number;
y: number;
}
interface ContextMenuState<T> {
isOpen: boolean;
position: Position;
data: T | null;
}
interface UseContextMenuReturn<T> {
contextMenu: ContextMenuState<T>;
openContextMenu: (e: React.MouseEvent, data: T) => void;
closeContextMenu: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
}
const MENU_WIDTH = 200;
const MENU_HEIGHT = 320; // Approximate max height
export function useContextMenu<T>(): UseContextMenuReturn<T> {
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
isOpen: false,
position: { x: 0, y: 0 },
data: null,
});
const menuRef = useRef<HTMLDivElement | null>(null);
const calculatePosition = useCallback((clientX: number, clientY: number): Position => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = clientX;
let y = clientY;
// Adjust for right edge
if (x + MENU_WIDTH > viewportWidth - 10) {
x = viewportWidth - MENU_WIDTH - 10;
}
// Adjust for bottom edge
if (y + MENU_HEIGHT > viewportHeight - 10) {
y = viewportHeight - MENU_HEIGHT - 10;
}
// Ensure minimum position
x = Math.max(10, x);
y = Math.max(10, y);
return { x, y };
}, []);
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
e.preventDefault();
e.stopPropagation();
const position = calculatePosition(e.clientX, e.clientY);
setContextMenu({
isOpen: true,
position,
data,
});
}, [calculatePosition]);
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({
...prev,
isOpen: false,
}));
}, []);
// Close on escape key, click outside, and scroll
useEffect(() => {
if (!contextMenu.isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeContextMenu();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
};
const handleScroll = () => {
closeContextMenu();
};
const handleBlur = () => {
closeContextMenu();
};
// Add listeners with a slight delay to prevent immediate closing
const timeoutId = setTimeout(() => {
document.addEventListener("keydown", handleEscape);
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("scroll", handleScroll, true);
window.addEventListener("blur", handleBlur);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("keydown", handleEscape);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("blur", handleBlur);
};
}, [contextMenu.isOpen, closeContextMenu]);
return {
contextMenu,
openContextMenu,
closeContextMenu,
menuRef,
};
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useCallback, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
interface UseEmailDragOptions {
email: Email;
sourceMailboxId: string;
}
interface UseEmailDragReturn {
dragHandlers: {
draggable: boolean;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
};
isDragging: boolean;
}
function createDragPreview(count: number): HTMLElement {
const preview = document.createElement("div");
preview.className = "drag-preview";
preview.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
padding: 8px 16px;
background-color: var(--color-primary, #3b82f6);
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 14px;
font-weight: 500;
z-index: 9999;
white-space: nowrap;
pointer-events: none;
`;
preview.textContent = count === 1 ? "1 email" : `${count} emails`;
document.body.appendChild(preview);
return preview;
}
export function useEmailDrag({ email, sourceMailboxId }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
// - If current email is selected, drag all selected
// - Otherwise, drag only this email
const isSelected = selectedEmailIds.has(email.id);
const emailsToDrag = isSelected
? emails.filter(em => selectedEmailIds.has(em.id))
: [email];
// Set data transfer
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(
"application/x-email-ids",
JSON.stringify(emailsToDrag.map(em => em.id))
);
e.dataTransfer.setData(
"text/plain",
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
);
// Create custom drag image
const dragPreview = createDragPreview(emailsToDrag.length);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
// Clean up preview after drag starts (browser keeps a snapshot)
requestAnimationFrame(() => {
dragPreview.remove();
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag]);
const handleDragEnd = useCallback(() => {
endDrag();
}, [endDrag]);
// Check if this specific email is being dragged
const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id);
return {
dragHandlers: {
draggable: true,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
},
isDragging: isThisEmailDragging,
};
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
export interface KeyboardShortcutHandlers {
// Navigation
onNextEmail?: () => void;
onPreviousEmail?: () => void;
onOpenEmail?: () => void;
onCloseEmail?: () => void;
// Email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onToggleStar?: () => void;
onArchive?: () => void;
onDelete?: () => void;
onMarkAsUnread?: () => void;
onMarkAsRead?: () => void;
// Global actions
onCompose?: () => void;
onFocusSearch?: () => void;
onShowHelp?: () => void;
onRefresh?: () => void;
// Selection
onSelectAll?: () => void;
onDeselectAll?: () => void;
// Thread actions
onToggleThreadExpansion?: () => void;
}
export interface UseKeyboardShortcutsOptions {
enabled?: boolean;
emails: Email[];
selectedEmailId?: string;
handlers: KeyboardShortcutHandlers;
}
// Check if user is typing in an input field
function isInputFocused(): boolean {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
return isInput || isContentEditable;
}
export function useKeyboardShortcuts({
enabled = true,
emails,
selectedEmailId,
handlers,
}: UseKeyboardShortcutsOptions) {
const handlersRef = useRef(handlers);
// Keep handlers ref updated
useEffect(() => {
handlersRef.current = handlers;
}, [handlers]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (isInputFocused()) return;
const h = handlersRef.current;
const key = event.key.toLowerCase();
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
// Shortcuts that work with modifiers
if (event.ctrlKey || event.metaKey) {
switch (key) {
case "a":
// Ctrl/Cmd + A: Select all
event.preventDefault();
h.onSelectAll?.();
return;
}
}
// Shortcuts that should NOT work with modifiers
if (hasModifier) return;
switch (key) {
// Navigation
case "j":
case "arrowdown":
event.preventDefault();
h.onNextEmail?.();
break;
case "k":
case "arrowup":
event.preventDefault();
h.onPreviousEmail?.();
break;
case "enter":
case "o":
if (selectedEmailId) {
event.preventDefault();
h.onOpenEmail?.();
}
break;
case "escape":
event.preventDefault();
h.onCloseEmail?.();
h.onDeselectAll?.();
break;
// Email actions (only when email is selected)
case "r":
if (selectedEmailId) {
event.preventDefault();
if (event.shiftKey) {
h.onReplyAll?.();
} else {
h.onReply?.();
}
}
break;
case "a":
if (selectedEmailId) {
event.preventDefault();
h.onReplyAll?.();
}
break;
case "f":
if (selectedEmailId) {
event.preventDefault();
h.onForward?.();
}
break;
case "s":
if (selectedEmailId) {
event.preventDefault();
h.onToggleStar?.();
}
break;
case "e":
if (selectedEmailId) {
event.preventDefault();
h.onArchive?.();
}
break;
case "#":
case "delete":
case "backspace":
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
event.preventDefault();
h.onDelete?.();
}
break;
case "u":
if (selectedEmailId) {
event.preventDefault();
h.onMarkAsUnread?.();
}
break;
case "i":
if (selectedEmailId && event.shiftKey) {
event.preventDefault();
h.onMarkAsRead?.();
}
break;
// Global actions
case "c":
event.preventDefault();
h.onCompose?.();
break;
case "/":
event.preventDefault();
h.onFocusSearch?.();
break;
case "?":
event.preventDefault();
h.onShowHelp?.();
break;
case "g":
if (event.shiftKey) {
event.preventDefault();
h.onRefresh?.();
}
break;
// Thread actions
case "x":
if (selectedEmailId) {
event.preventDefault();
h.onToggleThreadExpansion?.();
}
break;
}
},
[selectedEmailId]
);
useEffect(() => {
if (!enabled) return;
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [enabled, handleKeyDown]);
// Helper to get next/previous email
const getAdjacentEmailIndex = useCallback(
(direction: "next" | "previous"): number => {
if (emails.length === 0) return -1;
if (!selectedEmailId) {
// If no email selected, select first (for next) or last (for previous)
return direction === "next" ? 0 : emails.length - 1;
}
const currentIndex = emails.findIndex((e) => e.id === selectedEmailId);
if (currentIndex === -1) return direction === "next" ? 0 : emails.length - 1;
if (direction === "next") {
return currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
} else {
return currentIndex > 0 ? currentIndex - 1 : currentIndex;
}
},
[emails, selectedEmailId]
);
return { getAdjacentEmailIndex };
}
// Shortcut definitions for the help modal
export const KEYBOARD_SHORTCUTS = {
navigation: [
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
{ key: "Esc", description: "shortcuts.navigation.close_email" },
],
actions: [
{ key: "r", description: "shortcuts.actions.reply" },
{ key: "R / a", description: "shortcuts.actions.reply_all" },
{ key: "f", description: "shortcuts.actions.forward" },
{ key: "s", description: "shortcuts.actions.star" },
{ key: "e", description: "shortcuts.actions.archive" },
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
],
global: [
{ key: "c", description: "shortcuts.global.compose" },
{ key: "/", description: "shortcuts.global.search" },
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse" },
],
} as const;
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useCallback, useState, DragEvent } from "react";
import { Mailbox } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store";
interface UseMailboxDropOptions {
mailbox: Mailbox;
onDropComplete?: () => void;
}
interface UseMailboxDropReturn {
dropHandlers: {
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
};
isDropTarget: boolean;
isValidDropTarget: boolean;
isInvalidDropTarget: boolean;
}
export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
const isValidTarget = useCallback(() => {
if (!isDragging) return false;
// Cannot drop on same mailbox
if (mailbox.id === sourceMailboxId) return false;
// Check if mailbox accepts items
if (!mailbox.myRights?.mayAddItems) return false;
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// For shared mailboxes, check account compatibility
if (mailbox.isShared && draggedEmails[0]) {
// Get the source mailbox's account ID from the store
const mailboxes = useEmailStore.getState().mailboxes;
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
// Cross-account moves are not supported
if (sourceMb?.accountId !== mailbox.accountId) {
return false;
}
}
return true;
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (isValidTarget()) {
e.dataTransfer.dropEffect = "move";
} else {
e.dataTransfer.dropEffect = "none";
}
}, [isValidTarget]);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// Only leave if actually leaving the element (not entering a child)
const relatedTarget = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(relatedTarget)) {
setIsOver(false);
}
}, []);
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(false);
if (!client || !isValidTarget()) {
endDrag();
return;
}
try {
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
if (!emailIdsJson) {
endDrag();
return;
}
const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders)
const destinationId = mailbox.originalId || mailbox.id;
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection();
}
// Refresh the current mailbox view
await fetchEmails(client, selectedMailbox);
// Show success toast
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
}
onDropComplete?.();
} catch (error) {
console.error("Failed to move emails:", error);
toast.error("Move failed", "Could not move emails to the selected folder");
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete]);
const valid = isValidTarget();
return {
dropHandlers: {
onDragOver: handleDragOver,
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
isDropTarget: isOver && isDragging,
isValidDropTarget: isOver && valid,
isInvalidDropTarget: isOver && isDragging && !valid,
};
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState, useEffect } from "react";
import { useUIStore } from "@/stores/ui-store";
// Tailwind v4 breakpoints
const BREAKPOINTS = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
} as const;
/**
* SSR-safe media query hook
* Returns false during SSR to prevent hydration mismatch
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
}
/**
* Hook to detect device type and sync with UI store
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
*/
export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
const isTabletQuery = useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
);
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
useEffect(() => {
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]);
return { isMobile, isTablet, isDesktop };
}
/**
* Convenience hooks for specific breakpoints
*/
export function useIsMobile() {
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
}
export function useIsTablet() {
return useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
);
}
export function useIsDesktop() {
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
}
export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) {
return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
}