From 287941f42fadc61ea7d6bc3c5cdfb89481a01627 Mon Sep 17 00:00:00 2001 From: alma Date: Mon, 21 Apr 2025 16:07:55 +0200 Subject: [PATCH] mail page rest dang --- app/api/emails/route.ts | 2 +- app/courrier/login/page.tsx | 2 +- app/courrier/page.tsx | 20 +- app/mail/login/page.tsx | 116 --- app/mail/page.tsx | 1778 ----------------------------------- components/email.tsx | 2 +- 6 files changed, 13 insertions(+), 1907 deletions(-) delete mode 100644 app/mail/login/page.tsx delete mode 100644 app/mail/page.tsx diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts index 5d86b2f3..8f3a3960 100644 --- a/app/api/emails/route.ts +++ b/app/api/emails/route.ts @@ -44,7 +44,7 @@ export async function GET(req: NextRequest) { date: new Date().toISOString(), isUnread: true }], - mailUrl: `${nextcloudUrl}/apps/mail/box/unified` + mailUrl: `${nextcloudUrl}/apps/courrier/box/unified` }); } catch (error) { console.error('Error:', error); diff --git a/app/courrier/login/page.tsx b/app/courrier/login/page.tsx index 3782f9bd..8726f4ba 100644 --- a/app/courrier/login/page.tsx +++ b/app/courrier/login/page.tsx @@ -22,7 +22,7 @@ export default function MailLoginPage() { setLoading(true); try { - const response = await fetch('/api/mail/login', { + const response = await fetch('/api/courrier/login', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/app/courrier/page.tsx b/app/courrier/page.tsx index 8a33f9cc..f632e967 100644 --- a/app/courrier/page.tsx +++ b/app/courrier/page.tsx @@ -429,13 +429,13 @@ export default function CourrierPage() { const checkCredentials = async () => { try { console.log('Checking for stored credentials...'); - const response = await fetch('/api/mail'); + const response = await fetch('/api/courrier'); 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'); + router.push('/courrier/login'); return; } throw new Error(errorData.error || 'Failed to check credentials'); @@ -463,7 +463,7 @@ export default function CourrierPage() { } setError(null); - const response = await fetch(`/api/mail?folder=${currentView}&page=${page}&limit=${emailsPerPage}`); + const response = await fetch(`/api/courrier?folder=${currentView}&page=${page}&limit=${emailsPerPage}`); if (!response.ok) { throw new Error('Failed to load emails'); } @@ -557,7 +557,7 @@ export default function CourrierPage() { setSelectedEmail(email); // Fetch the full email content - const response = await fetch(`/api/mail/${emailId}`); + const response = await fetch(`/api/courrier/${emailId}`); if (!response.ok) { throw new Error('Failed to fetch full email content'); } @@ -575,7 +575,7 @@ export default function CourrierPage() { // Try to mark as read in the background try { - const markReadResponse = await fetch(`/api/mail/mark-read`, { + const markReadResponse = await fetch(`/api/courrier/mark-read`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -630,7 +630,7 @@ export default function CourrierPage() { } try { - const response = await fetch('/api/mail/bulk-actions', { + const response = await fetch('/api/courrier/bulk-actions', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -673,7 +673,7 @@ export default function CourrierPage() { // Add handleDeleteConfirm function const handleDeleteConfirm = async () => { try { - const response = await fetch('/api/mail/bulk-actions', { + const response = await fetch('/api/courrier/bulk-actions', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1221,7 +1221,7 @@ export default function CourrierPage() { } try { - const response = await fetch('/api/mail/send', { + const response = await fetch('/api/courrier/send', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1271,7 +1271,7 @@ export default function CourrierPage() { if (!email) return; try { - const response = await fetch('/api/mail/toggle-star', { + const response = await fetch('/api/courrier/toggle-star', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1369,7 +1369,7 @@ export default function CourrierPage() { 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}`, { + const response = await fetch(`/api/courrier?folder=${encodeURIComponent(newMailbox)}&page=1&limit=${emailsPerPage}`, { signal: controller.signal }); diff --git a/app/mail/login/page.tsx b/app/mail/login/page.tsx deleted file mode 100644 index 3782f9bd..00000000 --- a/app/mail/login/page.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'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/mail/page.tsx b/app/mail/page.tsx deleted file mode 100644 index 8c1b3e97..00000000 --- a/app/mail/page.tsx +++ /dev/null @@ -1,1778 +0,0 @@ -'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'; -import { - decodeQuotedPrintable, - decodeBase64, - convertCharset, - cleanHtml, - parseEmailHeaders, - extractBoundary, - extractFilename, - extractHeader -} from '@/lib/infomaniak-mime-decoder'; - -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; -} - -interface EmailAttachment { - filename: string; - contentType: string; - encoding: string; - content: string; -} - -interface ParsedEmail { - text: string | null; - html: string | null; - attachments: Array<{ - filename: string; - contentType: string; - encoding: string; - content: string; - }>; -} - -interface EmailMessage { - subject: string; - from: string; - to: string; - date: string; - contentType: string; - text: string | null; - html: string | null; - attachments: EmailAttachment[]; - raw: { - headers: string; - body: string; - }; -} - -function parseFullEmail(content: string): ParsedEmail { - try { - // First, try to parse the email headers - const headers = parseEmailHeaders(content); - - // If it's a multipart email, process each part - if (headers.contentType?.includes('multipart')) { - const boundary = extractBoundary(headers.contentType); - if (!boundary) { - throw new Error('No boundary found in multipart content'); - } - - const parts = content.split(boundary); - const result: ParsedEmail = { - text: null, - html: null, - attachments: [] - }; - - for (const part of parts) { - if (!part.trim()) continue; - - const partHeaders = parseEmailHeaders(part); - const partContent = part.split('\r\n\r\n')[1] || ''; - - // Handle HTML content - if (partHeaders.contentType?.includes('text/html')) { - const decoded = decodeMIME( - partContent, - partHeaders.encoding || '7bit', - partHeaders.charset || 'utf-8' - ); - result.html = cleanHtml(decoded); - } - // Handle plain text content - else if (partHeaders.contentType?.includes('text/plain')) { - const decoded = decodeMIME( - partContent, - partHeaders.encoding || '7bit', - partHeaders.charset || 'utf-8' - ); - result.text = decoded; - } - // Handle attachments - else if (partHeaders.contentType && !partHeaders.contentType.includes('text/')) { - const filename = extractFilename(partHeaders.contentType) || 'attachment'; - result.attachments.push({ - filename, - contentType: partHeaders.contentType, - encoding: partHeaders.encoding || '7bit', - content: partContent - }); - } - } - - return result; - } - - // If it's not multipart, handle as a single part - const body = content.split('\r\n\r\n')[1] || ''; - const decoded = decodeMIME( - body, - headers.encoding || '7bit', - headers.charset || 'utf-8' - ); - - if (headers.contentType?.includes('text/html')) { - return { - html: cleanHtml(decoded), - text: null, - attachments: [] - }; - } - - return { - html: null, - text: decoded, - attachments: [] - }; - } catch (e) { - console.error('Error parsing email:', e); - return { - html: null, - text: content, - attachments: [] - }; - } -} - -function processMultipartEmail(emailRaw: string, boundary: string, mainHeaders: string): ParsedEmail { - const parts = emailRaw.split(new RegExp(`--${boundary}(?:--)?\\s*`, 'm')); - const result: ParsedEmail = { - text: '', - html: '', - attachments: [] - }; - - for (const part of parts) { - if (!part.trim()) continue; - - const [partHeaders, ...bodyParts] = part.split(/\r?\n\r?\n/); - const partBody = bodyParts.join('\n\n'); - const partInfo = parseEmailHeaders(partHeaders); - - if (partInfo.contentType.startsWith('text/')) { - let decodedContent = ''; - - if (partInfo.encoding === 'quoted-printable') { - decodedContent = decodeQuotedPrintable(partBody, partInfo.charset); - } else if (partInfo.encoding === 'base64') { - decodedContent = decodeBase64(partBody, partInfo.charset); - } else { - decodedContent = partBody; - } - - if (partInfo.contentType.includes('html')) { - decodedContent = cleanHtml(decodedContent); - result.html = decodedContent; - } else { - result.text = decodedContent; - } - } else { - // Handle attachment - const filename = extractFilename(partHeaders); - result.attachments.push({ - filename, - contentType: partInfo.contentType, - encoding: partInfo.encoding, - content: partBody - }); - } - } - - 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 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); -} - -function renderEmailContent(email: Email) { - try { - // Parse the full email content - const parsed = parseFullEmail(email.body); - - // If we have HTML content, render it - if (parsed.html) { - return ( -
- ); - } - - // If we have text content, render it with proper formatting - if (parsed.text) { - return ( -
- {parsed.text.split('\n').map((line, i) => ( -

{line}

- ))} -
- ); - } - - // If we have attachments but no content, show a message - if (parsed.attachments.length > 0) { - return ( -
- This email contains {parsed.attachments.length} attachment{parsed.attachments.length > 1 ? 's' : ''}. -
- ); - } - - // If we couldn't parse the content, try to clean and display it - const cleanedContent = cleanHtml(email.body); - return ( -
- {cleanedContent.split('\n').map((line, i) => ( -

{line}

- ))} -
- ); - } catch (e) { - console.error('Error rendering email content:', e); - return ( -
- Error rendering email content. Please try again later. -
- ); - } -} - -// 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)} -
-
- -
- {renderEmailContent(selectedEmail)} -
-
- - ) : ( -
- -

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 while preserving structure - originalContent = parsed.html - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(//gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<\/div>/gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<\/p>/gi, '\n') - .replace(/]*>/gi, '\n• ') - .replace(/<\/li>/gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<\/ul>/gi, '\n') - .replace(/]*>/gi, '\n') - .replace(/<\/ol>/gi, '\n') - .replace(/]*>/gi, '\n> ') - .replace(/<\/blockquote>/gi, '\n') - .replace(/<[^>]+>/g, '') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/^\s+$/gm, '') - .replace(/\n{3,}/g, '\n\n') - .trim(); - } else if (parsed.text) { - originalContent = parsed.text.trim(); - } else { - originalContent = selectedEmail.body - .replace(/<[^>]+>/g, '') - .trim(); - } - - // Clean up the content while preserving important formatting - originalContent = originalContent - .split('\n') - .map(line => line.trim()) - .filter(line => { - 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 with proper email headers - 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 { - replyHeader = `\n\nOn ${formattedDate}, ${selectedEmail.from} wrote:\n`; - } - - // Properly indent the original content with blockquotes - const indentedContent = originalContent - .split('\n') - .map(line => line ? `> ${line}` : '>') - .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 */} -
- -