From 29bf70051b929269dd725696dc8bf946c35c6ba1 Mon Sep 17 00:00:00 2001 From: alma Date: Sat, 26 Apr 2025 09:22:51 +0200 Subject: [PATCH] panel 2 courier api restore --- components/email/ComposeEmail.tsx | 341 ++++++++++++++++++++++++++++ components/email/EmailLayout.tsx | 354 ++++++++++++++++++++++++++++++ components/email/EmailPanel.tsx | 177 +++++++++++++++ components/email/EmailPreview.tsx | 165 ++++++++++++++ lib/services/email-service.ts | 151 +++++++++++-- 5 files changed, 1175 insertions(+), 13 deletions(-) create mode 100644 components/email/ComposeEmail.tsx create mode 100644 components/email/EmailLayout.tsx create mode 100644 components/email/EmailPanel.tsx create mode 100644 components/email/EmailPreview.tsx diff --git a/components/email/ComposeEmail.tsx b/components/email/ComposeEmail.tsx new file mode 100644 index 00000000..ae8fa729 --- /dev/null +++ b/components/email/ComposeEmail.tsx @@ -0,0 +1,341 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { formatEmailForReplyOrForward, EmailMessage } from '@/lib/services/email-service'; +import { X, Paperclip, ChevronDown, ChevronUp, SendHorizontal, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; + +interface ComposeEmailProps { + initialEmail?: EmailMessage | null; + type?: 'new' | 'reply' | 'reply-all' | 'forward'; + onClose: () => void; + onSend: (emailData: { + to: string; + cc?: string; + bcc?: string; + subject: string; + body: string; + attachments?: Array<{ + name: string; + content: string; + type: string; + }>; + }) => Promise; +} + +export default function ComposeEmail({ + initialEmail, + type = 'new', + onClose, + onSend +}: ComposeEmailProps) { + // Email form state + const [to, setTo] = useState(''); + const [cc, setCc] = useState(''); + const [bcc, setBcc] = useState(''); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + + // UI state + const [showCc, setShowCc] = useState(false); + const [showBcc, setShowBcc] = useState(false); + const [sending, setSending] = useState(false); + const [attachments, setAttachments] = useState>([]); + + const editorRef = useRef(null); + const attachmentInputRef = useRef(null); + + // Initialize the form when replying to or forwarding an email + useEffect(() => { + if (initialEmail && type !== 'new') { + const formattedEmail = formatEmailForReplyOrForward(initialEmail, type as 'reply' | 'reply-all' | 'forward'); + + setTo(formattedEmail.to); + + if (formattedEmail.cc) { + setCc(formattedEmail.cc); + setShowCc(true); + } + + setSubject(formattedEmail.subject); + setBody(formattedEmail.body); + + // Focus editor after initializing + setTimeout(() => { + if (editorRef.current) { + editorRef.current.focus(); + + // Place cursor at the beginning of the content + const selection = window.getSelection(); + const range = document.createRange(); + + range.setStart(editorRef.current, 0); + range.collapse(true); + + selection?.removeAllRanges(); + selection?.addRange(range); + } + }, 100); + } + }, [initialEmail, type]); + + // Handle attachment selection + const handleAttachmentClick = () => { + attachmentInputRef.current?.click(); + }; + + // Process selected files + const handleFileSelection = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + // Convert selected files to attachments + const newAttachments = Array.from(files).map(file => ({ + file, + uploading: true + })); + + // Read files as data URLs + for (const file of files) { + const reader = new FileReader(); + + reader.onload = (event) => { + const content = event.target?.result as string; + + setAttachments(current => [ + ...current, + { + name: file.name, + content: content.split(',')[1], // Remove data:mime/type;base64, prefix + type: file.type + } + ]); + }; + + reader.readAsDataURL(file); + } + + // Reset file input + if (e.target) { + e.target.value = ''; + } + }; + + // Remove attachment + const removeAttachment = (index: number) => { + setAttachments(current => current.filter((_, i) => i !== index)); + }; + + // Send the email + const handleSend = async () => { + if (!to) { + alert('Please specify at least one recipient'); + return; + } + + try { + setSending(true); + + await onSend({ + to, + cc: cc || undefined, + bcc: bcc || undefined, + subject, + body: editorRef.current?.innerHTML || body, + attachments + }); + + onClose(); + } catch (error) { + console.error('Error sending email:', error); + alert('Failed to send email. Please try again.'); + } finally { + setSending(false); + } + }; + + // Handle editor input + const handleEditorInput = (e: React.FormEvent) => { + // Store the HTML content for use in the send function + setBody(e.currentTarget.innerHTML); + }; + + return ( + + +
+ + {type === 'new' ? 'New Message' : + type === 'reply' ? 'Reply' : + type === 'reply-all' ? 'Reply All' : + 'Forward'} + + +
+
+ + + {/* Email header fields */} +
+
+ To: + setTo(e.target.value)} + className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0" + placeholder="recipient@example.com" + /> +
+ + {showCc && ( +
+ Cc: + setCc(e.target.value)} + className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0" + placeholder="cc@example.com" + /> +
+ )} + + {showBcc && ( +
+ Bcc: + setBcc(e.target.value)} + className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0" + placeholder="bcc@example.com" + /> +
+ )} + + {/* CC/BCC controls */} +
+ + + +
+ +
+ Subject: + setSubject(e.target.value)} + className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0" + placeholder="Subject" + /> +
+
+ + {/* Email body editor */} +
+ + {/* Attachments list */} + {attachments.length > 0 && ( +
+
Attachments:
+
+ {attachments.map((attachment, index) => ( +
+ + {attachment.name} + +
+ ))} +
+
+ )} + + + +
+ + +
+ + +
+ + ); +} \ No newline at end of file diff --git a/components/email/EmailLayout.tsx b/components/email/EmailLayout.tsx new file mode 100644 index 00000000..e7eb5349 --- /dev/null +++ b/components/email/EmailLayout.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Inbox, Star, Send, File, Trash, RefreshCw, Plus, + Search, Loader2, MailOpen, Mail, ArchiveIcon +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import EmailPanel from './EmailPanel'; +import { EmailMessage } from '@/lib/services/email-service'; + +interface EmailLayoutProps { + className?: string; +} + +export default function EmailLayout({ className = '' }: EmailLayoutProps) { + // Email state + const [emails, setEmails] = useState([]); + const [selectedEmailId, setSelectedEmailId] = useState(null); + const [currentFolder, setCurrentFolder] = useState('INBOX'); + const [folders, setFolders] = useState([]); + const [mailboxes, setMailboxes] = useState([]); + + // UI state + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + + // Load emails on component mount and when folder changes + useEffect(() => { + loadEmails(); + }, [currentFolder, page]); + + // Function to load emails + const loadEmails = async (refresh = false) => { + if (refresh) { + setPage(1); + } + + setLoading(true); + setError(null); + + try { + // Construct the API endpoint URL with parameters + const queryParams = new URLSearchParams({ + folder: currentFolder, + page: page.toString(), + perPage: '20' + }); + + if (searchQuery) { + queryParams.set('search', searchQuery); + } + + const response = await fetch(`/api/courrier?${queryParams.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch emails'); + } + + const data = await response.json(); + + if (refresh || page === 1) { + setEmails(data.emails || []); + } else { + // Append emails for pagination + setEmails(prev => [...prev, ...(data.emails || [])]); + } + + // Update available folders if returned from API + if (data.mailboxes && data.mailboxes.length > 0) { + setMailboxes(data.mailboxes); + + // Create a nicer list of standard folders + const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']; + const customFolders = data.mailboxes.filter( + (folder: string) => !standardFolders.includes(folder) + ); + + // Combine standard folders that exist with custom folders + const availableFolders = [ + ...standardFolders.filter(f => data.mailboxes.includes(f)), + ...customFolders + ]; + + setFolders(availableFolders); + } + + // Check if there are more emails to load + setHasMore(data.emails && data.emails.length >= 20); + } catch (err) { + console.error('Error loading emails:', err); + setError(err instanceof Error ? err.message : 'Failed to load emails'); + } finally { + setLoading(false); + } + }; + + // Handle folder change + const handleFolderChange = (folder: string) => { + setCurrentFolder(folder); + setSelectedEmailId(null); + setPage(1); + setSearchQuery(''); + }; + + // Handle email selection + const handleEmailSelect = (id: string) => { + setSelectedEmailId(id); + }; + + // Handle search + const handleSearch = () => { + if (searchQuery.trim()) { + setSearching(true); + setPage(1); + loadEmails(true); + } + }; + + // Handle refreshing emails + const handleRefresh = () => { + loadEmails(true); + }; + + // Handle composing a new email + const handleComposeNew = () => { + setSelectedEmailId(null); + // The compose functionality will be handled by the EmailPanel component + }; + + // Handle email sending + const handleSendEmail = async (emailData: { + to: string; + cc?: string; + bcc?: string; + subject: string; + body: string; + attachments?: { name: string; content: string; type: string; }[]; + }) => { + try { + const response = await fetch('/api/courrier/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(emailData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to send email'); + } + + // If email was sent successfully and we're in the Sent folder, refresh + if (currentFolder === 'Sent') { + loadEmails(true); + } + } catch (err) { + console.error('Error sending email:', err); + throw err; + } + }; + + // Format the date in a readable format + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + // Check if date is today + if (date >= today) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + // Check if date is yesterday + if (date >= yesterday) { + return 'Yesterday'; + } + + // Check if date is this year + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + + // Date is from a previous year + return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); + }; + + // Get folder icon + const getFolderIcon = (folder: string) => { + switch (folder.toLowerCase()) { + case 'inbox': + return ; + case 'sent': + case 'sent items': + return ; + case 'drafts': + return ; + case 'trash': + case 'deleted': + case 'bin': + return ; + case 'junk': + case 'spam': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Sidebar */} +
+ {/* New email button */} +
+ +
+ + {/* Folder navigation */} + +
+ {folders.map((folder) => ( + + ))} +
+
+
+ + {/* Main content */} +
+ {/* Email list */} +
+ {/* Search and refresh */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> +
+ +
+ + {/* Email list */} + + {loading && emails.length === 0 ? ( +
+ +
+ ) : emails.length === 0 ? ( +
+ No emails found +
+ ) : ( +
+ {emails.map((email) => ( +
handleEmailSelect(email.id)} + > +
+
+ {email.flags.seen ? ( + + ) : ( + + )} +
+
+
+

+ {email.from[0]?.name || email.from[0]?.address || 'Unknown'} +

+ + {formatDate(email.date.toString())} + +
+

{email.subject}

+

{email.preview}

+
+
+ + {/* Email indicators */} +
+ {email.hasAttachments && ( + + + + + + Attachment + + + )} +
+
+ ))} +
+ )} +
+
+ + {/* Email preview */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/components/email/EmailPanel.tsx b/components/email/EmailPanel.tsx new file mode 100644 index 00000000..617543a2 --- /dev/null +++ b/components/email/EmailPanel.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { EmailMessage } from '@/lib/services/email-service'; +import EmailPreview from './EmailPreview'; +import ComposeEmail from './ComposeEmail'; +import { Loader2 } from 'lucide-react'; + +interface EmailPanelProps { + selectedEmailId: string | null; + folder?: string; + onSendEmail: (emailData: { + to: string; + cc?: string; + bcc?: string; + subject: string; + body: string; + attachments?: Array<{ + name: string; + content: string; + type: string; + }>; + }) => Promise; +} + +export default function EmailPanel({ + selectedEmailId, + folder = 'INBOX', + onSendEmail +}: EmailPanelProps) { + const [email, setEmail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Compose mode state + const [isComposing, setIsComposing] = useState(false); + const [composeType, setComposeType] = useState<'new' | 'reply' | 'reply-all' | 'forward'>('new'); + + // Load email content when selectedEmailId changes + useEffect(() => { + if (selectedEmailId) { + fetchEmail(selectedEmailId); + // Close compose mode when selecting a different email + setIsComposing(false); + } else { + setEmail(null); + } + }, [selectedEmailId, folder]); + + // Fetch the email content + const fetchEmail = async (id: string) => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/courrier/${id}?folder=${encodeURIComponent(folder)}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to fetch email'); + } + + const data = await response.json(); + + if (!data) { + throw new Error('Email not found'); + } + + // Mark as read if not already + if (!data.flags?.seen) { + markAsRead(id); + } + + setEmail(data); + } catch (err) { + console.error('Error fetching email:', err); + setError(err instanceof Error ? err.message : 'Failed to load email'); + } finally { + setLoading(false); + } + }; + + // Mark email as read + const markAsRead = async (id: string) => { + try { + await fetch(`/api/courrier/${id}/mark-read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action: 'mark-read' }), + }); + } catch (err) { + console.error('Error marking email as read:', err); + } + }; + + // Handle reply/forward actions + const handleReply = (type: 'reply' | 'reply-all' | 'forward') => { + setComposeType(type); + setIsComposing(true); + }; + + // Handle compose mode close + const handleComposeClose = () => { + setIsComposing(false); + setComposeType('new'); + }; + + // If no email is selected and not composing + if (!selectedEmailId && !isComposing) { + return ( +
+
+

Select an email to view or

+ +
+
+ ); + } + + // Show loading state + if (loading) { + return ( +
+
+ +

Loading email...

+
+
+ ); + } + + // Show error state + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + // Show compose mode or email preview + return ( +
+ {isComposing ? ( + + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/components/email/EmailPreview.tsx b/components/email/EmailPreview.tsx new file mode 100644 index 00000000..e194ad99 --- /dev/null +++ b/components/email/EmailPreview.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import DOMPurify from 'isomorphic-dompurify'; +import { EmailMessage } from '@/lib/services/email-service'; +import { Loader2, Paperclip, Download } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; + +interface EmailPreviewProps { + email: EmailMessage | null; + loading?: boolean; + onReply?: (type: 'reply' | 'reply-all' | 'forward') => void; +} + +export default function EmailPreview({ email, loading = false, onReply }: EmailPreviewProps) { + const [contentLoading, setContentLoading] = useState(false); + + // Handle sanitizing and rendering HTML content + const renderContent = () => { + if (!email?.content) return

No content available

; + + // Sanitize HTML content + const sanitizedContent = DOMPurify.sanitize(email.content, { + ADD_TAGS: ['style', 'table', 'thead', 'tbody', 'tr', 'td', 'th'], + ADD_ATTR: ['colspan', 'rowspan', 'style', 'width', 'height'] + }); + + return ( +
+ ); + }; + + // Format the date + const formatDate = (date: Date | string) => { + if (!date) return ''; + + const dateObj = date instanceof Date ? date : new Date(date); + return dateObj.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // Format email addresses + const formatEmailAddresses = (addresses: Array<{name: string, address: string}> | undefined) => { + if (!addresses || addresses.length === 0) return ''; + + return addresses.map(addr => + addr.name && addr.name !== addr.address + ? `${addr.name} <${addr.address}>` + : addr.address + ).join(', '); + }; + + if (loading || contentLoading) { + return ( +
+
+ +

Loading email content...

+
+
+ ); + } + + if (!email) { + return ( +
+
+

Select an email to view

+
+
+ ); + } + + return ( +
+ {/* Email header */} +
+
+

{email.subject}

+
+
+ From: + {formatEmailAddresses(email.from)} +
+ {formatDate(email.date)} +
+ + {email.to && email.to.length > 0 && ( +
+ To: + {formatEmailAddresses(email.to)} +
+ )} + + {email.cc && email.cc.length > 0 && ( +
+ Cc: + {formatEmailAddresses(email.cc)} +
+ )} +
+ + {/* Action buttons */} + {onReply && ( +
+ + + +
+ )} + + {/* Attachments */} + {email.attachments && email.attachments.length > 0 && ( +
+
Attachments:
+
+ {email.attachments.map((attachment, index) => ( + + + {attachment.filename} + + ({Math.round(attachment.size / 1024)}KB) + + + ))} +
+
+ )} +
+ + {/* Email content */} +
+ {renderContent()} +
+
+ ); +} \ No newline at end of file diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index 55371c5e..b7f1a190 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -133,8 +133,9 @@ export async function getImapConnection(userId: string): Promise { }; return client; - } catch (error) { - throw new Error(`Failed to connect to IMAP server: ${error.message}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to connect to IMAP server: ${errorMessage}`); } } @@ -183,6 +184,16 @@ export async function saveUserEmailCredentials( }); } +// Helper type for IMAP fetch options +interface FetchOptions { + envelope: boolean; + flags: boolean; + bodyStructure: boolean; + internalDate: boolean; + size: boolean; + bodyParts: { part: string; query: any; limit?: number }[]; +} + /** * Get list of emails for a user */ @@ -234,19 +245,21 @@ export async function getEmails( for (const id of messageIds) { try { - const message = await client.fetchOne(id, { + // Define fetch options with proper typing + const fetchOptions: any = { envelope: true, flags: true, bodyStructure: true, internalDate: true, size: true, - bodyParts: [ - { - query: { type: "text" }, - limit: 5000 - } - ] - }); + bodyParts: [{ + part: '1', + query: { type: "text" }, + limit: 5000 + }] + }; + + const message = await client.fetchOne(id, fetchOptions); if (!message) continue; @@ -254,9 +267,14 @@ export async function getEmails( // Extract preview content let preview = ''; - if (bodyParts && bodyParts.length > 0) { - const textPart = bodyParts.find((part: any) => part.type === 'text/plain'); - const htmlPart = bodyParts.find((part: any) => part.type === 'text/html'); + if (bodyParts && typeof bodyParts === 'object') { + // Convert to array if it's a Map + const partsArray = Array.isArray(bodyParts) + ? bodyParts + : Array.from(bodyParts.values()); + + const textPart = partsArray.find((part: any) => part.type === 'text/plain'); + const htmlPart = partsArray.find((part: any) => part.type === 'text/html'); const content = textPart?.content || htmlPart?.content || ''; if (typeof content === 'string') { @@ -597,4 +615,111 @@ export async function testEmailConnection(credentials: EmailCredentials): Promis // Ignore logout errors } } +} + +/** + * Format email for reply/forward + */ +export function formatEmailForReplyOrForward( + email: EmailMessage, + type: 'reply' | 'reply-all' | 'forward' +): { + to: string; + cc?: string; + subject: string; + body: string; +} { + // Format the subject with Re: or Fwd: prefix + const subject = formatSubject(email.subject, type); + + // Create the email quote with proper formatting + const quoteHeader = createQuoteHeader(email); + const quotedContent = email.html || email.text || ''; + + // Format recipients + let to = ''; + let cc = ''; + + if (type === 'reply') { + // Reply to sender only + to = email.from.map(addr => `${addr.name} <${addr.address}>`).join(', '); + } else if (type === 'reply-all') { + // Reply to sender and all recipients + to = email.from.map(addr => `${addr.name} <${addr.address}>`).join(', '); + + // Add all original recipients to CC, except ourselves + const allRecipients = [ + ...(email.to || []), + ...(email.cc || []) + ]; + + cc = allRecipients + .map(addr => `${addr.name} <${addr.address}>`) + .join(', '); + } + + // Format the email body with quote + const body = ` +
+
+
+
${quoteHeader}
+
+ ${quotedContent} +
+
`; + + return { + to, + cc: cc || undefined, + subject, + body + }; +} + +/** + * Format subject with appropriate prefix (Re:, Fwd:) + */ +function formatSubject(subject: string, type: 'reply' | 'reply-all' | 'forward'): string { + // Clean up any existing prefixes + let cleanSubject = subject + .replace(/^(Re|Fwd|FW|Forward):\s*/i, '') + .trim(); + + // Add appropriate prefix + if (type === 'reply' || type === 'reply-all') { + if (!subject.match(/^Re:/i)) { + return `Re: ${cleanSubject}`; + } + } else if (type === 'forward') { + if (!subject.match(/^(Fwd|FW|Forward):/i)) { + return `Fwd: ${cleanSubject}`; + } + } + + return subject; +} + +/** + * Create a quote header for reply/forward + */ +function createQuoteHeader(email: EmailMessage): string { + // Format the date + const date = new Date(email.date); + const formattedDate = date.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + // Format the sender + const sender = email.from[0]; + const fromText = sender?.name + ? `${sender.name} <${sender.address}>` + : sender?.address || 'Unknown sender'; + + return `
On ${formattedDate}, ${fromText} wrote:
`; } \ No newline at end of file