NeahNew/app/courrier/page.tsx
2025-05-03 14:17:46 +02:00

1129 lines
44 KiB
TypeScript

'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import {
Mail, Loader2, AlertCircle,
MoreVertical, Settings, Plus as PlusIcon, Trash2, Edit,
Inbox, Send, Star, Trash, Plus, ChevronLeft, ChevronRight,
Search, ChevronDown, Folder, ChevronUp, Reply, Forward, ReplyAll,
MoreHorizontal, FolderOpen, X, Paperclip, MessageSquare, Copy, EyeOff,
AlertOctagon, Archive, Menu, Check
} from 'lucide-react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/use-toast';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
// Import components
import EmailSidebar from '@/components/email/EmailSidebar';
import EmailList from '@/components/email/EmailList';
import EmailSidebarContent from '@/components/email/EmailSidebarContent';
import EmailDetailView from '@/components/email/EmailDetailView';
import ComposeEmail from '@/components/email/ComposeEmail';
import { DeleteConfirmDialog } from '@/components/email/EmailDialogs';
// Import the custom hooks
import { useEmailState } from '@/hooks/use-email-state';
// Import the prefetching function
import { prefetchFolderEmails } from '@/lib/services/prefetch-service';
// Import Account type from the reducer
import { Account } from '@/lib/reducers/emailReducer';
// Add the missing EmailData import from use-courrier
import { EmailData } from '@/hooks/use-courrier';
// Simplified version for this component
function SimplifiedLoadingFix() {
// In production, don't render anything
if (process.env.NODE_ENV === 'production') {
return null;
}
// Simple debugging component
return (
<div className="fixed bottom-4 right-4 z-50 p-2 bg-white/80 shadow rounded-lg text-xs">
Debug: Email app loaded
</div>
);
}
interface EmailWithFlags {
id: string;
read?: boolean;
flags?: {
seen?: boolean;
};
}
interface EmailMessage {
id: string;
from: { name: string; address: string }[];
to: { name: string; address: string }[];
subject: string;
date: Date;
flags: {
seen: boolean;
flagged: boolean;
answered: boolean;
draft: boolean;
deleted: boolean;
};
size: number;
hasAttachments: boolean;
folder: string;
contentFetched: boolean;
accountId: string;
content: {
text: string;
html: string;
};
}
interface AccountData {
email: string;
password: string;
host: string;
port: number;
secure: boolean;
display_name: string;
smtp_host?: string;
smtp_port?: number;
smtp_secure?: boolean;
}
// Define a color palette for account circles
const colorPalette = [
'bg-blue-500',
'bg-green-500',
'bg-red-500',
'bg-yellow-500',
'bg-purple-500',
'bg-pink-500',
'bg-indigo-500',
'bg-teal-500',
'bg-orange-500',
'bg-cyan-500',
];
// Helper function for consistent logging
const logEmailOp = (operation: string, details: string, data?: any) => {
const timestamp = new Date().toISOString().split('T')[1].substring(0, 12);
console.log(`[${timestamp}][EMAIL-APP][${operation}] ${details}`);
if (data) {
console.log(`[${timestamp}][EMAIL-APP][DATA]`, data);
}
};
export default function CourrierPage() {
const router = useRouter();
const { data: session } = useSession();
// Replace useCourrier with useEmailState
const {
// State values
accounts,
selectedAccount,
selectedFolders,
currentFolder,
emails,
selectedEmail,
selectedEmailIds,
isLoading,
error,
page,
totalPages,
totalEmails,
mailboxes,
unreadCountMap,
showFolders,
// Actions
loadEmails,
handleEmailSelect,
toggleEmailSelection,
toggleSelectAll,
markEmailAsRead,
toggleStarred,
changeFolder,
deleteEmails,
sendEmail,
searchEmails,
formatEmailForAction,
setPage,
setEmails,
selectAccount,
handleLoadMore
} = useEmailState();
// UI state (keeping only what's still needed)
const [showComposeModal, setShowComposeModal] = useState(false);
const [composeType, setComposeType] = useState<'new' | 'reply' | 'reply-all' | 'forward'>('new');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showLoginNeeded, setShowLoginNeeded] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [prefetchStarted, setPrefetchStarted] = useState(false);
const [showAddAccountForm, setShowAddAccountForm] = useState(false);
// Add state for modals/dialogs
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [accountToEdit, setAccountToEdit] = useState<Account | null>(null);
const [accountToDelete, setAccountToDelete] = useState<Account | null>(null);
const [newPassword, setNewPassword] = useState('');
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [selectedColor, setSelectedColor] = useState<string>('');
// Use the reducer-managed values directly instead of tracked separately
const [searchQuery, setSearchQuery] = useState('');
const [unreadCount, setUnreadCount] = useState(0);
// Calculate unread count for the selected folder
useEffect(() => {
if (selectedAccount && selectedAccount.id !== 'loading-account') {
const folderCounts = unreadCountMap[selectedAccount.id.toString()];
if (folderCounts) {
setUnreadCount(folderCounts[currentFolder] || 0);
} else {
setUnreadCount(0);
}
} else {
// For 'loading-account', sum up all unread counts for the current folder
let totalUnread = 0;
Object.values(unreadCountMap).forEach((folderCounts) => {
totalUnread += folderCounts[currentFolder] || 0;
});
setUnreadCount(totalUnread);
}
}, [unreadCountMap, selectedAccount, currentFolder]);
// Initialize session and start prefetching
useEffect(() => {
// Flag to prevent multiple initialization attempts
let isMounted = true;
let retryCount = 0;
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
const initSession = async () => {
try {
if (!isMounted) return;
logEmailOp('SESSION', 'Initializing email session');
setLoading(true);
// First check if Redis is ready before making API calls
const redisStatus = await fetch('/api/redis/status')
.then(res => res.json())
.catch(() => ({ ready: false }));
if (!isMounted) return;
// Call the session API to check email credentials and start prefetching
logEmailOp('SESSION', 'Fetching session data from API');
const response = await fetch('/api/courrier/session', {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
}
});
// Handle 401 Unauthorized with retry logic
if (response.status === 401) {
if (retryCount < MAX_RETRIES) {
retryCount++;
console.log(`Session request failed (attempt ${retryCount}/${MAX_RETRIES}), retrying in ${RETRY_DELAY}ms...`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
return initSession();
} else {
console.error('Max retries reached for session request');
return;
}
}
if (!response.ok) {
throw new Error(`Session request failed with status ${response.status}`);
}
const data = await response.json();
// Log session response
console.log('[DEBUG] Session API response details:', {
authenticated: data.authenticated,
hasEmailCredentials: data.hasEmailCredentials,
accountsCount: data.allAccounts?.length || 0
});
// Process accounts if authenticated
if (data.authenticated && data.hasEmailCredentials) {
setPrefetchStarted(Boolean(data.prefetchStarted));
let updatedAccounts: Account[] = [];
// Process multiple accounts
if (data.allAccounts && Array.isArray(data.allAccounts) && data.allAccounts.length > 0) {
console.log('[DEBUG] Processing multiple accounts:', data.allAccounts.length);
data.allAccounts.forEach((account: any) => {
// Use exact folders from IMAP
const accountFolders = (account.folders && Array.isArray(account.folders))
? account.folders
: [];
// Ensure folder names have account prefix
const validFolders = accountFolders.map((folder: string) => {
if (!folder.includes(':')) {
return `${account.id}:${folder}`;
}
return folder;
});
updatedAccounts.push({
id: account.id,
name: account.display_name || account.email,
email: account.email,
color: account.color || colorPalette[(updatedAccounts.length) % colorPalette.length],
folders: validFolders
});
});
console.log('[DEBUG] Constructed accounts:', updatedAccounts);
} else {
// Fallback to single account if allAccounts is not available
const folderList = (data.mailboxes && data.mailboxes.length > 0) ?
data.mailboxes : [];
updatedAccounts.push({
id: 'default-account',
name: data.displayName || data.email,
email: data.email,
color: colorPalette[0],
folders: folderList
});
console.log('[DEBUG] Constructed single fallback account:', updatedAccounts[0]);
}
// Update accounts state using our reducer actions
// First, set the accounts
setEmails([]); // Clear any existing emails first
// Log current state for debugging
console.log('[DEBUG] Current state before setting accounts:', {
accounts: accounts?.length || 0,
selectedAccount: selectedAccount?.id || 'none',
currentFolder: currentFolder || 'none'
});
// Use our reducer actions instead of setState
setAccounts(updatedAccounts);
// Auto-select the first account if available
if (updatedAccounts.length > 0) {
const firstAccount = updatedAccounts[0];
console.log('[DEBUG] Auto-selecting first account:', firstAccount);
// Use our new selectAccount function which handles state atomically
// Add a slight delay to ensure the accounts are set first
setTimeout(() => {
console.log('[DEBUG] Now calling selectAccount');
selectAccount(firstAccount);
}, 100);
}
} else {
// User is authenticated but doesn't have email credentials
setShowLoginNeeded(true);
}
} catch (error) {
console.error('Error initializing session:', error);
} finally {
if (isMounted) {
setLoading(false);
}
}
};
if (session?.user?.id) {
initSession();
}
return () => {
isMounted = false;
};
}, [session?.user?.id, setEmails, selectAccount]);
// Helper to get folder icons
const getFolderIcon = (folder: string) => {
const folderLower = folder.toLowerCase();
if (folderLower.includes('inbox')) {
return <Inbox className="h-4 w-4 text-gray-500" />;
} else if (folderLower.includes('sent')) {
return <Send className="h-4 w-4 text-gray-500" />;
} else if (folderLower.includes('trash')) {
return <Trash className="h-4 w-4 text-gray-500" />;
} else if (folderLower.includes('archive')) {
return <Archive className="h-4 w-4 text-gray-500" />;
} else if (folderLower.includes('draft')) {
return <Edit className="h-4 w-4 text-gray-500" />;
} else if (folderLower.includes('spam') || folderLower.includes('junk')) {
return <AlertOctagon className="h-4 w-4 text-gray-500" />;
} else {
return <Folder className="h-4 w-4 text-gray-500" />;
}
};
// Helper to format folder names
const formatFolderName = (folder: string) => {
// Extract base folder name if prefixed
const baseFolderName = folder.includes(':') ? folder.split(':')[1] : folder;
return baseFolderName.charAt(0).toUpperCase() + baseFolderName.slice(1).toLowerCase();
};
// Handle actions - replace with useReducer-based functions
const handleMailboxChange = (folder: string, accountId?: string) => {
// Simply call our new changeFolder function which handles everything atomically
setLoading(true);
changeFolder(folder, accountId)
.finally(() => {
setLoading(false);
});
};
// Handle account selection - replace with reducer-based function
const handleAccountSelect = (account: Account) => {
// Add extensive debugging to track the process
console.log('[DEBUG] handleAccountSelect called with account:', {
id: account.id,
email: account.email,
folders: account.folders?.length
});
// Skip if no valid account provided
if (!account || !account.id) {
console.error('Invalid account passed to handleAccountSelect');
return;
}
// Skip if this is already the selected account
if (selectedAccount?.id === account.id) {
console.log('[DEBUG] Account already selected, skipping');
return;
}
// Simply call our new selectAccount function which handles everything atomically
setLoading(true);
// Clear all existing selections first
console.log('[DEBUG] Now selecting account through reducer action');
selectAccount(account);
// Log what happened
console.log('[DEBUG] Account selection completed');
// Give some time for the UI to update
setTimeout(() => setLoading(false), 300);
};
// Email actions
const handleReply = () => {
if (!selectedEmail) return;
setComposeType('reply');
setShowComposeModal(true);
};
const handleReplyAll = () => {
if (!selectedEmail) return;
setComposeType('reply-all');
setShowComposeModal(true);
};
const handleForward = () => {
if (!selectedEmail) return;
setComposeType('forward');
setShowComposeModal(true);
};
const handleComposeNew = () => {
setComposeType('new');
setShowComposeModal(true);
};
// Handle bulk actions
const handleBulkAction = async (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => {
if (selectedEmailIds.length === 0) return;
switch (action) {
case 'delete':
setShowDeleteConfirm(true);
break;
case 'mark-read':
// Mark all selected emails as read
for (const emailId of selectedEmailIds) {
await markEmailAsRead(emailId, true);
}
break;
case 'mark-unread':
// Mark all selected emails as unread
for (const emailId of selectedEmailIds) {
await markEmailAsRead(emailId, false);
}
break;
case 'archive':
// Archive functionality would be implemented here
break;
}
};
const handleSendEmail = async (emailData: EmailData) => {
try {
const result = await sendEmail(emailData);
if (!result.success) {
throw new Error(result.error);
}
return result;
} catch (error) {
throw error;
}
};
const handleDeleteConfirm = async () => {
await deleteEmails(selectedEmailIds);
setShowDeleteConfirm(false);
// Clear selected emails after deletion
// Using setEmails will reset the selection state
setLoading(true);
setPage(1);
loadEmails(1, 20, false).finally(() => {
// Selection will be cleared by loading new emails
setLoading(false);
});
};
const handleGoToLogin = () => {
router.push('/courrier/login');
};
// Update the accounts from state - fix type issues
const setAccounts = (newAccounts: Account[]) => {
console.log('[DEBUG] Setting accounts:', newAccounts);
// In the previous implementation, we'd dispatch an action
// But since we don't have direct access to the reducer's dispatch function,
// we need to use the exported actions from our hook
// This dispatch function should be made available by our hook
const windowWithDispatch = window as any;
if (typeof windowWithDispatch.dispatchEmailAction === 'function') {
// Use the global dispatch function if available
windowWithDispatch.dispatchEmailAction({
type: 'SET_ACCOUNTS',
payload: newAccounts
});
} else {
console.error('Cannot dispatch SET_ACCOUNTS action - no dispatch function available');
// Fallback: Try to directly modify the accounts array if we have access
// This isn't ideal but ensures backward compatibility during transition
console.log('[DEBUG] Using fallback method to update accounts');
// Our reducer should expose this action
const useEmailStateDispatch = windowWithDispatch.__emailStateDispatch;
if (typeof useEmailStateDispatch === 'function') {
useEmailStateDispatch({
type: 'SET_ACCOUNTS',
payload: newAccounts
});
} else {
console.error('No fallback dispatch method available either');
}
}
};
return (
<>
<SimplifiedLoadingFix />
{/* Main layout */}
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<div className="flex h-full bg-carnet-bg">
{/* Use EmailSidebar component instead of inline sidebar */}
<EmailSidebar
accounts={accounts}
selectedAccount={selectedAccount}
selectedFolders={selectedFolders}
currentFolder={currentFolder}
loading={loading || isLoading}
unreadCount={unreadCountMap}
showAddAccountForm={showAddAccountForm}
onFolderChange={handleMailboxChange}
onRefresh={() => {
setLoading(true);
setPage(1);
loadEmails(page, 10, false).finally(() => setLoading(false));
}}
onComposeNew={handleComposeNew}
onAccountSelect={handleAccountSelect}
onShowAddAccountForm={setShowAddAccountForm}
onAddAccount={async (formData) => {
setLoading(true);
console.log('[DEBUG] Add account form submission:', formData);
// Pull values from form with proper type handling
const formValues = {
email: formData.get('email')?.toString() || '',
password: formData.get('password')?.toString() || '',
host: formData.get('host')?.toString() || '',
port: parseInt(formData.get('port')?.toString() || '993'),
secure: formData.get('secure') === 'on',
display_name: formData.get('display_name')?.toString() || '',
smtp_host: formData.get('smtp_host')?.toString() || '',
smtp_port: formData.get('smtp_port')?.toString() ?
parseInt(formData.get('smtp_port')?.toString() || '587') : undefined,
smtp_secure: formData.get('smtp_secure') === 'on'
};
// If display_name is empty, use email
if (!formValues.display_name) {
formValues.display_name = formValues.email;
}
try {
// First test the connection
const testResponse = await fetch('/api/courrier/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: formValues.email,
password: formValues.password,
host: formValues.host,
port: formValues.port,
secure: formValues.secure
})
});
const testResult = await testResponse.json();
if (!testResponse.ok) {
throw new Error(testResult.error || 'Connection test failed');
}
console.log('Connection test successful:', testResult);
// Only declare realAccounts once before using for color assignment
const realAccounts = accounts.filter(a => a.id !== 'loading-account');
const saveResponse = await fetch('/api/courrier/account', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formValues)
});
const saveResult = await saveResponse.json();
if (!saveResponse.ok) {
throw new Error(saveResult.error || 'Failed to add account');
}
const realAccount = saveResult.account;
realAccount.color = colorPalette[realAccounts.length % colorPalette.length];
realAccount.folders = testResult.details.sampleFolders || ['INBOX', 'Sent', 'Drafts', 'Trash'];
setAccounts([...accounts, realAccount]);
setShowAddAccountForm(false);
toast({
title: "Account added successfully",
description: `Your email account ${formValues.email} has been added.`,
duration: 5000
});
} catch (error) {
console.error('Error adding account:', error);
toast({
title: "Failed to add account",
description: error instanceof Error ? error.message : 'Unknown error',
variant: "destructive",
duration: 5000
});
} finally {
setLoading(false);
}
}}
onEditAccount={async (account) => {
try {
// Get the latest account data from accounts array
const updatedAccount = accounts.find(a => a.id === account.id);
if (updatedAccount) {
setAccountToEdit(updatedAccount as any);
setSelectedColor(updatedAccount.color || '');
setShowEditModal(true);
} else {
toast({
title: "Error",
description: "Could not find account data",
variant: "destructive",
duration: 3000
});
}
} catch (error) {
console.error("Error preparing account edit:", error);
toast({
title: "Error",
description: "Failed to load account settings",
variant: "destructive",
duration: 3000
});
}
}}
onDeleteAccount={(account) => {
setAccountToDelete(account as any);
setShowDeleteDialog(true);
}}
onSelectEmail={(emailId, accountId, folder) => {
if (typeof emailId === 'string') {
handleEmailSelect(emailId, accountId || '', folder || currentFolder);
}
}}
{...({} as any)}
/>
{/* Panel 2: Email List - Always visible */}
<div className="w-80 flex flex-col border-r border-gray-100 overflow-hidden">
{/* Header without search bar or profile */}
<div className="p-2 border-b border-gray-100 bg-white flex items-center justify-between">
<Button
variant="ghost"
size="icon"
className="md:hidden h-9 w-9"
onClick={() => setMobileSidebarOpen(!mobileSidebarOpen)}
>
<Menu className="h-5 w-5 text-gray-500" />
</Button>
<div className="flex-1">
<div className="flex items-center">
{getFolderIcon(currentFolder)}
{/* Extract base folder and show email as prefix */}
<span className="ml-2 font-medium text-gray-700">
{selectedAccount?.email ? `${selectedAccount.email}: ` : ''}
{formatFolderName(currentFolder.includes(':') ? currentFolder.split(':')[1] : currentFolder)}
</span>
</div>
</div>
{/* Buttons removed from here to avoid duplication with the BulkActionsToolbar */}
</div>
{/* Email List - Always visible */}
<div className="flex-1 overflow-hidden bg-white">
{isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-2" />
<p className="text-sm text-gray-500">Loading emails...</p>
</div>
</div>
) : error ? (
<div className="h-full flex items-center justify-center">
<div className="max-w-md p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error}
</AlertDescription>
</Alert>
</div>
</div>
) : (
<div className="h-full overflow-hidden flex flex-col">
{/* Email List */}
<div
className="flex-1 overflow-y-auto"
onScroll={(e) => {
const target = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = target;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// Store last scroll position to detect direction
const lastScrollTop = target.dataset.lastScrollTop ?
parseInt(target.dataset.lastScrollTop) : 0;
const scrollingDown = scrollTop > lastScrollTop;
// Update last scroll position
target.dataset.lastScrollTop = scrollTop.toString();
// Prevent frequent log spam with a timestamp check
const now = Date.now();
const lastLog = parseInt(target.dataset.lastLogTime || '0');
if (now - lastLog > 500) { // Log at most every 500ms
console.log(`[DEBUG-WRAPPER-SCROLL] Distance: ${distanceToBottom}px, %: ${Math.round(scrollPercentage * 100)}%, direction: ${scrollingDown ? 'down' : 'up'}, more: ${page < totalPages}, loading: ${isLoading}`);
target.dataset.lastLogTime = now.toString();
}
// Check throttle to prevent multiple rapid triggers
const lastTrigger = parseInt(target.dataset.lastTriggerTime || '0');
const throttleTime = 1000; // 1 second throttle
// CRITICAL FIX: Only trigger loading more emails when:
// 1. User is scrolling DOWN (not up)
// 2. User is EXACTLY at the bottom (distance < 5px)
// 3. Not currently loading
// 4. More emails exist to load
// 5. Not throttled (hasn't triggered in last second)
if (scrollingDown &&
distanceToBottom < 5 && // Much stricter - truly at bottom
!isLoading &&
page < totalPages &&
now - lastTrigger > throttleTime) {
console.log(`[DEBUG-WRAPPER-TRIGGER] *** AT BOTTOM *** Loading more emails`);
target.dataset.lastTriggerTime = now.toString();
handleLoadMore();
}
}}
>
{emails.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center p-6">
<Inbox className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-700">No emails found</h3>
<p className="text-sm text-gray-500 mt-1">
{searchQuery
? `No results found for "${searchQuery}"`
: `Your ${currentFolder.toLowerCase()} is empty`}
</p>
</div>
</div>
) : (
<EmailList
emails={emails}
selectedEmailIds={selectedEmailIds}
selectedEmail={selectedEmail}
onSelectEmail={(emailId, emailAccountId, emailFolder) => {
// Always use the email's own accountId and folder if available
handleEmailSelect(
emailId,
emailAccountId || selectedAccount?.id || '',
emailFolder || currentFolder
);
}}
onToggleSelect={toggleEmailSelection}
onToggleSelectAll={toggleSelectAll}
onToggleStarred={toggleStarred}
onLoadMore={handleLoadMore}
hasMoreEmails={page < totalPages}
currentFolder={currentFolder}
isLoading={isLoading}
totalEmails={emails.length}
onBulkAction={handleBulkAction}
/>
)}
</div>
</div>
)}
</div>
</div>
{/* Panel 3: Email Detail - Always visible */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Content for Panel 3 based on state but always visible */}
<div className="flex-1 overflow-hidden bg-white">
{selectedEmail ? (
<EmailDetailView
email={selectedEmail as any}
onBack={() => {
handleEmailSelect('', '', '');
// Ensure sidebar stays visible
setSidebarOpen(true);
}}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onToggleStar={() => toggleStarred(selectedEmail.id)}
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<p>Select an email to view or</p>
<button
className="text-primary mt-2 hover:underline"
onClick={() => {
setComposeType('new');
setShowComposeModal(true);
}}
>
Compose a new message
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</main>
{/* Modals and Dialogs */}
<DeleteConfirmDialog
show={showDeleteConfirm}
selectedCount={selectedEmailIds.length}
onConfirm={handleDeleteConfirm}
onCancel={() => setShowDeleteConfirm(false)}
/>
{/* Compose Email Dialog */}
<Dialog open={showComposeModal} onOpenChange={(open) => !open && setShowComposeModal(false)}>
<DialogContent className="sm:max-w-[800px] p-0 h-[80vh]">
<DialogTitle asChild>
<span className="sr-only">New Message</span>
</DialogTitle>
<ComposeEmail
type={composeType}
initialEmail={composeType !== 'new' ? (selectedEmail as any) : undefined}
onSend={async (emailData) => {
try {
const result = await sendEmail(emailData);
return;
} catch (error) {
console.error('Error sending email:', error);
throw error;
}
}}
onClose={() => setShowComposeModal(false)}
accounts={accounts}
/>
</DialogContent>
</Dialog>
{/* Edit Password Modal */}
<Dialog open={showEditModal} onOpenChange={open => {
if (!open) {
setShowEditModal(false);
setEditLoading(false);
setAccountToEdit(null);
setNewPassword('');
setSelectedColor('');
window.location.reload();
}
}}>
<DialogContent className="sm:max-w-[500px] bg-white text-gray-800">
<DialogTitle className="text-gray-800">Edit Account Settings</DialogTitle>
<form onSubmit={async e => {
e.preventDefault();
if (!accountToEdit) return;
setEditLoading(true);
try {
const formElement = e.target as HTMLFormElement;
const displayName = (formElement.querySelector('#display-name') as HTMLInputElement).value;
const color = selectedColor;
// If password is changed, test the connection first
if (newPassword) {
try {
// First get the account's connection details
const accountDetailsRes = await fetch(`/api/courrier/account-details?accountId=${accountToEdit.id}`);
if (!accountDetailsRes.ok) {
throw new Error('Failed to fetch account connection details');
}
const accountDetails = await accountDetailsRes.json();
// Test connection with new password before saving
const testResponse = await fetch('/api/courrier/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: accountToEdit.email,
password: newPassword,
// Use the account's connection details from the API
host: accountDetails.host,
port: accountDetails.port || 993,
secure: accountDetails.secure || true
})
});
const testResult = await testResponse.json();
if (!testResponse.ok) {
throw new Error(testResult.error || 'Connection test failed with new password');
}
console.log('Connection test successful with new password');
} catch (error) {
console.error('Error testing connection:', error);
throw new Error(`Password test failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Continue with the update if test passed or no password change
const res = await fetch('/api/courrier/account', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: accountToEdit.id,
newPassword: newPassword || undefined,
display_name: displayName,
color: color
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to update account settings');
toast({ title: 'Account updated', description: 'Account settings updated successfully.' });
setShowEditModal(false);
setNewPassword('');
// Update the local account data
setAccounts(accounts.map(account =>
account.id === accountToEdit.id
? {...account, name: displayName, color: color}
: account
));
// Clear accountToEdit to ensure fresh data on next edit
setAccountToEdit(null);
// Force a page refresh to reset all UI states
window.location.reload();
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to update account settings', variant: 'destructive' });
} finally {
setEditLoading(false);
}
}}>
<div className="mb-4">
<Label htmlFor="display-name" className="text-gray-800">Account Name</Label>
<Input
id="display-name"
type="text"
defaultValue={accountToEdit?.name}
className="mt-1 bg-white text-gray-800"
disabled={editLoading}
/>
</div>
<div className="mb-4">
<Label htmlFor="new-password" className="text-gray-800">New Password (optional)</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="mt-1 bg-white text-gray-800"
placeholder="Leave blank to keep current password"
disabled={editLoading}
/>
</div>
<div className="mb-4">
<Label className="block mb-2 text-gray-800">Account Color</Label>
<div className="grid grid-cols-5 gap-2">
{colorPalette.map((color, index) => (
<div key={index} className="flex items-center">
<input
type="radio"
id={`color-${index}`}
name="color"
value={color}
checked={selectedColor === color}
onChange={() => setSelectedColor(color)}
className="sr-only"
/>
<label
htmlFor={`color-${index}`}
className={`w-8 h-8 rounded-full cursor-pointer flex items-center justify-center ${color} hover:ring-2 hover:ring-blue-300 transition-all`}
style={{ boxShadow: selectedColor === color ? '0 0 0 2px white, 0 0 0 4px #3b82f6' : 'none' }}
onClick={() => setSelectedColor(color)}
>
{selectedColor === color && (
<Check className="h-4 w-4 text-white" />
)}
</label>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
type="button"
className="bg-red-500 hover:bg-red-600 text-white"
onClick={() => {
setShowEditModal(false);
window.location.reload();
}}
>
Cancel
</Button>
<Button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white"
disabled={editLoading}
>
{editLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Save
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete Account Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={open => { if (!open) setShowDeleteDialog(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this account? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowDeleteDialog(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" disabled={deleteLoading} onClick={async () => {
if (!accountToDelete) return;
setDeleteLoading(true);
try {
const res = await fetch(`/api/courrier/account?accountId=${accountToDelete.id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete account');
toast({ title: 'Account deleted', description: 'The account was deleted successfully.' });
setShowDeleteDialog(false);
window.location.reload();
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete account', variant: 'destructive' });
} finally {
setDeleteLoading(false);
}
}}>Delete</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}