Neah/app/courrier/page.tsx
2025-04-26 20:09:09 +02:00

2403 lines
86 KiB
TypeScript

'use client';
import React from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
Mail, Search, Plus as PlusIcon, Trash, Check, Forward, FolderOpen,
MessageSquare, Copy, AlertOctagon, MoreHorizontal, ChevronDown,
ChevronUp, X, RefreshCw, Inbox, Send, Archive, Star, Settings,
Menu, Plus, Loader2, MailX, UserPlus, CheckCircle2, XCircle, Filter,
Reply, ReplyAll, AlertCircle, Folder, Eye, ChevronLeft, ChevronRight,
Paperclip, Trash2, CornerUpRight, Edit, MoreVertical, EyeOff, FileDown,
FilePlus, Ban, MailOpen
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command';
import { useSession } from 'next-auth/react';
import DOMPurify from 'isomorphic-dompurify';
import ComposeEmail from '@/components/email/ComposeEmail';
import { decodeEmail, cleanHtml } from '@/lib/mail-parser-wrapper';
import { Attachment as MailParserAttachment } from 'mailparser';
import { LoadingFix } from './loading-fix';
// Import centralized email formatters
import {
formatForwardedEmail,
formatReplyEmail,
formatEmailForReplyOrForward,
EmailMessage as FormatterEmailMessage,
cleanHtmlContent
} from '@/lib/utils/email-formatter';
export interface Account {
id: number;
name: string;
email: string;
color: string;
folders?: string[];
}
export interface Email {
id: string;
from: string;
fromName?: string;
to: string;
subject: string;
content: string;
preview?: string; // Preview content for list view
body?: string; // For backward compatibility
date: string;
read: boolean;
starred: boolean;
attachments?: { name: string; url: string }[];
folder: string;
cc?: string;
bcc?: string;
contentFetched?: boolean; // Track if full content has been fetched
}
interface Attachment {
name: string;
type: string;
content: string;
encoding: string;
}
interface ParsedEmailContent {
headers: string;
body: string;
html?: string;
text?: string;
attachments?: Array<{
filename: string;
content: string;
contentType: string;
}>;
}
interface ParsedEmailMetadata {
subject: string;
from: string;
to: string;
date: string;
contentType: string;
text: string | null;
html: string | null;
raw: {
headers: string;
body: string;
};
}
/**
* @deprecated This function is deprecated and will be removed in future versions.
* Email parsing has been centralized in lib/mail-parser-wrapper.ts and the API endpoint.
*/
function splitEmailHeadersAndBody(emailBody: string): { headers: string; body: string } {
const [headers, ...bodyParts] = emailBody.split('\r\n\r\n');
return {
headers: headers || '',
body: bodyParts.join('\r\n\r\n')
};
}
function EmailContent({ email }: { email: Email }) {
const [content, setContent] = useState<React.ReactNode>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [debugInfo, setDebugInfo] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function loadContent() {
if (!email) return;
setIsLoading(true);
setDebugInfo(null);
try {
console.log('Loading content for email:', email.id);
// Check if we need to fetch full content
if (!email.content || email.content.length === 0) {
console.log('Fetching full content for email:', email.id);
const response = await fetch(`/api/courrier/${email.id}?folder=${encodeURIComponent(email.folder || 'INBOX')}`);
if (!response.ok) {
throw new Error(`Failed to fetch email content: ${response.status}`);
}
const fullContent = await response.json();
if (mounted) {
// Update the email content with the fetched full content
email.content = fullContent.content;
// Render the content
const sanitizedHtml = DOMPurify.sanitize(fullContent.content);
setContent(
<div
className="email-content prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
setDebugInfo('Rendered fetched HTML content');
setError(null);
setIsLoading(false);
}
return;
}
// Use existing content if available
console.log('Using existing content for email');
const formattedEmail = email.content.trim();
if (!formattedEmail) {
console.log('Empty content for email:', email.id);
if (mounted) {
setContent(<div className="text-gray-500">Email content is empty</div>);
setDebugInfo('Email content is empty string');
setIsLoading(false);
}
return;
}
// Check if content is already HTML
if (formattedEmail.startsWith('<') && formattedEmail.endsWith('>')) {
// Content is likely HTML, sanitize and display directly
const sanitizedHtml = DOMPurify.sanitize(formattedEmail);
setContent(
<div
className="email-content prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
setDebugInfo('Rendered existing HTML content');
} else {
// Use mailparser for more complex formats
console.log('Parsing email content');
const parsedEmail = await decodeEmail(formattedEmail);
if (parsedEmail.html) {
const sanitizedHtml = DOMPurify.sanitize(parsedEmail.html);
setContent(
<div
className="email-content prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
setDebugInfo('Rendered HTML content from parser');
} else if (parsedEmail.text) {
setContent(
<div className="email-content whitespace-pre-wrap">
{parsedEmail.text}
</div>
);
setDebugInfo('Rendered text content from parser');
} else {
setContent(<div className="text-gray-500">No displayable content available</div>);
setDebugInfo('No HTML or text content in parsed email');
}
}
setError(null);
} catch (err) {
console.error('Error rendering email content:', err);
if (mounted) {
setError('Error rendering email content. Please try again.');
setDebugInfo(err instanceof Error ? err.message : 'Unknown error');
setContent(null);
}
} finally {
if (mounted) {
setIsLoading(false);
}
}
}
loadContent();
return () => {
mounted = false;
};
}, [email?.id, email?.content, email?.folder]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-500">Loading email content...</span>
</div>
);
}
if (error) {
return (
<div className="text-red-500 p-4">
<div>{error}</div>
{debugInfo && (
<div className="mt-2 text-xs text-gray-500">
Debug info: {debugInfo}
</div>
)}
</div>
);
}
return (
<>
{content || <div className="text-gray-500">No content available</div>}
{debugInfo && process.env.NODE_ENV !== 'production' && (
<div className="mt-4 p-2 border border-gray-200 rounded text-xs text-gray-500">
Debug: {debugInfo}
</div>
)}
</>
);
}
function renderEmailContent(email: Email) {
return <EmailContent email={email} />;
}
function renderAttachments(attachments: MailParserAttachment[]) {
if (!attachments.length) return null;
return (
<div className="mt-4 border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
<div className="space-y-2">
{attachments.map((attachment, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">{attachment.filename || 'unnamed_attachment'}</span>
<span className="text-xs text-gray-400">
{attachment.size ? `(${Math.round(attachment.size / 1024)} KB)` : ''}
</span>
</div>
))}
</div>
</div>
);
}
// Define the exact folder names from IMAP
type MailFolder = string;
// Map IMAP folders to sidebar items with icons
const getFolderIcon = (folder: string) => {
switch (folder.toLowerCase()) {
case 'inbox':
return Inbox;
case 'sent':
return Send;
case 'drafts':
return Reply;
case 'trash':
return Trash;
case 'spam':
return AlertCircle;
case 'archive':
case 'archives':
return Archive;
default:
return Folder;
}
};
// Update the initialSidebarItems to be empty since we're using folders under Accounts now
const initialSidebarItems: any[] = []; // Remove the default Inbox item
function formatDate(date: Date | null): string {
if (!date) return '';
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
/**
* @deprecated This function is deprecated and will be removed in future versions.
* Use the ReplyContent component directly instead.
*/
function getReplyBody(email: Email, type: 'reply' | 'reply-all' | 'forward' = 'reply') {
console.warn('getReplyBody is deprecated, use <ReplyContent email={email} type={type} /> instead');
return <ReplyContent email={email} type={type} />;
}
function ReplyContent({ email, type }: { email: Email; type: 'reply' | 'reply-all' | 'forward' }) {
const [content, setContent] = useState<string>('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
async function loadReplyContent() {
try {
if (!email.content) {
if (mounted) setContent('');
return;
}
// Create a formatter-compatible email object
const emailForFormatter: FormatterEmailMessage = {
id: email.id,
subject: email.subject || '',
from: [{
name: email.fromName || email.from.split('@')[0] || '',
address: email.from
}],
to: [{
name: '',
address: email.to
}],
date: new Date(email.date),
content: email.content,
html: email.content,
text: ''
};
// Use centralized formatters
let formattedContent = '';
if (type === 'forward') {
const formatted = formatForwardedEmail(emailForFormatter);
formattedContent = formatted.content;
} else {
const formatted = formatReplyEmail(emailForFormatter, type as 'reply' | 'reply-all');
formattedContent = formatted.content;
}
if (mounted) {
setContent(formattedContent);
setError(null);
}
} catch (err) {
console.error('Error generating reply body:', err);
if (mounted) {
setError('Error generating reply content. Please try again.');
setContent('');
}
}
}
loadReplyContent();
return () => {
mounted = false;
};
}, [email.content, type, email.id, email.subject, email.from, email.fromName, email.to, email.date]);
if (error) {
return <div className="text-red-500">{error}</div>;
}
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
function EmailPreview({ email }: { email: Email }) {
const [preview, setPreview] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
let mounted = true;
async function loadPreview() {
if (!email) {
if (mounted) setPreview('No content available');
return;
}
// If email already has a preview, use it directly
if (email.preview) {
if (mounted) setPreview(email.preview);
return;
}
setIsLoading(true);
try {
// If we have the content already, extract preview from it
if (email.content) {
const plainText = email.content.replace(/<[^>]*>/g, ' ').trim();
if (mounted) {
setPreview(plainText.substring(0, 150) + '...');
}
} else {
// Fallback to using parser for older emails
const decoded = await decodeEmail(email.content || '');
if (mounted) {
if (decoded.text) {
setPreview(decoded.text.substring(0, 150) + '...');
} else if (decoded.html) {
const cleanText = decoded.html.replace(/<[^>]*>/g, ' ').trim();
setPreview(cleanText.substring(0, 150) + '...');
} else {
setPreview('No preview available');
}
}
}
if (mounted) {
setError(null);
}
} catch (err) {
console.error('Error generating email preview:', err);
if (mounted) {
setError('Error generating preview');
setPreview('');
}
} finally {
if (mounted) setIsLoading(false);
}
}
loadPreview();
return () => {
mounted = false;
};
}, [email]);
if (isLoading) {
return <span className="text-gray-400">Loading preview...</span>;
}
if (error) {
return <span className="text-red-500 text-xs">{error}</span>;
}
return <span className="text-xs text-gray-500 line-clamp-2">{preview}</span>;
}
/**
* @deprecated This function is deprecated and will be removed in future versions.
* Use the EmailPreview component directly instead.
*/
function generateEmailPreview(email: Email) {
console.warn('generateEmailPreview is deprecated, use <EmailPreview email={email} /> instead');
return <EmailPreview email={email} />;
}
export default function CourrierPage() {
const router = useRouter();
const { data: session } = useSession();
const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState<Account[]>([
{ id: 0, name: 'All', email: '', color: 'bg-gray-400' },
{ id: 1, name: 'Mail Account', email: '', color: 'bg-blue-500' }
]);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [currentView, setCurrentView] = useState<MailFolder>('INBOX');
const [showCompose, setShowCompose] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [showBulkActions, setShowBulkActions] = useState(false);
const [showBcc, setShowBcc] = useState(false);
const [emails, setEmails] = useState<Email[]>([]);
const [error, setError] = useState<string | null>(null);
const [composeSubject, setComposeSubject] = useState('');
const [composeTo, setComposeTo] = useState('');
const [composeCc, setComposeCc] = useState('');
const [composeBcc, setComposeBcc] = useState('');
const [composeBody, setComposeBody] = useState('');
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [foldersOpen, setFoldersOpen] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [composeOpen, setComposeOpen] = useState(false);
const [accountsDropdownOpen, setAccountsDropdownOpen] = useState(true);
const [foldersDropdownOpen, setFoldersDropdownOpen] = useState(false);
const [showAccountActions, setShowAccountActions] = useState<number | null>(null);
const [showEmailActions, setShowEmailActions] = useState(false);
const [deleteType, setDeleteType] = useState<'email' | 'emails' | 'account'>('email');
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
const [showCc, setShowCc] = useState(false);
const [contentLoading, setContentLoading] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [availableFolders, setAvailableFolders] = useState<string[]>([]);
const [sidebarItems, setSidebarItems] = useState(initialSidebarItems);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isLoadingInitial, setIsLoadingInitial] = useState(true);
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
const [isLoadingCompose, setIsLoadingCompose] = useState(false);
const [isLoadingReply, setIsLoadingReply] = useState(false);
const [isLoadingForward, setIsLoadingForward] = useState(false);
const [isLoadingDelete, setIsLoadingDelete] = useState(false);
const [isLoadingMove, setIsLoadingMove] = useState(false);
const [isLoadingStar, setIsLoadingStar] = useState(false);
const [isLoadingUnstar, setIsLoadingUnstar] = useState(false);
const [isLoadingMarkRead, setIsLoadingMarkRead] = useState(false);
const [isLoadingMarkUnread, setIsLoadingMarkUnread] = useState(false);
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false);
const emailsPerPage = 20;
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Email[]>([]);
const [showSearchResults, setShowSearchResults] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const [composeEmail, setComposeEmail] = useState({
to: '',
subject: '',
body: '',
});
const [isSending, setIsSending] = useState(false);
const [isReplying, setIsReplying] = useState(false);
const [isForwarding, setIsForwarding] = useState(false);
const [replyToEmail, setReplyToEmail] = useState<Email | null>(null);
const [forwardEmail, setForwardEmail] = useState<Email | null>(null);
const [replyBody, setReplyBody] = useState('');
const [forwardBody, setForwardBody] = useState('');
const [replyAttachments, setReplyAttachments] = useState<File[]>([]);
const [forwardAttachments, setForwardAttachments] = useState<File[]>([]);
const [isSendingReply, setIsSendingReply] = useState(false);
const [isSendingForward, setIsSendingForward] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [isStarring, setIsStarring] = useState(false);
const [isUnstarring, setIsUnstarring] = useState(false);
const [isMarkingRead, setIsMarkingRead] = useState(false);
const [isMarkingUnread, setIsMarkingUnread] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const composeBodyRef = useRef<HTMLDivElement>(null);
const [originalEmail, setOriginalEmail] = useState<{
content: string;
type: 'reply' | 'reply-all' | 'forward';
} | null>(null);
// Debug logging for email distribution
useEffect(() => {
const emailsByFolder = emails.reduce((acc, email) => {
acc[email.folder] = (acc[email.folder] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log('Emails by folder:', emailsByFolder);
console.log('Current view:', currentView);
}, [emails, currentView]);
// Move getSelectedEmail inside the component
const getSelectedEmail = () => {
return emails.find(email => email.id === selectedEmail?.id);
};
// Add an effect to detect and fix stuck loading states
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
// If we have emails but loading state is still true after emails load, it's likely stuck
if (emails.length > 0 && (loading || isLoadingInitial)) {
console.log('[DEBUG] Detected potential stuck loading state, setting recovery timeout');
// Set a timeout to automatically reset the loading state
timeoutId = setTimeout(() => {
// Double check if still stuck
if (emails.length > 0 && (loading || isLoadingInitial)) {
console.log('[DEBUG] Confirmed stuck loading state, auto-resetting');
setLoading(false);
setIsLoadingInitial(false);
}
}, 3000); // 3 second timeout
}
// Cleanup
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [emails.length, loading, isLoadingInitial]);
// Single initialization effect that loads emails correctly on first page load
useEffect(() => {
const loadInitialData = async () => {
try {
console.log('[DEBUG] Starting initial email data loading...');
setLoading(true);
setIsLoadingInitial(true);
// Check credentials first
console.log('[DEBUG] Checking credentials...');
const credResponse = await fetch('/api/courrier');
if (!credResponse.ok) {
const errorData = await credResponse.json();
if (errorData.error === 'No stored credentials found') {
console.log('[DEBUG] No credentials found, redirecting to login');
router.push('/courrier/login');
return;
}
throw new Error(errorData.error || 'Failed to check credentials');
}
try {
// Try to get user email from credentials
const credsData = await credResponse.json();
console.log('[DEBUG] Credentials response:', {
hasEmail: !!credsData.email,
status: credResponse.status
});
if (credsData.email) {
console.log('[DEBUG] Got email from credentials:', credsData.email);
setAccounts(prev => prev.map(account =>
account.id === 1
? {
...account,
name: credsData.email,
email: credsData.email
}
: account
));
}
} catch (error) {
console.warn('[DEBUG] Error getting email from credentials:', error);
}
// First do a quick request just for folders
try {
console.log('[DEBUG] Preloading folders...');
const folderResponse = await fetch('/api/courrier?folder=INBOX&page=1&limit=1');
if (folderResponse.ok) {
const folderData = await folderResponse.json();
console.log('[DEBUG] Folder data:', {
folders: folderData.folders?.length || 0,
emails: folderData.emails?.length || 0
});
if (folderData.folders && folderData.folders.length > 0) {
setAvailableFolders(folderData.folders);
setAccounts(prev => prev.map(account =>
account.id === 1
? { ...account, folders: folderData.folders }
: account
));
}
}
} catch (error) {
console.warn('[DEBUG] Error preloading folders:', error);
}
// Then load emails (forced fetch with timestamp)
console.log('[DEBUG] Fetching emails...');
const timestamp = Date.now();
const emailResponse = await fetch(
`/api/courrier?folder=${encodeURIComponent(currentView)}&page=${page}&limit=${emailsPerPage}&_t=${timestamp}`,
{ cache: 'no-store' }
);
if (!emailResponse.ok) {
throw new Error('Failed to load emails');
}
const data = await emailResponse.json();
console.log(`[DEBUG] Loaded ${data.emails?.length || 0} emails, response status: ${emailResponse.status}`);
// Set available folders if present
if (data.folders) {
console.log('[DEBUG] Setting folders from initialization:', data.folders.length);
setAvailableFolders(data.folders);
// Update the mail account with folders
setAccounts(prev => {
console.log('[DEBUG] Updating accounts with folders');
return prev.map(account =>
account.id === 1
? { ...account, folders: data.folders }
: account
);
});
} else {
console.warn('[DEBUG] No folders returned from API during initialization');
}
// Process emails and sort by date
console.log('[DEBUG] Processing emails...');
const processedEmails = (data.emails || [])
.map((email: any) => {
// Add proper handling for from field which might be an array or object
let fromText = '';
let fromName = '';
let toText = '';
let ccText = '';
let bccText = '';
// Handle 'from' field
if (email.from) {
if (Array.isArray(email.from)) {
if (email.from.length > 0) {
if (typeof email.from[0] === 'object') {
fromText = email.from[0].address || '';
fromName = email.from[0].name || email.from[0].address?.split('@')[0] || '';
} else {
fromText = email.from[0] || '';
fromName = fromText.split('@')[0] || '';
}
}
}
else if (typeof email.from === 'object') {
fromText = email.from.address || '';
fromName = email.from.name || email.from.address?.split('@')[0] || '';
}
else if (typeof email.from === 'string') {
fromText = email.from;
fromName = email.fromName || email.from.split('@')[0] || '';
}
}
// Handle 'to' field
if (email.to) {
if (Array.isArray(email.to)) {
if (email.to.length > 0) {
if (typeof email.to[0] === 'object') {
toText = email.to.map((t: any) => t.address || '').join(', ');
} else {
toText = email.to.join(', ');
}
}
}
else if (typeof email.to === 'object') {
toText = email.to.address || '';
}
else if (typeof email.to === 'string') {
toText = email.to;
}
}
// Handle 'cc' field
if (email.cc) {
if (Array.isArray(email.cc)) {
if (email.cc.length > 0) {
if (typeof email.cc[0] === 'object') {
ccText = email.cc.map((c: any) => c.address || '').join(', ');
} else {
ccText = email.cc.join(', ');
}
}
}
else if (typeof email.cc === 'object') {
ccText = email.cc.address || '';
}
else if (typeof email.cc === 'string') {
ccText = email.cc;
}
}
// Handle 'bcc' field
if (email.bcc) {
if (Array.isArray(email.bcc)) {
if (email.bcc.length > 0) {
if (typeof email.bcc[0] === 'object') {
bccText = email.bcc.map((b: any) => b.address || '').join(', ');
} else {
bccText = email.bcc.join(', ');
}
}
}
else if (typeof email.bcc === 'object') {
bccText = email.bcc.address || '';
}
else if (typeof email.bcc === 'string') {
bccText = email.bcc;
}
}
return {
id: email.id,
accountId: 1,
from: fromText,
fromName: fromName,
to: toText,
subject: email.subject || '(No subject)',
content: email.content || '',
preview: email.preview || '',
date: email.date || new Date().toISOString(),
read: email.read || false,
starred: email.starred || false,
folder: email.folder || currentView,
cc: ccText,
bcc: bccText,
flags: email.flags || [],
hasAttachments: email.hasAttachments || false
};
})
.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Set emails
console.log('[DEBUG] Setting emails state with', processedEmails.length, 'emails');
setEmails(processedEmails);
// Update unread count for inbox
if (currentView === 'INBOX') {
const unreadInboxEmails = processedEmails.filter(
(email: Email) => !email.read && email.folder === 'INBOX'
).length;
setUnreadCount(unreadInboxEmails);
}
// Update pagination
setHasMore(data.hasMore);
setError(null);
console.log('[DEBUG] Initial load complete, setting loading states to false');
} catch (err) {
console.error('[DEBUG] Error loading initial data:', err);
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
console.log('[DEBUG] Setting loading state to false in finally block');
// Ensure we reset the loading state after a short delay to make sure React has processed state updates
setTimeout(() => {
setLoading(false);
setIsLoadingInitial(false);
}, 100);
}
};
loadInitialData();
}, [router, currentView, page, emailsPerPage]);
// Fix the type issue with the session email
useEffect(() => {
if (session?.user?.email) {
setAccounts(prev => prev.map(account =>
account.id === 1
? {
...account,
name: session.user.email || 'Mail Account',
email: session.user.email || ''
}
: account
));
}
}, [session?.user?.email]);
// Get account color
const getAccountColor = (accountId: number) => {
const account = accounts.find(acc => acc.id === accountId);
return account ? account.color : 'bg-gray-500';
};
// Update handleEmailSelect to handle the from field correctly
const handleEmailSelect = async (emailId: string) => {
try {
setContentLoading(true);
// Find the email in the current list
const selectedEmail = emails.find(email => email.id === emailId);
if (!selectedEmail) {
throw new Error('Email not found in list');
}
// Check if we need to fetch full content
if (!selectedEmail.content || selectedEmail.content.length === 0) {
console.log('[DEBUG] Fetching full content for email:', emailId);
try {
const response = await fetch(`/api/courrier/${emailId}?folder=${encodeURIComponent(selectedEmail.folder || 'INBOX')}`);
if (!response.ok) {
throw new Error(`Failed to fetch email content: ${response.status}`);
}
const fullContent = await response.json();
// Update the email content with the fetched full content
selectedEmail.content = fullContent.content;
selectedEmail.contentFetched = true;
// Update the email in the list too so we don't refetch
setEmails(prevEmails =>
prevEmails.map(email =>
email.id === emailId
? { ...email, content: fullContent.content, contentFetched: true }
: email
)
);
console.log('[DEBUG] Successfully fetched full content for email:', emailId);
} catch (error) {
console.error('[DEBUG] Error fetching full content:', error);
}
}
// Set selected email from our existing data (which now includes full content)
setSelectedEmail(selectedEmail);
// Try to mark as read in the background if not already read
if (!selectedEmail.read) {
try {
// Use the new API endpoint
await fetch(`/api/courrier/${emailId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'mark-read' }),
});
// Update read status in the list
setEmails(prevEmails =>
prevEmails.map(email =>
email.id === emailId
? { ...email, read: true }
: email
)
);
} catch (error) {
console.error('Error marking email as read:', error);
}
}
} catch (error) {
console.error('Error selecting email:', error);
setError('Failed to select email. Please try again.');
} finally {
setContentLoading(false);
}
};
// Add these improved handlers
const handleEmailCheckbox = (e: React.ChangeEvent<HTMLInputElement>, emailId: number) => {
e.stopPropagation();
if (e.target.checked) {
setSelectedEmails([...selectedEmails, emailId.toString()]);
} else {
setSelectedEmails(selectedEmails.filter(id => id !== emailId.toString()));
}
};
// Handles marking an individual email as read/unread
const handleMarkAsRead = (emailId: string, isRead: boolean) => {
setEmails(emails.map(email =>
email.id.toString() === emailId ? { ...email, read: isRead } : email
));
};
// Handles bulk actions for selected emails
const handleBulkAction = async (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => {
if (action === 'delete') {
setDeleteType('emails');
setShowDeleteConfirm(true);
return;
}
try {
const response = await fetch('/api/courrier/bulk-actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
emailIds: selectedEmails,
action: action
}),
});
if (!response.ok) {
throw new Error('Failed to perform bulk action');
}
// Update local state based on the action
setEmails(emails.map(email => {
if (selectedEmails.includes(email.id.toString())) {
switch (action) {
case 'mark-read':
return { ...email, read: true };
case 'mark-unread':
return { ...email, read: false };
case 'archive':
return { ...email, folder: 'Archive' };
default:
return email;
}
}
return email;
}));
// Clear selection after successful action
setSelectedEmails([]);
} catch (error) {
console.error('Error performing bulk action:', error);
alert('Failed to perform bulk action. Please try again.');
}
};
// Add handleDeleteConfirm function
const handleDeleteConfirm = async () => {
try {
const response = await fetch('/api/courrier/bulk-actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
emailIds: selectedEmails,
action: 'delete'
}),
});
if (!response.ok) {
throw new Error('Failed to delete emails');
}
// Remove deleted emails from state
setEmails(emails.filter(email => !selectedEmails.includes(email.id.toString())));
setSelectedEmails([]);
} catch (error) {
console.error('Error deleting emails:', error);
alert('Failed to delete emails. Please try again.');
} finally {
setShowDeleteConfirm(false);
}
};
// Add infinite scroll handler
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
if (
target.scrollHeight - target.scrollTop === target.clientHeight &&
!isLoadingMore &&
hasMore
) {
setPage(prev => prev + 1);
loadEmails(true);
}
}, [isLoadingMore, hasMore]);
// Sort emails by date (most recent first)
const sortedEmails = useMemo(() => {
return [...emails].sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
}, [emails]);
const toggleSelectAll = () => {
if (selectedEmails.length === emails.length) {
setSelectedEmails([]);
} else {
setSelectedEmails(emails.map(email => email.id.toString()));
}
};
// Update filtered emails to use sortedEmails
const filteredEmails = useMemo(() => {
if (!searchQuery) return sortedEmails;
const query = searchQuery.toLowerCase();
return sortedEmails.filter(email =>
email.subject.toLowerCase().includes(query) ||
email.from.toLowerCase().includes(query) ||
email.to.toLowerCase().includes(query) ||
email.content.toLowerCase().includes(query)
);
}, [sortedEmails, searchQuery]);
// Update the email list to use filtered emails
const renderEmailList = () => {
console.log('[DEBUG] Rendering email list with state:', {
loading,
isLoadingInitial,
emailCount: emails.length,
filteredEmailCount: filteredEmails.length,
searchQuery: searchQuery.length > 0 ? searchQuery : 'empty',
selectedEmails: selectedEmails.length
});
return (
<div className="w-[320px] bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col">
{renderEmailListHeader()}
{renderBulkActionsToolbar()}
<div
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{/* Always show emails when available, regardless of loading state */}
{filteredEmails.length > 0 ? (
<div className="divide-y divide-gray-100">
{filteredEmails.map((email) => renderEmailListItem(email))}
{(isLoadingMore || loading) && (
<div className="flex items-center justify-center p-4">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-blue-500 mr-2"></div>
<span className="text-xs text-gray-500">Refreshing...</span>
</div>
)}
</div>
) : isLoadingInitial || (loading && emails.length === 0) ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mb-4"></div>
<p className="text-sm text-gray-500">Loading emails...</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64">
<Mail className="h-12 w-12 text-gray-400 mb-3" />
<p className="text-gray-700 text-base font-medium mb-1">
{searchQuery ? 'No emails match your search' : 'No emails in this folder'}
</p>
{error && (
<p className="text-red-500 text-sm mt-2">{error}</p>
)}
<p className="text-gray-500 text-sm mt-3"><strong>Folder:</strong> {currentView}</p>
<p className="text-gray-500 text-sm mt-1"><strong>Total emails:</strong> {emails.length}</p>
<Button
variant="default"
size="sm"
className="mt-5 bg-blue-600 hover:bg-blue-700"
onClick={() => {
console.log('[DEBUG] Manual refresh clicked');
setLoading(true);
loadEmails();
}}
>
<RefreshCw className="h-4 w-4 mr-2" /> Refresh Folder
</Button>
</div>
)}
</div>
</div>
);
};
// Update the email count in the header to show filtered count
const renderEmailListHeader = () => (
<div className="border-b border-gray-100">
<div className="px-4 py-3 flex items-center justify-between">
<div className="relative flex-1">
<Search className="absolute left-2 top-2 h-4 w-4 text-gray-400" />
<Input
type="search"
placeholder="Search in folder..."
className="pl-8 h-8 bg-gray-50"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between px-4 h-10">
<div className="flex items-center gap-2">
<Checkbox
checked={filteredEmails.length > 0 && selectedEmails.length === filteredEmails.length}
onCheckedChange={toggleSelectAll}
className="mt-0.5"
/>
<h2 className="text-base font-semibold text-gray-900">
{currentView.charAt(0).toUpperCase() + currentView.slice(1).toLowerCase()}
</h2>
</div>
<span className="text-sm text-gray-600">
{searchQuery ? `${filteredEmails.length} of ${emails.length} emails` : `${emails.length} emails`}
</span>
</div>
</div>
);
// Update the bulk actions toolbar to include confirmation dialog
const renderBulkActionsToolbar = () => {
if (selectedEmails.length === 0) return null;
return (
<div className="bg-white border-b border-gray-100 px-4 py-2">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-gray-600">
{selectedEmails.length} selected
</span>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-900 h-8 px-2"
onClick={() => {
const allSelectedRead = selectedEmails.every(id =>
emails.find(email => email.id.toString() === id)?.read
);
handleBulkAction(allSelectedRead ? 'mark-unread' : 'mark-read');
}}
>
<Eye className="h-4 w-4 mr-1" />
<span className="text-sm">
{selectedEmails.every(id =>
emails.find(email => email.id.toString() === id)?.read
) ? 'Unread' : 'Read'}
</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-900 h-8 px-2"
onClick={() => handleBulkAction('archive')}
>
<Archive className="h-4 w-4 mr-1" />
<span className="text-sm">Archive</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 h-8 px-2"
onClick={() => handleBulkAction('delete')}
>
<Trash2 className="h-4 w-4 mr-1" />
<span className="text-sm">Delete</span>
</Button>
</div>
</div>
);
};
// Keep only one renderEmailListWrapper function that includes both panels
const renderEmailListWrapper = () => (
<div className="flex-1 flex overflow-hidden">
{/* Email list panel */}
{renderEmailList()}
{/* Preview panel - will automatically take remaining space */}
<div className="flex-1 bg-white/95 backdrop-blur-sm flex flex-col">
{selectedEmail ? (
<>
{/* Email actions header */}
<div className="flex-none px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedEmail(null)}
className="md:hidden flex-shrink-0"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="min-w-0 max-w-[500px]">
<h2 className="text-lg font-semibold text-gray-900 truncate">
{selectedEmail.subject}
</h2>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-auto">
<div className="flex items-center border-l border-gray-200 pl-4">
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={() => handleReply('reply')}
>
<Reply className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={() => handleReply('reply-all')}
>
<ReplyAll className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={() => handleReply('forward')}
>
<Forward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={(e) => toggleStarred(selectedEmail.id, e)}
>
<Star className={`h-4 w-4 ${selectedEmail.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={() => {/* Add to folder logic */}}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
{/* Scrollable content area */}
<ScrollArea className="flex-1 p-6">
{contentLoading ? (
<div className="flex flex-col items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mb-4"></div>
<p className="text-sm text-gray-500">Loading email content...</p>
</div>
) : (
<>
<div className="flex items-center gap-4 mb-6">
<Avatar className="h-10 w-10">
<AvatarFallback>
{selectedEmail.fromName?.charAt(0) || selectedEmail.from?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-medium text-gray-900">
{selectedEmail.fromName || 'Unknown'} {selectedEmail.from && <span className="text-gray-500">&lt;{selectedEmail.from}&gt;</span>}
</p>
<p className="text-sm text-gray-500">
to {selectedEmail.to || 'No recipients'}
</p>
{selectedEmail.cc && (
<p className="text-sm text-gray-500">
cc {selectedEmail.cc}
</p>
)}
</div>
<div className="text-sm text-gray-500 whitespace-nowrap">
{formatDate(new Date(selectedEmail.date))}
</div>
</div>
<div className="prose max-w-none">
{renderEmailContent(selectedEmail)}
</div>
</>
)}
</ScrollArea>
</>
) : (
<div className="flex flex-col items-center justify-center h-full">
<Mail className="h-12 w-12 text-gray-400 mb-4" />
<p className="text-gray-500">Select an email to view its contents</p>
</div>
)}
</div>
</div>
);
// Update the email list item to safely display the fromName
const renderEmailListItem = (email: Email) => (
<div
key={email.id}
className={`p-3 hover:bg-gray-50/50 transition-colors cursor-pointer flex items-start gap-3 ${
selectedEmail?.id === email.id ? 'bg-gray-50/80' : ''
}`}
onClick={() => handleEmailSelect(email.id)}
>
<div className="flex-none pt-1">
<Checkbox
checked={selectedEmails.includes(email.id)}
onCheckedChange={(checked) => toggleEmailSelection(email.id)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
</div>
<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">
<span className={`text-sm truncate ${!email.read ? 'font-semibold text-gray-900' : 'text-gray-600'}`}>
{email.fromName || email.from || 'Unknown'}
</span>
{!email.read && (
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full flex-shrink-0"></span>
)}
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{formatDate(new Date(email.date))}
</span>
</div>
<h3 className={`text-sm truncate mb-0.5 ${!email.read ? 'text-gray-900' : 'text-gray-600'}`}>
{email.subject || '(No subject)'}
</h3>
<EmailPreview email={email} />
</div>
<div className="flex-none flex items-center gap-1">
{email.starred && (
<Star className="h-4 w-4 text-yellow-400 fill-yellow-400" />
)}
{email.attachments && email.attachments.length > 0 && (
<Paperclip className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
);
const handleMailboxChange = async (newMailbox: string) => {
setCurrentView(newMailbox);
setSelectedEmails([]);
setSearchQuery('');
setEmails([]);
setLoading(true);
setError(null);
setHasMore(true);
setPage(1);
try {
// Optimize the request by adding a timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch(`/api/courrier?folder=${encodeURIComponent(newMailbox)}&page=1&limit=${emailsPerPage}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('Failed to fetch emails');
}
const data = await response.json();
// Process emails more efficiently
const processedEmails = data.emails.map((email: any) => ({
id: Number(email.id),
accountId: 1,
from: email.from || '',
fromName: email.fromName || email.from?.split('@')[0] || '',
to: email.to || '',
subject: email.subject || '(No subject)',
body: email.body || '',
date: email.date || new Date().toISOString(),
read: email.read || false,
starred: email.starred || false,
folder: email.folder || newMailbox,
cc: email.cc || '',
bcc: email.bcc || '',
flags: email.flags || [],
raw: email.body || ''
}));
setEmails(processedEmails);
setHasMore(processedEmails.length === emailsPerPage);
// If folders are returned, update them
if (data.folders && data.folders.length > 0) {
setAvailableFolders(data.folders);
// Update the mail account with folders
setAccounts(prev => prev.map(account =>
account.id === 1
? { ...account, folders: data.folders }
: account
));
}
// Only update unread count if we're in the Inbox folder
if (newMailbox === 'INBOX') {
const unreadInboxEmails = processedEmails.filter(
(email: Email) => !email.read && email.folder === 'INBOX'
).length;
setUnreadCount(unreadInboxEmails);
}
} catch (error) {
console.error('Error fetching emails:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch emails');
} finally {
setLoading(false);
}
};
// Update the renderSidebarNav function to handle empty items or display other navigation options
const renderSidebarNav = () => (
<nav className="p-3">
{sidebarItems.length > 0 ? (
<ul className="space-y-0.5 px-2">
{sidebarItems.map((item) => (
<li key={item.view}>
<Button
variant={currentView === item.view ? 'secondary' : 'ghost'}
className={`w-full justify-start py-2 ${
currentView === item.view ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => {
setCurrentView(item.view);
setSelectedEmail(null);
}}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<item.icon className="h-4 w-4 mr-2" />
<span>{item.label}</span>
</div>
{item.view === 'INBOX' && unreadCount > 0 && (
<span className="ml-auto bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{unreadCount}
</span>
)}
</div>
</Button>
</li>
))}
</ul>
) : (
// If needed, you can add other navigation items here that aren't folders
<div className="px-2 py-2">
{/* This section is empty since we're now using folders under accounts */}
</div>
)}
</nav>
);
// Update handleReply to include body property for backward compatibility
const handleReply = async (type: 'reply' | 'reply-all' | 'forward') => {
if (!selectedEmail) return;
try {
// If content hasn't been loaded yet, fetch it
if (!selectedEmail.contentFetched) {
console.log('[DEBUG] Fetching email content for reply:', selectedEmail.id);
// Use the API route instead of directly calling getEmailContent
const response = await fetch(`/api/courrier/${selectedEmail.id}?folder=${encodeURIComponent(selectedEmail.folder || 'INBOX')}`);
if (!response.ok) {
throw new Error(`Failed to fetch email content: ${response.status}`);
}
const content = await response.json();
if (content) {
// Update the selected email with content
const updatedEmail = {
...selectedEmail,
content: content.content || content.html || content.text || '',
html: content.html || '',
text: content.text || '',
contentFetched: true,
// Add proper from/to/cc format for client-side formatters
from: typeof content.from === 'string' ? content.from : content.from?.[0]?.address || '',
fromName: typeof content.from === 'string' ? '' : content.from?.[0]?.name || '',
to: typeof content.to === 'string' ? content.to : content.to?.[0]?.address || '',
cc: typeof content.cc === 'string' ? content.cc : content.cc?.[0]?.address || '',
bcc: typeof content.bcc === 'string' ? content.bcc : content.bcc?.[0]?.address || '',
date: typeof content.date === 'string' ? content.date : (content.date ? content.date.toString() : '')
};
setSelectedEmail(updatedEmail);
// Format content directly here
formatEmailAndShowCompose(updatedEmail, type);
}
} else {
// Content already loaded, format directly
formatEmailAndShowCompose(selectedEmail, type);
}
} catch (error) {
console.error('[DEBUG] Error preparing email for reply/forward:', error);
}
};
// New helper function to directly format email content
const formatEmailAndShowCompose = (email: Email, type: 'reply' | 'reply-all' | 'forward') => {
// Create an EmailMessage compatible object for the ComposeEmail component
const emailForCompose: FormatterEmailMessage = {
id: email.id,
messageId: '',
subject: email.subject,
from: [{
name: email.fromName || email.from.split('@')[0] || '',
address: email.from
}],
to: [{
name: '',
address: email.to
}],
date: new Date(email.date),
content: email.content,
html: email.content,
text: '',
hasAttachments: email.attachments ? email.attachments.length > 0 : false,
folder: email.folder
};
// Use centralized formatters to ensure consistent formatting
let formattedContent = '';
let formattedSubject = '';
let formattedTo = '';
if (type === 'reply' || type === 'reply-all') {
const formatted = formatReplyEmail(emailForCompose, type);
formattedContent = formatted.content;
formattedSubject = formatted.subject;
formattedTo = formatted.to;
} else if (type === 'forward') {
const formatted = formatForwardedEmail(emailForCompose);
formattedContent = formatted.content;
formattedSubject = formatted.subject;
}
// Set state for compose form
setIsReplying(true);
setIsForwarding(type === 'forward');
// Set original email content for LegacyAdapter
setOriginalEmail({
content: formattedContent,
type: type
});
// Show compose form with formatted content
setShowCompose(true);
if (type === 'reply' || type === 'reply-all') {
setComposeTo(formattedTo);
setComposeSubject(formattedSubject);
setComposeBody(formattedContent);
} else if (type === 'forward') {
setComposeTo('');
setComposeSubject(formattedSubject);
setComposeBody(formattedContent);
}
};
// Update toggleStarred to use string IDs
const toggleStarred = async (emailId: string, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
const email = emails.find(e => e.id === emailId);
if (!email) return;
try {
const response = await fetch('/api/courrier/toggle-star', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ emailId, starred: !email.starred }),
});
if (!response.ok) {
throw new Error('Failed to toggle star');
}
// Update email in state
setEmails(emails.map(e =>
e.id === emailId ? { ...e, starred: !e.starred } : e
));
} catch (error) {
console.error('Error toggling star:', error);
}
};
// Add back the handleSend function
const handleSend = async () => {
if (!composeTo) {
alert('Please specify at least one recipient');
return;
}
try {
const response = await fetch('/api/courrier/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: composeTo,
cc: composeCc,
bcc: composeBcc,
subject: composeSubject,
body: composeBody,
attachments: attachments,
}),
});
const data = await response.json();
if (!response.ok) {
if (data.error === 'Attachment size limit exceeded') {
alert(`Error: ${data.error}\nThe following files are too large:\n${data.details.oversizedFiles.join('\n')}`);
} else {
alert(`Error sending email: ${data.error}`);
}
return;
}
// Clear compose form and close modal
setComposeTo('');
setComposeCc('');
setComposeBcc('');
setComposeSubject('');
setComposeBody('');
setAttachments([]);
setShowCompose(false);
} catch (error) {
console.error('Error sending email:', error);
alert('Failed to send email. Please try again.');
}
};
// Add back the renderDeleteConfirmDialog function
const renderDeleteConfirmDialog = () => (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Emails</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {selectedEmails.length} selected email{selectedEmails.length > 1 ? 's' : ''}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const toggleEmailSelection = (emailId: string) => {
setSelectedEmails((prev) =>
prev.includes(emailId)
? prev.filter((id) => id !== emailId)
: [...prev, emailId]
);
};
const searchEmails = (query: string) => {
setSearchQuery(query.trim());
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setSearchQuery(query);
};
const renderEmailPreview = (email: Email) => {
if (!email) return null;
return (
<div className="p-4">
<h2 className="text-lg font-semibold mb-2">{email.subject}</h2>
<div className="text-sm text-gray-600">
{renderEmailContent(email)}
</div>
</div>
);
};
// Update loadEmails to store the full content from the API response
const loadEmails = async (isLoadMore = false) => {
try {
// Skip if already loading
if (isLoadingInitial || isLoadingMore) {
console.log('[DEBUG] Skipping loadEmails - already loading');
return;
}
console.log(`[DEBUG] Loading emails for folder: ${currentView}, page: ${page}, isLoadMore: ${isLoadMore}`);
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setLoading(true);
}
// Fetch emails with timestamp for cache busting
const timestamp = Date.now();
try {
const response = await fetch(
`/api/courrier?folder=${encodeURIComponent(currentView)}&page=${page}&limit=${emailsPerPage}&_t=${timestamp}`,
{ cache: 'no-store' }
);
if (!response.ok) {
console.error('[DEBUG] API response error:', response.status, response.statusText);
throw new Error('Failed to load emails');
}
const data = await response.json();
console.log('[DEBUG] API response:', {
emailCount: data.emails?.length || 0,
folderCount: data.folders?.length || 0,
hasMore: data.hasMore,
total: data.total
});
// Set available folders
if (data.folders) {
console.log('[DEBUG] Setting available folders:', data.folders.length);
setAvailableFolders(data.folders);
// Update the mail account with folders
setAccounts(prev => prev.map(account =>
account.id === 1
? { ...account, folders: data.folders }
: account
));
} else {
console.warn('[DEBUG] No folders returned from API');
}
// Process and sort emails
const processedEmails = (data.emails || [])
.map((email: any) => {
// Add proper handling for from field which might be an array or object
let fromText = '';
let fromName = '';
let toText = '';
let ccText = '';
let bccText = '';
// Handle 'from' field
if (email.from) {
if (Array.isArray(email.from)) {
if (email.from.length > 0) {
if (typeof email.from[0] === 'object') {
fromText = email.from[0].address || '';
fromName = email.from[0].name || email.from[0].address?.split('@')[0] || '';
} else {
fromText = email.from[0] || '';
fromName = fromText.split('@')[0] || '';
}
}
}
else if (typeof email.from === 'object') {
fromText = email.from.address || '';
fromName = email.from.name || email.from.address?.split('@')[0] || '';
}
else if (typeof email.from === 'string') {
fromText = email.from;
fromName = email.fromName || email.from.split('@')[0] || '';
}
}
// Handle 'to' field
if (email.to) {
if (Array.isArray(email.to)) {
if (email.to.length > 0) {
if (typeof email.to[0] === 'object') {
toText = email.to.map((t: any) => t.address || '').join(', ');
} else {
toText = email.to.join(', ');
}
}
}
else if (typeof email.to === 'object') {
toText = email.to.address || '';
}
else if (typeof email.to === 'string') {
toText = email.to;
}
}
// Handle 'cc' field
if (email.cc) {
if (Array.isArray(email.cc)) {
if (email.cc.length > 0) {
if (typeof email.cc[0] === 'object') {
ccText = email.cc.map((c: any) => c.address || '').join(', ');
} else {
ccText = email.cc.join(', ');
}
}
}
else if (typeof email.cc === 'object') {
ccText = email.cc.address || '';
}
else if (typeof email.cc === 'string') {
ccText = email.cc;
}
}
// Handle 'bcc' field
if (email.bcc) {
if (Array.isArray(email.bcc)) {
if (email.bcc.length > 0) {
if (typeof email.bcc[0] === 'object') {
bccText = email.bcc.map((b: any) => b.address || '').join(', ');
} else {
bccText = email.bcc.join(', ');
}
}
}
else if (typeof email.bcc === 'object') {
bccText = email.bcc.address || '';
}
else if (typeof email.bcc === 'string') {
bccText = email.bcc;
}
}
return {
id: email.id,
accountId: 1,
from: fromText,
fromName: fromName,
to: toText,
subject: email.subject || '(No subject)',
content: email.content || '',
preview: email.preview || '',
date: email.date || new Date().toISOString(),
read: email.read || false,
starred: email.starred || false,
folder: email.folder || currentView,
cc: ccText,
bcc: bccText,
flags: email.flags || [],
hasAttachments: email.hasAttachments || false
};
})
.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Set emails appropriately
console.log('[DEBUG] Setting emails state with', processedEmails.length, 'emails');
setEmails(prev => {
if (isLoadMore) {
// Filter out duplicates when appending
const existingIds = new Set(prev.map(email => email.id));
const uniqueNewEmails = processedEmails.filter((email: Email) => !existingIds.has(email.id));
return [...prev, ...uniqueNewEmails];
} else {
return processedEmails;
}
});
// Update unread count
if (currentView === 'INBOX') {
const unreadInboxEmails = processedEmails.filter(
(email: Email) => !email.read && email.folder === 'INBOX'
).length;
setUnreadCount(unreadInboxEmails);
}
setHasMore(data.hasMore);
setError(null);
} catch (err) {
console.error('[DEBUG] Error in fetch emails:', err);
setError('Failed to load emails');
throw err; // Rethrow to ensure the finally block still runs
}
} catch (err) {
console.error('[DEBUG] Error loading emails:', err);
setError('Failed to load emails');
} finally {
console.log('[DEBUG] Setting loading states to false in loadEmails finally block');
// Ensure we reset the loading state after a short delay to make sure React has processed state updates
setTimeout(() => {
setLoading(false);
setIsLoadingMore(false);
setIsLoadingInitial(false);
}, 100);
}
};
// Add back the view change effect
useEffect(() => {
setPage(1); // Reset page when view changes
setHasMore(true);
loadEmails();
}, [currentView]);
// Create a function to load folders that will be available throughout the component
const loadFolders = async () => {
console.log('[DEBUG] Explicitly loading folders from standalone function...');
try {
// Make a specific request just to get folders
const timestamp = Date.now(); // Cache busting
const response = await fetch(`/api/courrier?folder=INBOX&page=1&limit=1&skipCache=true&_t=${timestamp}`, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
});
if (response.ok) {
const data = await response.json();
console.log('[DEBUG] Folder response data:', JSON.stringify(data, null, 2));
// Check for mailboxes field (from IMAP) and use it if available
const folders = data.mailboxes || data.folders || [];
if (folders && folders.length > 0) {
console.log('[DEBUG] Successfully loaded folders:', folders);
setAvailableFolders(folders);
// Update the mail account with folders
setAccounts(prev => {
console.log('[DEBUG] Updating account with folders:', folders);
return prev.map(account =>
account.id === 1
? { ...account, folders: folders }
: account
);
});
} else {
console.warn('[DEBUG] No folders found in response. Response data:', data);
}
} else {
console.error('[DEBUG] Folder request failed:', response.status);
}
} catch (error) {
console.error('[DEBUG] Error explicitly loading folders:', error);
}
};
// Improve the folder loading logic with a delay and better reliability
useEffect(() => {
let isMounted = true; // For cleanup
// Only load if we don't have folders yet and we're not already loading
if ((!accounts[1]?.folders || accounts[1]?.folders?.length === 0) && !loading) {
console.log('[DEBUG] Triggering folder load in useEffect...');
// Set a small delay to ensure other loading operations have completed
setTimeout(() => {
if (!isMounted) return;
loadFolders();
}, 500); // 500ms delay
}
return () => {
isMounted = false;
};
}, [accounts, loading]);
// Add a new function to fetch the IMAP credentials email
const fetchImapCredentials = async () => {
try {
console.log("[DEBUG] Fetching IMAP credentials...");
const response = await fetch("/api/courrier/credentials", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache"
},
});
console.log("[DEBUG] IMAP credentials response status:", response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error("[DEBUG] Error fetching IMAP credentials:", response.status, errorData);
throw new Error(`Failed to fetch IMAP credentials: ${response.status}`);
}
const data = await response.json();
console.log("[DEBUG] IMAP credentials data:", data);
if (data && data.credentials && data.credentials.email) {
console.log("[DEBUG] Setting account with IMAP email:", data.credentials.email);
setAccounts(prev => prev.map(account =>
account.id === 1
? {
...account,
name: data.credentials.email,
email: data.credentials.email
}
: account
));
// After setting the account email, explicitly load folders
setTimeout(() => {
console.log("[DEBUG] Triggering folder load after setting account");
loadFolders();
}, 1000);
} else {
console.log("[DEBUG] No valid IMAP credentials found in response:", data);
}
} catch (error) {
console.error("[DEBUG] Error in fetchImapCredentials:", error);
}
};
// Call it once on component mount
useEffect(() => {
fetchImapCredentials();
}, []);
// Add ref for tracking component mount status
const isComponentMounted = useRef(true);
// Add a cleanup effect to reset states on unmount/remount
useEffect(() => {
// Reset isComponentMounted value on mount
isComponentMounted.current = true;
// Reset loading states when component mounts
const timeoutId = setTimeout(() => {
if (isComponentMounted.current && (loading || isLoadingInitial)) {
console.log('[DEBUG] Reset loading states on mount');
setLoading(false);
setIsLoadingInitial(false);
}
}, 5000); // Longer timeout to avoid race conditions
// Return cleanup function
return () => {
console.log('[DEBUG] Component unmounting, clearing states');
isComponentMounted.current = false;
clearTimeout(timeoutId);
// No need to set states during unmount as component is gone
};
}, []); // Empty dependency array
if (error) {
return (
<div className="flex h-[calc(100vh-theme(spacing.12))] items-center justify-center bg-gray-100 mt-12">
<div className="text-center max-w-md mx-auto px-4">
<Mail className="h-12 w-12 mb-4 text-red-400 mx-auto" />
<p className="text-red-500 mb-4">{error}</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="mx-auto"
>
Try Again
</Button>
</div>
</div>
);
}
if (isLoadingInitial && !emails.length) {
return (
<div className="flex h-[calc(100vh-theme(spacing.12))] items-center justify-center bg-gray-100 mt-12">
<div className="text-center">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-blue-500 mb-4"></div>
<p className="text-gray-700">Loading your emails...</p>
</div>
</div>
</div>
);
}
return (
<>
{/* 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">
{/* Sidebar */}
<div className={`${sidebarOpen ? 'w-60' : 'w-16'} bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col transition-all duration-300 ease-in-out
${mobileSidebarOpen ? 'fixed inset-y-0 left-0 z-40' : 'hidden'} md:block`}>
{/* Courrier Title */}
<div className="p-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<Mail className="h-6 w-6 text-gray-600" />
<span className="text-xl font-semibold text-gray-900">COURRIER</span>
</div>
</div>
{/* Compose button and refresh button */}
<div className="p-2 border-b border-gray-100 flex items-center gap-2">
<Button
className="flex-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center transition-all py-1.5 text-sm"
onClick={() => {
setShowCompose(true);
setComposeTo('');
setComposeCc('');
setComposeBcc('');
setComposeSubject('');
setComposeBody('');
setShowCc(false);
setShowBcc(false);
}}
>
<div className="flex items-center gap-2">
<PlusIcon className="h-3.5 w-3.5" />
<span>Compose</span>
</div>
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-gray-400 hover:text-gray-600"
onClick={() => {
setLoading(true);
loadEmails();
}}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Accounts Section */}
<div className="p-3 border-b border-gray-100">
<Button
variant="ghost"
className="w-full justify-between mb-2 text-sm font-medium text-gray-500"
onClick={() => setAccountsDropdownOpen(!accountsDropdownOpen)}
>
<span>Accounts</span>
{accountsDropdownOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
{accountsDropdownOpen && (
<div className="space-y-1 pl-2">
{accounts.map(account => (
<div key={account.id} className="relative group">
<Button
variant="ghost"
className="w-full justify-between px-2 py-1.5 text-sm group"
onClick={() => setSelectedAccount(account)}
>
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${account.color}`}></div>
<span className="font-medium text-gray-700">{account.name}</span>
</div>
</Button>
{/* Show folders for email accounts (not for "All" account) without the "Folders" header */}
{account.id !== 0 && (
<div className="pl-4 mt-1 mb-2 space-y-0.5 border-l border-gray-200">
{account.folders && account.folders.length > 0 ? (
account.folders.map((folder) => (
<Button
key={folder}
variant="ghost"
className={`w-full justify-start py-1 px-2 text-xs ${
currentView === folder ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={(e) => {
e.stopPropagation();
handleMailboxChange(folder);
}}
>
<div className="flex items-center justify-between w-full gap-1.5">
<div className="flex items-center gap-1.5">
{React.createElement(getFolderIcon(folder), { className: "h-3.5 w-3.5" })}
<span className="truncate">{folder}</span>
</div>
{folder === 'INBOX' && unreadCount > 0 && (
<span className="ml-auto bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full text-[10px]">
{unreadCount}
</span>
)}
</div>
</Button>
))
) : (
<div className="px-2 py-2">
<div className="flex flex-col space-y-2">
{/* Create placeholder folder items with shimmer effect */}
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex items-center gap-1.5 animate-pulse">
<div className="h-3.5 w-3.5 bg-gray-200 rounded-sm"></div>
<div className="h-3 w-24 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Navigation */}
{renderSidebarNav()}
</div>
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{/* Email list panel */}
{renderEmailListWrapper()}
</div>
</div>
</div>
</main>
{/* Compose Email Modal */}
<ComposeEmail
showCompose={showCompose}
setShowCompose={setShowCompose}
composeTo={composeTo}
setComposeTo={setComposeTo}
composeCc={composeCc}
setComposeCc={setComposeCc}
composeBcc={composeBcc}
setComposeBcc={setComposeBcc}
composeSubject={composeSubject}
setComposeSubject={setComposeSubject}
composeBody={composeBody}
setComposeBody={setComposeBody}
showCc={showCc}
setShowCc={setShowCc}
showBcc={showBcc}
setShowBcc={setShowBcc}
attachments={attachments}
setAttachments={setAttachments}
handleSend={handleSend}
onSend={async (emailData) => {
console.log('Email sent:', emailData);
setShowCompose(false);
setIsReplying(false);
setIsForwarding(false);
}}
onCancel={() => {
setShowCompose(false);
setComposeTo('');
setComposeCc('');
setComposeBcc('');
setComposeSubject('');
setComposeBody('');
setShowCc(false);
setShowBcc(false);
setAttachments([]);
setIsReplying(false);
setIsForwarding(false);
}}
/>
{renderDeleteConfirmDialog()}
{/* Debug tools - only shown in development mode */}
{process.env.NODE_ENV !== 'production' && (
<LoadingFix
loading={loading}
isLoadingInitial={isLoadingInitial}
setLoading={setLoading}
setIsLoadingInitial={setIsLoadingInitial}
setEmails={setEmails}
loadEmails={loadEmails}
emails={emails}
/>
)}
</>
);
}