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 + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + setHost(e.target.value)} + required + /> +
+
+ + setPort(e.target.value)} + required + /> +
+ {error && ( +
{error}
+ )} + +
+
+
+
+ ); +} \ 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(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/ |‌|»|«|>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + // If no preview from parsed content, try direct body + if (!preview) { + preview = email.body + .replace(/<[^>]+>/g, '') + .replace(/ |‌|»|«|>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + // Remove email artifacts and clean up + preview = preview + .replace(/^>+/gm, '') + .replace(/Content-Type:[^\n]+/g, '') + .replace(/Content-Transfer-Encoding:[^\n]+/g, '') + .replace(/--[a-zA-Z0-9]+(-[a-zA-Z0-9]+)?/g, '') + .replace(/boundary=[^\n]+/g, '') + .replace(/charset=[^\n]+/g, '') + .replace(/[\r\n]+/g, ' ') + .trim(); + + // Take first 100 characters + preview = preview.substring(0, 100); + + // Try to end at a complete word + if (preview.length === 100) { + const lastSpace = preview.lastIndexOf(' '); + if (lastSpace > 80) { + preview = preview.substring(0, lastSpace); + } + preview += '...'; + } + + } catch (e) { + console.error('Error generating preview:', e); + preview = ''; + } + + return preview || 'No preview available'; + })()} +
+
+
+ ); + + // Render the sidebar navigation + const renderSidebarNav = () => ( + + ); + + // Add attachment handling functions + const handleFileAttachment = async (e: React.ChangeEvent) => { + if (!e.target.files) return; + + const newAttachments: Attachment[] = []; + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes + const oversizedFiles: string[] = []; + + for (const file of e.target.files) { + if (file.size > MAX_FILE_SIZE) { + oversizedFiles.push(file.name); + continue; + } + + try { + // Read file as base64 + const base64Content = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = reader.result as string; + resolve(base64.split(',')[1]); // Remove data URL prefix + }; + reader.readAsDataURL(file); + }); + + newAttachments.push({ + name: file.name, + type: file.type, + content: base64Content, + encoding: 'base64' + }); + } catch (error) { + console.error('Error processing attachment:', error); + } + } + + if (oversizedFiles.length > 0) { + alert(`The following files exceed the 10MB size limit and were not attached:\n${oversizedFiles.join('\n')}`); + } + + if (newAttachments.length > 0) { + setAttachments([...attachments, ...newAttachments]); + } + }; + + // Add handleSend function for email composition + const handleSend = async () => { + if (!composeTo) { + alert('Please specify at least one recipient'); + return; + } + + try { + const response = await fetch('/api/mail/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 toggleStarred function + const toggleStarred = async (emailId: number, e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + } + + const email = emails.find(e => e.id === emailId); + if (!email) return; + + try { + const response = await fetch('/api/mail/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 handleReply function + const handleReply = (type: 'reply' | 'replyAll' | 'forward') => { + if (!selectedEmail) return; + + const getReplySubject = () => { + const subject = selectedEmail.subject; + if (type === 'forward') { + return subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`; + } + return subject.startsWith('Re:') ? subject : `Re: ${subject}`; + }; + + const getReplyTo = () => { + switch (type) { + case 'reply': + return selectedEmail.from; + case 'replyAll': + // For Reply All, only put the original sender in To + return selectedEmail.from; + case 'forward': + return ''; + } + }; + + const getReplyCc = () => { + if (type === 'replyAll') { + // For Reply All, put all other recipients in CC, excluding the sender and current user + const allRecipients = new Set([ + ...(selectedEmail.to?.split(',') || []), + ...(selectedEmail.cc?.split(',') || []) + ]); + // Remove the sender and current user from CC + allRecipients.delete(selectedEmail.from); + allRecipients.delete(accounts[1]?.email); + return Array.from(allRecipients) + .map(email => email.trim()) + .filter(email => email) // Remove empty strings + .join(', '); + } + return ''; + }; + + const getReplyBody = () => { + try { + const parsed = parseFullEmail(selectedEmail.body); + let originalContent = ''; + + // Get the content from either HTML or text part + if (parsed.html) { + // Convert HTML to plain text for the reply + originalContent = parsed.html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(//gi, '\n') + .replace(/]*>/gi, '\n') + .replace(/<\/div>/gi, '') + .replace(/]*>/gi, '\n') + .replace(/<\/p>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/ |‌|»|«|>/g, match => { + switch (match) { + case ' ': return ' '; + case '‌': return ''; + case '»': return '»'; + case '«': return '«'; + case '>': return '>'; + case '<': return '<'; + case '&': return '&'; + default: return match; + } + }) + .replace(/^\s+$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } else if (parsed.text) { + originalContent = parsed.text.trim(); + } else { + // Fallback to raw body if parsing fails + originalContent = selectedEmail.body + .replace(/<[^>]+>/g, '') + .trim(); + } + + // Clean up the content + originalContent = originalContent + .split('\n') + .map(line => line.trim()) + .filter(line => { + // Remove email client signatures and headers + return !line.match(/^(From|To|Sent|Subject|Date|Cc|Bcc):/i) && + !line.match(/^-{2,}/) && + !line.match(/^_{2,}/) && + !line.match(/^={2,}/) && + !line.match(/^This (email|message) has been/i) && + !line.match(/^Disclaimer/i) && + !line.match(/^[*_-]{3,}/) && + !line.match(/^Envoyé depuis/i) && + !line.match(/^Envoyé à partir de/i) && + !line.match(/^Sent from/i) && + !line.match(/^Outlook pour/i) && + !line.match(/^De :/i) && + !line.match(/^À :/i) && + !line.match(/^Objet :/i); + }) + .join('\n') + .trim(); + + // Format the reply + const date = new Date(selectedEmail.date); + const formattedDate = date.toLocaleString('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + + let replyHeader = ''; + if (type === 'forward') { + replyHeader = `\n\n---------- Forwarded message ----------\n`; + replyHeader += `From: ${selectedEmail.from}\n`; + replyHeader += `Date: ${formattedDate}\n`; + replyHeader += `Subject: ${selectedEmail.subject}\n`; + replyHeader += `To: ${selectedEmail.to}\n`; + if (selectedEmail.cc) { + replyHeader += `Cc: ${selectedEmail.cc}\n`; + } + replyHeader += `\n`; + } else { + // Simple header for reply and reply all + replyHeader = `\n\nOn ${formattedDate}, ${selectedEmail.from} wrote:\n`; + } + + // Indent the original content + const indentedContent = originalContent + .split('\n') + .map(line => line ? `> ${line}` : '>') // Keep empty lines as '>' for better readability + .join('\n'); + + return `${replyHeader}${indentedContent}`; + } catch (error) { + console.error('Error formatting reply:', error); + return `\n\nOn ${new Date(selectedEmail.date).toLocaleString()}, ${selectedEmail.from} wrote:\n> ${selectedEmail.body}`; + } + }; + + // Open compose modal with reply details + setShowCompose(true); + setComposeTo(getReplyTo()); + setComposeSubject(getReplySubject()); + setComposeBody(getReplyBody()); + setComposeCc(getReplyCc()); + setComposeBcc(''); + // Show CC field automatically for Reply All + setShowCc(type === 'replyAll'); + setShowBcc(false); + setAttachments([]); + }; + + // Add the confirmation dialog component + const renderDeleteConfirmDialog = () => ( + + + + Delete Emails + + Are you sure you want to delete {selectedEmails.length} selected email{selectedEmails.length > 1 ? 's' : ''}? This action cannot be undone. + + + + Cancel + Delete + + + + ); + + 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/mail?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.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 || [] + })); + + setEmails(processedEmails); + setHasMore(processedEmails.length === emailsPerPage); + + // 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 (err) { + if (err instanceof Error) { + if (err.name === 'AbortError') { + setError('Request timed out. Please try again.'); + } else { + setError(err.message); + } + } else { + setError('Failed to fetch emails'); + } + } finally { + setLoading(false); + } + }; + + if (error) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + return ( + <> + {/* Main layout */} +
+ {/* Sidebar */} +
+ {/* Courrier Title */} +
+
+ + COURRIER +
+
+ + {/* Compose button and refresh button */} +
+ + +
+ + {/* Accounts Section */} +
+ + + {accountsDropdownOpen && ( +
+ {accounts.map(account => ( +
+ +
+ ))} +
+ )} +
+ + {/* Navigation */} + {renderSidebarNav()} +
+ + {/* Main content area */} +
+ {/* Email list panel */} + {renderEmailListWrapper()} +
+
+ + {/* Compose Email Modal */} + {showCompose && ( +
+
+ {/* Modal Header */} +
+

+ {composeSubject.startsWith('Re:') ? 'Reply' : + composeSubject.startsWith('Fwd:') ? 'Forward' : 'New Message'} +

+ +
+ + {/* Modal Body */} +
+
+ {/* To Field */} +
+ + setComposeTo(e.target.value)} + placeholder="recipient@example.com" + className="w-full mt-1 bg-white border-gray-300 text-gray-900" + /> +
+ + {/* CC/BCC Toggle Buttons */} +
+ + +
+ + {/* CC Field */} + {showCc && ( +
+ + setComposeCc(e.target.value)} + placeholder="cc@example.com" + className="w-full mt-1 bg-white border-gray-300 text-gray-900" + /> +
+ )} + + {/* BCC Field */} + {showBcc && ( +
+ + setComposeBcc(e.target.value)} + placeholder="bcc@example.com" + className="w-full mt-1 bg-white border-gray-300 text-gray-900" + /> +
+ )} + + {/* Subject Field */} +
+ + setComposeSubject(e.target.value)} + placeholder="Enter subject" + className="w-full mt-1 bg-white border-gray-300 text-gray-900" + /> +
+ + {/* Message Body */} +
+ +