feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA

- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
This commit is contained in:
Matthieu MALVACHE
2026-02-16 18:51:30 +01:00
committed by Matthieu MALVACHE
parent 8a5bc9b88d
commit fb414bf2c9
70 changed files with 8160 additions and 547 deletions
+244 -269
View File
@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -16,14 +17,13 @@ interface ThreadListItemProps {
isExpanded: boolean;
selectedEmailId?: string;
isLoading?: boolean;
expandedEmails?: Email[]; // Full thread emails when expanded
expandedEmails?: Email[];
onToggleExpand: () => void;
onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
onOpenConversation?: (thread: ThreadGroup) => void;
}
// Color tag mapping
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
@@ -34,90 +34,41 @@ const colorTags = {
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
export function ThreadListItem({
thread,
isExpanded,
selectedEmailId,
isLoading = false,
expandedEmails,
onToggleExpand,
onEmailSelect,
onContextMenu,
onOpenConversation,
}: ThreadListItemProps) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
interface SingleEmailItemProps {
email: Email;
selected: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
}
// Get color tag from thread
const threadColor = getThreadColorTag(thread.emails);
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
// Check if latest email is selected
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
// Single email thread - render as regular email, no expand
if (emailCount === 1) {
return (
<SingleEmailItem
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
/>
);
}
// Get emails to display when expanded
const emailsToShow = expandedEmails || thread.emails;
const handleHeaderClick = (e: React.MouseEvent) => {
// Mobile: open conversation view instead of inline expansion
if (isMobile && onOpenConversation) {
onOpenConversation(thread);
return;
}
// Desktop: If clicking directly on the expand icon area, toggle expansion
// Otherwise, select the latest email
const target = e.target as HTMLElement;
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
// Clicking on the row selects the latest email but also expands
if (!isExpanded) {
onToggleExpand();
}
onEmailSelect(latestEmail);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, latestEmail);
};
return (
<div className="border-b border-border">
{/* Thread Header (collapsed view) */}
<div
ref={ref}
className={cn(
"relative group cursor-pointer transition-all duration-200",
"relative group cursor-pointer transition-all duration-200 border-b border-border",
colorTag ? colorTag : (
isSelected
selected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50"
isUnread && !colorTag && "bg-accent/30"
)}
onClick={handleHeaderClick}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
@@ -125,257 +76,281 @@ export function ThreadListItem({
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Expand/Collapse Button - Hidden on mobile */}
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground"
)}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
<div className="w-6 flex-shrink-0" />
{/* Unread indicator */}
{hasUnread && (
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Participants and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
{/* Email count badge */}
<span className={cn(
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{emailCount}
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAttachment && (
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
hasUnread
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}
);
{/* Expanded Thread Emails - Desktop only */}
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t('loading')}
</div>
) : (
emailsToShow.map((email, index) => (
<ThreadEmailItem
key={email.id}
email={email}
selected={email.id === selectedEmailId}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onContextMenu={onContextMenu}
/>
))
export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemProps>(
function ThreadListItem({
thread,
isExpanded,
selectedEmailId,
isLoading = false,
expandedEmails,
onToggleExpand,
onEmailSelect,
onContextMenu,
onOpenConversation,
}, ref) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
const threadColor = getThreadColorTag(thread.emails);
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
if (emailCount === 1) {
return (
<SingleEmailItem
ref={ref}
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
/>
);
}
const emailsToShow = expandedEmails || thread.emails;
const handleHeaderClick = (e: React.MouseEvent) => {
if (isMobile && onOpenConversation) {
onOpenConversation(thread);
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
if (!isExpanded) {
onToggleExpand();
}
onEmailSelect(latestEmail);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, latestEmail);
};
return (
<div ref={ref} className="border-b border-border">
<div
className={cn(
"relative group cursor-pointer transition-all duration-200",
colorTag ? colorTag : (
isSelected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50"
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground"
)}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{hasUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
<span className={cn(
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</div>
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
)}
</div>
);
}
// Single email item (for threads with only 1 email)
function SingleEmailItem({
email,
selected,
onClick,
onContextMenu,
showPreview,
colorTag,
}: {
email: Email;
selected: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
}) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
colorTag ? colorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Spacer for alignment with thread items */}
<div className="w-6 flex-shrink-0" />
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t('loading')}
</div>
) : (
emailsToShow.map((email, index) => (
<ThreadEmailItem
key={email.id}
email={email}
selected={email.id === selectedEmailId}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onContextMenu={onContextMenu}
/>
))
)}
</div>
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}
);
}
);