panel 2 courier api restore
This commit is contained in:
parent
d73bf3b773
commit
29bf70051b
341
components/email/ComposeEmail.tsx
Normal file
341
components/email/ComposeEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
components/email/EmailLayout.tsx
Normal file
354
components/email/EmailLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
components/email/EmailPanel.tsx
Normal file
177
components/email/EmailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
components/email/EmailPreview.tsx
Normal file
165
components/email/EmailPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user