panel 2 courier api restore

This commit is contained in:
alma 2025-04-26 09:22:51 +02:00
parent d73bf3b773
commit 29bf70051b
5 changed files with 1175 additions and 13 deletions

View File

@ -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<void>;
}
export default function ComposeEmail({
initialEmail,
type = 'new',
onClose,
onSend
}: ComposeEmailProps) {
// Email form state
const [to, setTo] = useState<string>('');
const [cc, setCc] = useState<string>('');
const [bcc, setBcc] = useState<string>('');
const [subject, setSubject] = useState<string>('');
const [body, setBody] = useState<string>('');
// UI state
const [showCc, setShowCc] = useState<boolean>(false);
const [showBcc, setShowBcc] = useState<boolean>(false);
const [sending, setSending] = useState<boolean>(false);
const [attachments, setAttachments] = useState<Array<{
name: string;
content: string;
type: string;
}>>([]);
const editorRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
// Store the HTML content for use in the send function
setBody(e.currentTarget.innerHTML);
};
return (
<Card className="w-full h-full flex flex-col overflow-hidden shadow-lg">
<CardHeader className="border-b py-2 px-4">
<div className="flex justify-between items-center">
<CardTitle className="text-lg">
{type === 'new' ? 'New Message' :
type === 'reply' ? 'Reply' :
type === 'reply-all' ? 'Reply All' :
'Forward'}
</CardTitle>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0 flex-1 flex flex-col overflow-hidden">
{/* Email header fields */}
<div className="p-3 border-b space-y-2">
<div className="flex items-center">
<span className="w-16 text-sm font-medium">To:</span>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0"
placeholder="recipient@example.com"
/>
</div>
{showCc && (
<div className="flex items-center">
<span className="w-16 text-sm font-medium">Cc:</span>
<Input
value={cc}
onChange={(e) => setCc(e.target.value)}
className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0"
placeholder="cc@example.com"
/>
</div>
)}
{showBcc && (
<div className="flex items-center">
<span className="w-16 text-sm font-medium">Bcc:</span>
<Input
value={bcc}
onChange={(e) => setBcc(e.target.value)}
className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0"
placeholder="bcc@example.com"
/>
</div>
)}
{/* CC/BCC controls */}
<div className="flex items-center text-xs">
<button
className="text-primary hover:underline mr-3 flex items-center"
onClick={() => setShowCc(!showCc)}
>
{showCc ? (
<>
<ChevronUp className="h-3 w-3 mr-0.5" />
Hide Cc
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-0.5" />
Show Cc
</>
)}
</button>
<button
className="text-primary hover:underline flex items-center"
onClick={() => setShowBcc(!showBcc)}
>
{showBcc ? (
<>
<ChevronUp className="h-3 w-3 mr-0.5" />
Hide Bcc
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-0.5" />
Show Bcc
</>
)}
</button>
</div>
<div className="flex items-center">
<span className="w-16 text-sm font-medium">Subject:</span>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="flex-1 border-0 shadow-none h-8 focus-visible:ring-0"
placeholder="Subject"
/>
</div>
</div>
{/* Email body editor */}
<div
ref={editorRef}
className="flex-1 overflow-auto p-4 focus:outline-none email-content"
contentEditable={true}
onInput={handleEditorInput}
dangerouslySetInnerHTML={{ __html: body }}
style={{ minHeight: '200px' }}
/>
{/* Attachments list */}
{attachments.length > 0 && (
<div className="border-t p-2">
<div className="text-sm font-medium mb-1">Attachments:</div>
<div className="flex flex-wrap gap-2">
{attachments.map((attachment, index) => (
<div key={index} className="flex items-center gap-1 text-sm bg-muted px-2 py-1 rounded">
<Paperclip className="h-3 w-3" />
<span>{attachment.name}</span>
<button
onClick={() => removeAttachment(index)}
className="ml-1 text-muted-foreground hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
</CardContent>
<CardFooter className="border-t p-3 flex justify-between">
<div>
<input
type="file"
ref={attachmentInputRef}
className="hidden"
onChange={handleFileSelection}
multiple
/>
<Button
variant="outline"
size="sm"
onClick={handleAttachmentClick}
>
<Paperclip className="h-4 w-4 mr-1" />
Attach
</Button>
</div>
<Button
size="sm"
onClick={handleSend}
disabled={sending}
>
{sending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Sending...
</>
) : (
<>
<SendHorizontal className="h-4 w-4 mr-1" />
Send
</>
)}
</Button>
</CardFooter>
</Card>
);
}

View File

@ -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<EmailMessage[]>([]);
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null);
const [currentFolder, setCurrentFolder] = useState<string>('INBOX');
const [folders, setFolders] = useState<string[]>([]);
const [mailboxes, setMailboxes] = useState<string[]>([]);
// UI state
const [loading, setLoading] = useState<boolean>(true);
const [searching, setSearching] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 <Inbox className="h-4 w-4" />;
case 'sent':
case 'sent items':
return <Send className="h-4 w-4" />;
case 'drafts':
return <File className="h-4 w-4" />;
case 'trash':
case 'deleted':
case 'bin':
return <Trash className="h-4 w-4" />;
case 'junk':
case 'spam':
return <ArchiveIcon className="h-4 w-4" />;
default:
return <MailOpen className="h-4 w-4" />;
}
};
return (
<div className={`flex h-full bg-background ${className}`}>
{/* Sidebar */}
<div className="w-64 border-r h-full flex flex-col">
{/* New email button */}
<div className="p-4">
<Button
className="w-full"
onClick={handleComposeNew}
>
<Plus className="h-4 w-4 mr-2" />
New Email
</Button>
</div>
{/* Folder navigation */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{folders.map((folder) => (
<Button
key={folder}
variant={currentFolder === folder ? "secondary" : "ghost"}
className="w-full justify-start"
onClick={() => handleFolderChange(folder)}
>
{getFolderIcon(folder)}
<span className="ml-2">{folder}</span>
</Button>
))}
</div>
</ScrollArea>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col lg:flex-row h-full">
{/* Email list */}
<div className="w-full lg:w-96 border-r h-full flex flex-col overflow-hidden">
{/* Search and refresh */}
<div className="p-2 border-b flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search emails..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* Email list */}
<ScrollArea className="flex-1">
{loading && emails.length === 0 ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : emails.length === 0 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground">
No emails found
</div>
) : (
<div className="divide-y">
{emails.map((email) => (
<div
key={email.id}
className={`p-3 hover:bg-secondary/20 cursor-pointer transition-colors ${
selectedEmailId === email.id ? 'bg-secondary/30' : ''
} ${!email.flags.seen ? 'bg-primary/5' : ''}`}
onClick={() => handleEmailSelect(email.id)}
>
<div className="flex items-start gap-2">
<div className="pt-0.5">
{email.flags.seen ? (
<MailOpen className="h-4 w-4 text-muted-foreground" />
) : (
<Mail className="h-4 w-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-baseline">
<p className={`text-sm truncate font-medium ${!email.flags.seen ? 'text-primary' : ''}`}>
{email.from[0]?.name || email.from[0]?.address || 'Unknown'}
</p>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-2">
{formatDate(email.date.toString())}
</span>
</div>
<p className="text-sm font-medium truncate">{email.subject}</p>
<p className="text-xs text-muted-foreground truncate">{email.preview}</p>
</div>
</div>
{/* Email indicators */}
<div className="flex items-center mt-1 gap-1">
{email.hasAttachments && (
<Badge variant="outline" className="text-xs py-0 h-5">
<span className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 mr-1">
<path fillRule="evenodd" d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z" clipRule="evenodd" />
</svg>
Attachment
</span>
</Badge>
)}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</div>
{/* Email preview */}
<div className="flex-1 h-full overflow-hidden">
<EmailPanel
selectedEmailId={selectedEmailId}
folder={currentFolder}
onSendEmail={handleSendEmail}
/>
</div>
</div>
</div>
);
}

View File

@ -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<void>;
}
export default function EmailPanel({
selectedEmailId,
folder = 'INBOX',
onSendEmail
}: EmailPanelProps) {
const [email, setEmail] = useState<EmailMessage | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Compose mode state
const [isComposing, setIsComposing] = useState<boolean>(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 (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<p>Select an email to view or</p>
<button
className="text-primary mt-2 hover:underline"
onClick={() => {
setComposeType('new');
setIsComposing(true);
}}
>
Compose a new message
</button>
</div>
</div>
);
}
// Show loading state
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p>Loading email...</p>
</div>
</div>
);
}
// Show error state
if (error) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-destructive">
<p>{error}</p>
<button
className="text-primary mt-2 hover:underline"
onClick={() => selectedEmailId && fetchEmail(selectedEmailId)}
>
Try again
</button>
</div>
</div>
);
}
// Show compose mode or email preview
return (
<div className="h-full">
{isComposing ? (
<ComposeEmail
initialEmail={email}
type={composeType}
onClose={handleComposeClose}
onSend={onSendEmail}
/>
) : (
<EmailPreview
email={email}
onReply={handleReply}
/>
)}
</div>
);
}

View File

@ -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<boolean>(false);
// Handle sanitizing and rendering HTML content
const renderContent = () => {
if (!email?.content) return <p>No content available</p>;
// 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 (
<div
className="email-content prose max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
);
};
// 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 (
<div className="flex items-center justify-center h-full p-6">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p>Loading email content...</p>
</div>
</div>
);
}
if (!email) {
return (
<div className="flex items-center justify-center h-full p-6">
<div className="text-center text-muted-foreground">
<p>Select an email to view</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Email header */}
<div className="p-4 border-b">
<div className="mb-3">
<h2 className="text-xl font-semibold mb-2">{email.subject}</h2>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center">
<span className="font-medium mr-1">From:</span>
<span>{formatEmailAddresses(email.from)}</span>
</div>
<span className="text-muted-foreground">{formatDate(email.date)}</span>
</div>
{email.to && email.to.length > 0 && (
<div className="text-sm mt-1">
<span className="font-medium mr-1">To:</span>
<span>{formatEmailAddresses(email.to)}</span>
</div>
)}
{email.cc && email.cc.length > 0 && (
<div className="text-sm mt-1">
<span className="font-medium mr-1">Cc:</span>
<span>{formatEmailAddresses(email.cc)}</span>
</div>
)}
</div>
{/* Action buttons */}
{onReply && (
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="outline"
onClick={() => onReply('reply')}
>
Reply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReply('reply-all')}
>
Reply All
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReply('forward')}
>
Forward
</Button>
</div>
)}
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
<div className="mt-4 border-t pt-2">
<div className="text-sm font-medium mb-2">Attachments:</div>
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, index) => (
<Badge key={index} variant="outline" className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
<span>{attachment.filename}</span>
<span className="text-xs text-muted-foreground ml-1">
({Math.round(attachment.size / 1024)}KB)
</span>
</Badge>
))}
</div>
</div>
)}
</div>
{/* Email content */}
<div className="flex-1 overflow-auto p-4">
{renderContent()}
</div>
</div>
);
}

View File

@ -133,8 +133,9 @@ export async function getImapConnection(userId: string): Promise<ImapFlow> {
};
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 = `
<div>
<br/>
<br/>
<div>${quoteHeader}</div>
<blockquote style="border-left: 2px solid #ccc; padding-left: 10px; margin-left: 10px; color: #777;">
${quotedContent}
</blockquote>
</div>`;
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 `<div>On ${formattedDate}, ${fromText} wrote:</div>`;
}