diff --git a/.DS_Store b/.DS_Store
index 6ddb0d00..c3c20e74 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/app/.DS_Store b/app/.DS_Store
new file mode 100644
index 00000000..1a958b34
Binary files /dev/null and b/app/.DS_Store differ
diff --git a/app/courrier/login/page.tsx b/app/courrier/login/page.tsx
new file mode 100644
index 00000000..3782f9bd
--- /dev/null
+++ b/app/courrier/login/page.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+export default function MailLoginPage() {
+ const router = useRouter();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [host, setHost] = useState('mail.infomaniak.com');
+ const [port, setPort] = useState('993');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const response = await fetch('/api/mail/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email,
+ password,
+ host,
+ port,
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Failed to connect to email server');
+ }
+
+ // Redirect to mail page
+ router.push('/mail');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Email Configuration
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/courrier/page.tsx b/app/courrier/page.tsx
new file mode 100644
index 00000000..d8b47b3b
--- /dev/null
+++ b/app/courrier/page.tsx
@@ -0,0 +1,1879 @@
+'use client';
+
+import { useEffect, useState, useMemo, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Avatar, AvatarFallback } from '@/components/ui/avatar';
+import {
+ MoreVertical, Settings, Plus as PlusIcon, Trash2, Edit, Mail,
+ Inbox, Send, Star, Trash, Plus, ChevronLeft, ChevronRight,
+ Search, ChevronDown, Folder, ChevronUp, Reply, Forward, ReplyAll,
+ MoreHorizontal, FolderOpen, X, Paperclip, MessageSquare, Copy, EyeOff,
+ AlertOctagon, Archive, RefreshCw
+} from 'lucide-react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+interface Account {
+ id: number;
+ name: string;
+ email: string;
+ color: string;
+ folders?: string[];
+}
+
+interface Email {
+ id: number;
+ accountId: number;
+ from: string;
+ fromName?: string;
+ to: string;
+ subject: string;
+ body: string;
+ date: string;
+ read: boolean;
+ starred: boolean;
+ folder: string;
+ cc?: string;
+ bcc?: string;
+ flags?: string[];
+}
+
+interface Attachment {
+ name: string;
+ type: string;
+ content: string;
+ encoding: string;
+}
+
+// Improved MIME Decoder Implementation for Infomaniak
+function extractBoundary(headers: string): string | null {
+ const boundaryMatch = headers.match(/boundary="?([^"\r\n;]+)"?/i) ||
+ headers.match(/boundary=([^\r\n;]+)/i);
+
+ return boundaryMatch ? boundaryMatch[1].trim() : null;
+}
+
+function decodeQuotedPrintable(text: string, charset: string): string {
+ if (!text) return '';
+
+ // Replace soft line breaks (=\r\n or =\n or =\r)
+ let decoded = text.replace(/=(?:\r\n|\n|\r)/g, '');
+
+ // Replace quoted-printable encoded characters (including non-ASCII characters)
+ decoded = decoded.replace(/=([0-9A-F]{2})/gi, (match, p1) => {
+ return String.fromCharCode(parseInt(p1, 16));
+ });
+
+ // Handle character encoding
+ try {
+ // For browsers with TextDecoder support
+ if (typeof TextDecoder !== 'undefined') {
+ // Convert string to array of byte values
+ const bytes = new Uint8Array(Array.from(decoded).map(c => c.charCodeAt(0)));
+ return new TextDecoder(charset).decode(bytes);
+ }
+
+ // Fallback for older browsers or when charset handling is not critical
+ return decoded;
+ } catch (e) {
+ console.warn('Charset conversion error:', e);
+ return decoded;
+ }
+}
+
+function parseFullEmail(emailRaw: string) {
+ // Check if this is a multipart message by looking for boundary definition
+ const boundaryMatch = emailRaw.match(/boundary="?([^"\r\n;]+)"?/i) ||
+ emailRaw.match(/boundary=([^\r\n;]+)/i);
+
+ if (boundaryMatch) {
+ const boundary = boundaryMatch[1].trim();
+
+ // Check if there's a preamble before the first boundary
+ let mainHeaders = '';
+ let mainContent = emailRaw;
+
+ // Extract the headers before the first boundary if they exist
+ const firstBoundaryPos = emailRaw.indexOf('--' + boundary);
+ if (firstBoundaryPos > 0) {
+ const headerSeparatorPos = emailRaw.indexOf('\r\n\r\n');
+ if (headerSeparatorPos > 0 && headerSeparatorPos < firstBoundaryPos) {
+ mainHeaders = emailRaw.substring(0, headerSeparatorPos);
+ }
+ }
+
+ return processMultipartEmail(emailRaw, boundary, mainHeaders);
+ } else {
+ // This is a single part message
+ return processSinglePartEmail(emailRaw);
+ }
+}
+
+function processMultipartEmail(emailRaw: string, boundary: string, mainHeaders: string = ''): {
+ text: string;
+ html: string;
+ attachments: { filename: string; contentType: string; encoding: string; content: string; }[];
+ headers?: string;
+} {
+ const result = {
+ text: '',
+ html: '',
+ attachments: [] as { filename: string; contentType: string; encoding: string; content: string; }[],
+ headers: mainHeaders
+ };
+
+ // Split by boundary (more robust pattern)
+ const boundaryRegex = new RegExp(`--${boundary}(?:--)?(\\r?\\n|$)`, 'g');
+
+ // Get all boundary positions
+ const matches = Array.from(emailRaw.matchAll(boundaryRegex));
+ const boundaryPositions = matches.map(match => match.index!);
+
+ // Extract content between boundaries
+ for (let i = 0; i < boundaryPositions.length - 1; i++) {
+ const startPos = boundaryPositions[i] + matches[i][0].length;
+ const endPos = boundaryPositions[i + 1];
+
+ if (endPos > startPos) {
+ const partContent = emailRaw.substring(startPos, endPos).trim();
+
+ if (partContent) {
+ const decoded = processSinglePartEmail(partContent);
+
+ if (decoded.contentType.includes('text/plain')) {
+ result.text = decoded.text || '';
+ } else if (decoded.contentType.includes('text/html')) {
+ result.html = cleanHtml(decoded.html || '');
+ } else if (
+ decoded.contentType.startsWith('image/') ||
+ decoded.contentType.startsWith('application/')
+ ) {
+ const filename = extractFilename(partContent);
+ result.attachments.push({
+ filename,
+ contentType: decoded.contentType,
+ encoding: decoded.raw?.headers ? parseEmailHeaders(decoded.raw.headers).encoding : '7bit',
+ content: decoded.raw?.body || ''
+ });
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+function processSinglePartEmail(rawEmail: string) {
+ // Split headers and body
+ const headerBodySplit = rawEmail.split(/\r?\n\r?\n/);
+ const headers = headerBodySplit[0];
+ const body = headerBodySplit.slice(1).join('\n\n');
+
+ // Parse headers to get content type, encoding, etc.
+ const emailInfo = parseEmailHeaders(headers);
+
+ // Decode the body based on its encoding
+ const decodedBody = decodeMIME(body, emailInfo.encoding, emailInfo.charset);
+
+ return {
+ subject: extractHeader(headers, 'Subject'),
+ from: extractHeader(headers, 'From'),
+ to: extractHeader(headers, 'To'),
+ date: extractHeader(headers, 'Date'),
+ contentType: emailInfo.contentType,
+ text: emailInfo.contentType.includes('html') ? null : decodedBody,
+ html: emailInfo.contentType.includes('html') ? decodedBody : null,
+ raw: {
+ headers,
+ body
+ }
+ };
+}
+
+function extractHeader(headers: string, headerName: string): string {
+ const regex = new RegExp(`^${headerName}:\\s*(.+?)(?:\\r?\\n(?!\\s)|$)`, 'im');
+ const match = headers.match(regex);
+ return match ? match[1].trim() : '';
+}
+
+function extractFilename(headers: string): string {
+ const filenameMatch = headers.match(/filename="?([^"\r\n;]+)"?/i);
+ return filenameMatch ? filenameMatch[1].trim() : 'attachment';
+}
+
+function parseEmailHeaders(headers: string): { contentType: string; encoding: string; charset: string } {
+ const result = {
+ contentType: 'text/plain',
+ encoding: '7bit',
+ charset: 'utf-8'
+ };
+
+ // Extract content type and charset
+ const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;"\r\n]+)|(?:;\s*charset="([^"]+)"))?/i);
+ if (contentTypeMatch) {
+ result.contentType = contentTypeMatch[1].trim().toLowerCase();
+ if (contentTypeMatch[2]) {
+ result.charset = contentTypeMatch[2].trim().toLowerCase();
+ } else if (contentTypeMatch[3]) {
+ result.charset = contentTypeMatch[3].trim().toLowerCase();
+ }
+ }
+
+ // Extract content transfer encoding
+ const encodingMatch = headers.match(/Content-Transfer-Encoding:\s*([^\s;\r\n]+)/i);
+ if (encodingMatch) {
+ result.encoding = encodingMatch[1].trim().toLowerCase();
+ }
+
+ return result;
+}
+
+function decodeMIME(text: string, encoding?: string, charset: string = 'utf-8'): string {
+ if (!text) return '';
+
+ // Normalize encoding and charset
+ encoding = (encoding || '').toLowerCase();
+ charset = (charset || 'utf-8').toLowerCase();
+
+ try {
+ // Handle different encoding types
+ if (encoding === 'quoted-printable') {
+ return decodeQuotedPrintable(text, charset);
+ } else if (encoding === 'base64') {
+ return decodeBase64(text, charset);
+ } else if (encoding === '7bit' || encoding === '8bit' || encoding === 'binary') {
+ // For these encodings, we still need to handle the character set
+ return convertCharset(text, charset);
+ } else {
+ // Unknown encoding, return as is but still handle charset
+ return convertCharset(text, charset);
+ }
+ } catch (error) {
+ console.error('Error decoding MIME:', error);
+ return text;
+ }
+}
+
+function decodeBase64(text: string, charset: string): string {
+ const cleanText = text.replace(/\s/g, '');
+
+ let binaryString;
+ try {
+ binaryString = atob(cleanText);
+ } catch (e) {
+ console.error('Base64 decoding error:', e);
+ return text;
+ }
+
+ return convertCharset(binaryString, charset);
+}
+
+function convertCharset(text: string, fromCharset: string): string {
+ try {
+ if (typeof TextDecoder !== 'undefined') {
+ const bytes = new Uint8Array(text.length);
+ for (let i = 0; i < text.length; i++) {
+ bytes[i] = text.charCodeAt(i) & 0xFF;
+ }
+
+ let normalizedCharset = fromCharset.toLowerCase();
+
+ // Normalize charset names
+ if (normalizedCharset === 'iso-8859-1' || normalizedCharset === 'latin1') {
+ normalizedCharset = 'iso-8859-1';
+ } else if (normalizedCharset === 'windows-1252' || normalizedCharset === 'cp1252') {
+ normalizedCharset = 'windows-1252';
+ }
+
+ const decoder = new TextDecoder(normalizedCharset);
+ return decoder.decode(bytes);
+ }
+
+ // Fallback for older browsers or unsupported charsets
+ if (fromCharset.toLowerCase() === 'iso-8859-1' || fromCharset.toLowerCase() === 'windows-1252') {
+ return text
+ .replace(/\xC3\xA0/g, 'à')
+ .replace(/\xC3\xA2/g, 'â')
+ .replace(/\xC3\xA9/g, 'é')
+ .replace(/\xC3\xA8/g, 'è')
+ .replace(/\xC3\xAA/g, 'ê')
+ .replace(/\xC3\xAB/g, 'ë')
+ .replace(/\xC3\xB4/g, 'ô')
+ .replace(/\xC3\xB9/g, 'ù')
+ .replace(/\xC3\xBB/g, 'û')
+ .replace(/\xC3\x80/g, 'À')
+ .replace(/\xC3\x89/g, 'É')
+ .replace(/\xC3\x87/g, 'Ç')
+ // Clean up HTML entities
+ .replace(/ç/g, 'ç')
+ .replace(/é/g, 'é')
+ .replace(/è/g, 'ë')
+ .replace(/ê/g, 'ª')
+ .replace(/ë/g, '«')
+ .replace(/û/g, '»')
+ .replace(/ /g, ' ')
+ .replace(/\xA0/g, ' ');
+ }
+
+ return text;
+ } catch (e) {
+ console.error('Character set conversion error:', e, 'charset:', fromCharset);
+ return text;
+ }
+}
+
+function extractHtmlBody(htmlContent: string): string {
+ const bodyMatch = htmlContent.match(/]*>([\s\S]*?)<\/body>/i);
+ return bodyMatch ? bodyMatch[1] : htmlContent;
+}
+
+function cleanHtml(html: string): string {
+ if (!html) return '';
+
+ return html
+ // Fix common Infomaniak-specific character encodings
+ .replace(/=C2=A0/g, ' ') // non-breaking space
+ .replace(/=E2=80=93/g, '\u2013') // en dash
+ .replace(/=E2=80=94/g, '\u2014') // em dash
+ .replace(/=E2=80=98/g, '\u2018') // left single quote
+ .replace(/=E2=80=99/g, '\u2019') // right single quote
+ .replace(/=E2=80=9C/g, '\u201C') // left double quote
+ .replace(/=E2=80=9D/g, '\u201D') // right double quote
+ .replace(/=C3=A0/g, 'à')
+ .replace(/=C3=A2/g, 'â')
+ .replace(/=C3=A9/g, 'é')
+ .replace(/=C3=A8/g, 'è')
+ .replace(/=C3=AA/g, 'ê')
+ .replace(/=C3=AB/g, 'ë')
+ .replace(/=C3=B4/g, 'ô')
+ .replace(/=C3=B9/g, 'ù')
+ .replace(/=C3=xBB/g, 'û')
+ .replace(/=C3=80/g, 'À')
+ .replace(/=C3=89/g, 'É')
+ .replace(/=C3=87/g, 'Ç')
+ // Clean up HTML entities
+ .replace(/ç/g, 'ç')
+ .replace(/é/g, 'é')
+ .replace(/è/g, 'ë')
+ .replace(/ê/g, 'ª')
+ .replace(/ë/g, '«')
+ .replace(/û/g, '»')
+ .replace(/ /g, ' ')
+ .replace(/\xA0/g, ' ');
+}
+
+function decodeMimeContent(content: string): string {
+ if (!content) return '';
+
+ // Check if this is an Infomaniak multipart message
+ if (content.includes('Content-Type: multipart/')) {
+ const boundary = content.match(/boundary="([^"]+)"/)?.[1];
+ if (boundary) {
+ const parts = content.split('--' + boundary);
+ let htmlContent = '';
+ let textContent = '';
+
+ parts.forEach(part => {
+ if (part.includes('Content-Type: text/html')) {
+ const match = part.match(/\r?\n\r?\n([\s\S]+?)(?=\r?\n--)/);
+ if (match) {
+ htmlContent = cleanHtml(match[1]);
+ }
+ } else if (part.includes('Content-Type: text/plain')) {
+ const match = part.match(/\r?\n\r?\n([\s\S]+?)(?=\r?\n--)/);
+ if (match) {
+ textContent = cleanHtml(match[1]);
+ }
+ }
+ });
+
+ // Prefer HTML content if available
+ return htmlContent || textContent;
+ }
+ }
+
+ // If not multipart or no boundary found, clean the content directly
+ return cleanHtml(content);
+}
+
+// Add this helper function
+const renderEmailContent = (email: Email) => {
+ const decodedContent = decodeMimeContent(email.body);
+ if (email.body.includes('Content-Type: text/html')) {
+ return ;
+ }
+ return {decodedContent}
;
+};
+
+// Add this helper function
+const decodeEmailContent = (content: string, charset: string = 'utf-8') => {
+ return convertCharset(content, charset);
+};
+
+function cleanEmailContent(content: string): string {
+ // Remove or fix malformed URLs
+ return content.replace(/=3D"(http[^"]+)"/g, (match, url) => {
+ try {
+ return `"${decodeURIComponent(url)}"`;
+ } catch {
+ return '';
+ }
+ });
+}
+
+// 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 Edit;
+ case 'trash':
+ return Trash;
+ case 'spam':
+ return AlertOctagon;
+ case 'archive':
+ case 'archives':
+ return Archive;
+ default:
+ return Folder;
+ }
+};
+
+// Initial sidebar items - only INBOX
+const initialSidebarItems = [
+ {
+ view: 'INBOX' as MailFolder,
+ label: 'Inbox',
+ icon: Inbox,
+ folder: 'INBOX'
+ }
+];
+
+export default function CourrierPage() {
+ const router = useRouter();
+ const [loading, setLoading] = useState(true);
+ const [accounts, setAccounts] = useState([
+ { id: 0, name: 'All', email: '', color: 'bg-gray-500' },
+ { id: 1, name: 'Mail', email: 'alma@governance-labs.org', color: 'bg-blue-500' }
+ ]);
+ const [selectedAccount, setSelectedAccount] = useState(null);
+ const [currentView, setCurrentView] = useState('INBOX');
+ const [showCompose, setShowCompose] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [selectedEmails, setSelectedEmails] = useState([]);
+ const [showBulkActions, setShowBulkActions] = useState(false);
+ const [showBcc, setShowBcc] = useState(false);
+ const [emails, setEmails] = useState([]);
+ const [error, setError] = useState(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(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(false);
+ const [foldersDropdownOpen, setFoldersDropdownOpen] = useState(false);
+ const [showAccountActions, setShowAccountActions] = useState(null);
+ const [showEmailActions, setShowEmailActions] = useState(false);
+ const [deleteType, setDeleteType] = useState<'email' | 'emails' | 'account'>('email');
+ const [itemToDelete, setItemToDelete] = useState(null);
+ const [showCc, setShowCc] = useState(false);
+ const [contentLoading, setContentLoading] = useState(false);
+ const [attachments, setAttachments] = useState([]);
+ const [folders, setFolders] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [availableFolders, setAvailableFolders] = useState([]);
+ const [sidebarItems, setSidebarItems] = useState(initialSidebarItems);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+ const emailsPerPage = 24;
+
+ // Debug logging for email distribution
+ useEffect(() => {
+ const emailsByFolder = emails.reduce((acc, email) => {
+ acc[email.folder] = (acc[email.folder] || 0) + 1;
+ return acc;
+ }, {} as Record);
+
+ 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);
+ };
+
+ // Check for stored credentials
+ useEffect(() => {
+ const checkCredentials = async () => {
+ try {
+ console.log('Checking for stored credentials...');
+ const response = await fetch('/api/mail');
+ if (!response.ok) {
+ const errorData = await response.json();
+ console.log('API response error:', errorData);
+ if (errorData.error === 'No stored credentials found') {
+ console.log('No credentials found, redirecting to login...');
+ router.push('/mail/login');
+ return;
+ }
+ throw new Error(errorData.error || 'Failed to check credentials');
+ }
+ console.log('Credentials verified, loading emails...');
+ setLoading(false);
+ loadEmails();
+ } catch (err) {
+ console.error('Error checking credentials:', err);
+ setError(err instanceof Error ? err.message : 'Failed to check credentials');
+ setLoading(false);
+ }
+ };
+
+ checkCredentials();
+ }, [router]);
+
+ // Update the loadEmails function
+ const loadEmails = async (isLoadMore = false) => {
+ try {
+ if (isLoadMore) {
+ setIsLoadingMore(true);
+ } else {
+ setLoading(true);
+ }
+ setError(null);
+
+ const response = await fetch(`/api/mail?folder=${currentView}&page=${page}&limit=${emailsPerPage}`);
+ if (!response.ok) {
+ throw new Error('Failed to load emails');
+ }
+
+ const data = await response.json();
+
+ // Get available folders from the API response
+ if (data.folders) {
+ setAvailableFolders(data.folders);
+ }
+
+ // Process emails keeping exact folder names
+ const processedEmails = (data.emails || []).map((email: any) => ({
+ id: Number(email.id),
+ accountId: 1,
+ from: email.from || '',
+ 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 || 'INBOX',
+ cc: email.cc,
+ bcc: email.bcc,
+ flags: email.flags || []
+ }));
+
+ // Only update unread count if we're in the Inbox folder
+ if (currentView === 'INBOX') {
+ const unreadInboxEmails = processedEmails.filter(
+ (email: Email) => !email.read && email.folder === 'INBOX'
+ ).length;
+ setUnreadCount(unreadInboxEmails);
+ }
+
+ // Update emails state based on whether we're loading more
+ if (isLoadMore) {
+ setEmails(prev => [...prev, ...processedEmails]);
+ } else {
+ setEmails(processedEmails);
+ }
+
+ // Update hasMore state based on the number of emails received
+ setHasMore(processedEmails.length === emailsPerPage);
+
+ } catch (err) {
+ console.error('Error loading emails:', err);
+ setError(err instanceof Error ? err.message : 'Failed to load emails');
+ } finally {
+ setLoading(false);
+ setIsLoadingMore(false);
+ }
+ };
+
+ // Add an effect to reload emails when the view changes
+ useEffect(() => {
+ setPage(1); // Reset page when view changes
+ setHasMore(true);
+ loadEmails();
+ }, [currentView]);
+
+ // Format date for display
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ const now = new Date();
+
+ if (date.toDateString() === now.toDateString()) {
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ } else {
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ }
+ };
+
+ // 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 set selectedEmail correctly
+ const handleEmailSelect = (emailId: number) => {
+ const email = emails.find(e => e.id === emailId);
+ if (email) {
+ setSelectedEmail(email);
+ if (!email.read) {
+ // Mark as read in state
+ setEmails(emails.map(e =>
+ e.id === emailId ? { ...e, read: true } : e
+ ));
+
+ // Update read status on server
+ fetch('/api/mail/mark-read', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ emailId })
+ }).catch(error => {
+ console.error('Error marking email as read:', error);
+ });
+ }
+ }
+ };
+
+ // Add these improved handlers
+ const handleEmailCheckbox = (e: React.ChangeEvent, 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/mail/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/mail/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) => {
+ 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()));
+ }
+ };
+
+ // Add filtered emails based on search query
+ const filteredEmails = useMemo(() => {
+ if (!searchQuery) return emails;
+
+ const query = searchQuery.toLowerCase();
+ return emails.filter(email =>
+ email.subject.toLowerCase().includes(query) ||
+ email.from.toLowerCase().includes(query) ||
+ email.to.toLowerCase().includes(query) ||
+ email.body.toLowerCase().includes(query)
+ );
+ }, [emails, searchQuery]);
+
+ // Update the email list to use filtered emails
+ const renderEmailList = () => (
+
+ {renderEmailListHeader()}
+ {renderBulkActionsToolbar()}
+
+
+ {loading ? (
+
+ ) : filteredEmails.length === 0 ? (
+
+
+
+ {searchQuery ? 'No emails match your search' : 'No emails in this folder'}
+
+
+ ) : (
+
+ {filteredEmails.map((email) => renderEmailListItem(email))}
+ {isLoadingMore && (
+
+ )}
+
+ )}
+
+
+ );
+
+ // Update the email count in the header to show filtered count
+ const renderEmailListHeader = () => (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+
+ 0 && selectedEmails.length === filteredEmails.length}
+ onCheckedChange={toggleSelectAll}
+ className="mt-0.5"
+ />
+ Inbox
+
+
+ {searchQuery ? `${filteredEmails.length} of ${emails.length} emails` : `${emails.length} emails`}
+
+
+
+ );
+
+ // Update the bulk actions toolbar to include confirmation dialog
+ const renderBulkActionsToolbar = () => {
+ if (selectedEmails.length === 0) return null;
+
+ return (
+
+
+
+ {selectedEmails.length} selected
+
+
+
+
+
+
+
+
+ );
+ };
+
+ // Keep only one renderEmailListWrapper function that includes both panels
+ const renderEmailListWrapper = () => (
+
+ {/* Email list panel */}
+ {renderEmailList()}
+
+ {/* Preview panel - will automatically take remaining space */}
+
+ {selectedEmail ? (
+ <>
+ {/* Email actions header */}
+
+
+
+
+
+
+ {selectedEmail.subject}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Scrollable content area */}
+
+
+
+
+ {selectedEmail.fromName?.charAt(0) || selectedEmail.from.charAt(0)}
+
+
+
+
+ {selectedEmail.fromName || selectedEmail.from}
+
+
+ to {selectedEmail.to}
+
+
+
+ {formatDate(selectedEmail.date)}
+
+
+
+
+ {(() => {
+ try {
+ const parsed = parseFullEmail(selectedEmail.body);
+ return (
+
+ {/* Display HTML content if available, otherwise fallback to text */}
+
+
+ {/* Display attachments if present */}
+ {parsed.attachments && parsed.attachments.length > 0 && (
+
+
Attachments
+
+ {parsed.attachments.map((attachment, index) => (
+
+
+
+ {attachment.filename}
+
+
+ ))}
+
+
+ )}
+
+ );
+ } catch (e) {
+ console.error('Error parsing email:', e);
+ return selectedEmail.body;
+ }
+ })()}
+
+
+ >
+ ) : (
+
+
+
Select an email to view its contents
+
+ )}
+
+
+ );
+
+ // Update sidebar items when available folders change
+ useEffect(() => {
+ if (availableFolders.length > 0) {
+ const newItems = [
+ ...initialSidebarItems,
+ ...availableFolders
+ .filter(folder => !['INBOX'].includes(folder)) // Exclude folders already in initial items
+ .map(folder => ({
+ view: folder as MailFolder,
+ label: folder.charAt(0).toUpperCase() + folder.slice(1).toLowerCase(),
+ icon: getFolderIcon(folder),
+ folder: folder
+ }))
+ ];
+ setSidebarItems(newItems);
+ }
+ }, [availableFolders]);
+
+ // Update the email list item to match header checkbox alignment
+ const renderEmailListItem = (email: Email) => (
+ handleEmailSelect(email.id)}
+ >
+
{
+ const e = { target: { checked }, stopPropagation: () => {} } as React.ChangeEvent;
+ handleEmailCheckbox(e, email.id);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ className="mt-0.5"
+ />
+
+
+
+
+ {currentView === 'Sent' ? email.to : (
+ (() => {
+ const fromMatch = email.from.match(/^([^<]+)\s*<([^>]+)>$/);
+ return fromMatch ? fromMatch[1].trim() : email.from;
+ })()
+ )}
+
+
+
+
+ {formatDate(email.date)}
+
+
+
+
+
+ {email.subject || '(No subject)'}
+
+
+ {(() => {
+ // Get clean preview of the actual message content
+ let preview = '';
+ try {
+ const parsed = parseFullEmail(email.body);
+
+ // Try to get content from parsed email
+ preview = (parsed.text || parsed.html || '')
+ .replace(/