Neah version mail design fix 5
This commit is contained in:
parent
b0dc6f9aed
commit
4b6a901442
@ -666,6 +666,7 @@ export default function MailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Add these improved handlers
|
||||
const handleEmailCheckbox = (e: React.ChangeEvent<HTMLInputElement>, emailId: number) => {
|
||||
e.stopPropagation();
|
||||
if (e.target.checked) {
|
||||
@ -675,457 +676,229 @@ export default function MailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Update the toggleStarred function
|
||||
const toggleStarred = async (emailId: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Update the email in state
|
||||
setEmails(prevEmails =>
|
||||
prevEmails.map(email =>
|
||||
email.id === emailId
|
||||
? { ...email, starred: !email.starred }
|
||||
: email
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mail/toggle-star', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emailId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to toggle star');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling star:', error);
|
||||
// Revert the change if the server request fails
|
||||
setEmails(prevEmails =>
|
||||
prevEmails.map(email =>
|
||||
email.id === emailId
|
||||
? { ...email, starred: !email.starred }
|
||||
: email
|
||||
)
|
||||
);
|
||||
}
|
||||
// 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
|
||||
));
|
||||
};
|
||||
|
||||
// Handle reply with MIME encoding
|
||||
const handleReply = async (type: 'reply' | 'replyAll' | 'forward') => {
|
||||
const selectedEmailData = getSelectedEmail();
|
||||
if (!selectedEmailData) return;
|
||||
|
||||
setShowCompose(true);
|
||||
const subject = `${type === 'forward' ? 'Fwd: ' : 'Re: '}${selectedEmailData.subject}`;
|
||||
let to = '';
|
||||
let cc = '';
|
||||
let content = '';
|
||||
|
||||
// Parse the original email content using MIME decoder
|
||||
const parsedEmail = parseFullEmail(selectedEmailData.body);
|
||||
const decodedBody = parsedEmail?.text || parsedEmail?.html || selectedEmailData.body;
|
||||
|
||||
// Format the date properly
|
||||
const emailDate = new Date(selectedEmailData.date).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
// Handles bulk actions for selected emails
|
||||
const handleBulkAction = (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => {
|
||||
selectedEmails.forEach(emailId => {
|
||||
const email = emails.find(e => e.id.toString() === emailId);
|
||||
if (email) {
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
setEmails(emails.filter(e => e.id.toString() !== emailId));
|
||||
break;
|
||||
case 'mark-read':
|
||||
handleMarkAsRead(emailId, true);
|
||||
break;
|
||||
case 'mark-unread':
|
||||
handleMarkAsRead(emailId, false);
|
||||
break;
|
||||
case 'archive':
|
||||
setEmails(emails.map(e =>
|
||||
e.id.toString() === emailId ? { ...e, folder: 'Archive' } : e
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
switch (type) {
|
||||
case 'reply':
|
||||
to = selectedEmailData.from;
|
||||
content = `\n\nOn ${emailDate}, ${selectedEmailData.fromName} wrote:\n> ${decodedBody.split('\n').join('\n> ')}`;
|
||||
break;
|
||||
|
||||
case 'replyAll':
|
||||
to = selectedEmailData.from;
|
||||
|
||||
// Get our email address from the selected account
|
||||
const ourEmail = accounts.find(acc => acc.id === selectedEmailData.accountId)?.email || '';
|
||||
|
||||
// Handle CC addresses
|
||||
const ccList = new Set<string>();
|
||||
|
||||
// Add original TO recipients (except ourselves and the person we're replying to)
|
||||
selectedEmailData.to.split(',')
|
||||
.map(addr => addr.trim())
|
||||
.filter(addr => addr !== ourEmail && addr !== selectedEmailData.from)
|
||||
.forEach(addr => ccList.add(addr));
|
||||
|
||||
// Add original CC recipients (if any)
|
||||
if (selectedEmailData.cc) {
|
||||
selectedEmailData.cc.split(',')
|
||||
.map(addr => addr.trim())
|
||||
.filter(addr => addr !== ourEmail && addr !== selectedEmailData.from)
|
||||
.forEach(addr => ccList.add(addr));
|
||||
}
|
||||
|
||||
// Convert Set to string
|
||||
cc = Array.from(ccList).join(', ');
|
||||
|
||||
// If we have CC recipients, show the CC field
|
||||
if (cc) {
|
||||
setShowCc(true);
|
||||
}
|
||||
|
||||
content = `\n\nOn ${emailDate}, ${selectedEmailData.fromName} wrote:\n> ${decodedBody.split('\n').join('\n> ')}`;
|
||||
break;
|
||||
|
||||
case 'forward':
|
||||
// Safely handle attachments
|
||||
const attachments = parsedEmail?.attachments || [];
|
||||
const attachmentInfo = attachments.length > 0
|
||||
? '\n\n-------- Attachments --------\n' +
|
||||
attachments.map(att => att.filename).join('\n')
|
||||
: '';
|
||||
|
||||
content = `\n\n---------- Forwarded message ----------\n` +
|
||||
`From: ${selectedEmailData.fromName} <${selectedEmailData.from}>\n` +
|
||||
`Date: ${emailDate}\n` +
|
||||
`Subject: ${selectedEmailData.subject}\n` +
|
||||
`To: ${selectedEmailData.to}\n` +
|
||||
(selectedEmailData.cc ? `Cc: ${selectedEmailData.cc}\n` : '') +
|
||||
`\n\n${decodedBody}${attachmentInfo}`;
|
||||
|
||||
// Handle attachments if present
|
||||
if (attachments.length > 0) {
|
||||
handleForwardedAttachments(attachments);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Set the form state
|
||||
setComposeSubject(subject);
|
||||
setComposeTo(to);
|
||||
setComposeCc(cc);
|
||||
setComposeBody(content);
|
||||
};
|
||||
|
||||
// Handle forwarded attachments
|
||||
const handleForwardedAttachments = (attachments: Array<{
|
||||
filename: string;
|
||||
contentType: string;
|
||||
encoding: string;
|
||||
content: string;
|
||||
}>) => {
|
||||
try {
|
||||
// Store attachment information in state
|
||||
setAttachments(attachments.map(att => ({
|
||||
name: att.filename,
|
||||
type: att.contentType,
|
||||
content: att.content,
|
||||
encoding: att.encoding
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error handling attachments:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Modified send function to handle MIME encoding
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
// Create multipart message
|
||||
const boundary = `----=_Part_${Date.now()}`;
|
||||
let mimeContent = [
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: 7bit',
|
||||
'',
|
||||
composeBody,
|
||||
''
|
||||
];
|
||||
|
||||
// Add attachments if any
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (const attachment of attachments) {
|
||||
mimeContent = mimeContent.concat([
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${attachment.type}`,
|
||||
`Content-Transfer-Encoding: ${attachment.encoding}`,
|
||||
`Content-Disposition: attachment; filename="${attachment.name}"`,
|
||||
'',
|
||||
attachment.content,
|
||||
''
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the multipart message
|
||||
mimeContent.push(`--${boundary}--`);
|
||||
|
||||
// Send the email with MIME content
|
||||
await fetch('/api/mail/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: composeTo,
|
||||
cc: composeCc,
|
||||
bcc: composeBcc,
|
||||
subject: composeSubject,
|
||||
body: mimeContent.join('\r\n'),
|
||||
attachments: attachments
|
||||
})
|
||||
});
|
||||
|
||||
// Clear the form
|
||||
setShowCompose(false);
|
||||
setComposeTo('');
|
||||
setComposeCc('');
|
||||
setComposeBcc('');
|
||||
setComposeSubject('');
|
||||
setComposeBody('');
|
||||
setShowCc(false);
|
||||
setShowBcc(false);
|
||||
setAttachments([]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
setDeleteType('emails');
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
try {
|
||||
if (selectedEmails.length > 0) {
|
||||
await fetch('/api/mail/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emailIds: selectedEmails })
|
||||
});
|
||||
setEmails(emails.filter(email => !selectedEmails.includes(email.id.toString())));
|
||||
setSelectedEmails([]);
|
||||
setShowBulkActions(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting emails:', error);
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
const handleAccountAction = (accountId: number, action: 'edit' | 'delete') => {
|
||||
setShowAccountActions(null);
|
||||
if (action === 'delete') {
|
||||
setDeleteType('account');
|
||||
setItemToDelete(accountId);
|
||||
setShowDeleteConfirm(true);
|
||||
}
|
||||
setSelectedEmails([]);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedEmails.length === emails.length) {
|
||||
setSelectedEmails([]);
|
||||
setShowBulkActions(false);
|
||||
} else {
|
||||
const allEmailIds = emails.map(email => email.id.toString());
|
||||
setSelectedEmails(allEmailIds);
|
||||
setShowBulkActions(true);
|
||||
setSelectedEmails(emails.map(email => email.id.toString()));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setAttachments(Array.from(e.target.files).map(file => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
content: URL.createObjectURL(file),
|
||||
encoding: 'base64'
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
// Add debug logging to help track the filtering
|
||||
useEffect(() => {
|
||||
console.log('Current view:', currentView);
|
||||
console.log('Total emails:', emails.length);
|
||||
console.log('Filter criteria:', {
|
||||
starred: emails.filter(e => e.starred).length,
|
||||
sent: emails.filter(e => e.folder === 'Sent').length,
|
||||
trash: emails.filter(e => e.folder === 'Trash').length,
|
||||
spam: emails.filter(e => e.folder === 'Spam').length,
|
||||
drafts: emails.filter(e => e.folder === 'Drafts').length,
|
||||
archives: emails.filter(e => e.folder === 'Archives' || e.folder === 'Archive').length
|
||||
});
|
||||
}, [currentView, emails]);
|
||||
|
||||
// Add a function to move to trash
|
||||
const moveToTrash = async (emailId: number) => {
|
||||
// Update the email in state
|
||||
setEmails(prevEmails =>
|
||||
prevEmails.map(email =>
|
||||
email.id === emailId
|
||||
? { ...email, read: true, starred: false, folder: 'Trash' }
|
||||
: email
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mail/move-to-trash', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emailId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to move to trash');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving to trash:', error);
|
||||
// Revert the change if the server request fails
|
||||
setEmails(prevEmails =>
|
||||
prevEmails.map(email =>
|
||||
email.id === emailId
|
||||
? { ...email, read: false, starred: true, folder: 'starred' }
|
||||
: email
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Add this debug component to help us see what's happening
|
||||
const DebugInfo = () => {
|
||||
if (process.env.NODE_ENV !== 'development') return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded-lg text-xs">
|
||||
<div>Current view: {currentView}</div>
|
||||
<div>Total emails: {emails.length}</div>
|
||||
<div>Categories present: {
|
||||
[...new Set(emails.map(e => e.folder))].join(', ')
|
||||
}</div>
|
||||
<div>Flags present: {
|
||||
[...new Set(emails.flatMap(e => e.flags || []))].join(', ')
|
||||
}</div>
|
||||
// Update the email list header
|
||||
const renderEmailListHeader = () => (
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={emails.length > 0 && selectedEmails.length === emails.length}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{currentView === 'INBOX' ? 'Inbox' :
|
||||
currentView === 'starred' ? 'Starred' :
|
||||
currentView.charAt(0).toUpperCase() + currentView.slice(1).toLowerCase()}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedEmails.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
onClick={() => {
|
||||
const someUnread = emails.some(email =>
|
||||
selectedEmails.includes(email.id.toString()) && !email.read
|
||||
);
|
||||
handleBulkAction(someUnread ? 'mark-read' : 'mark-unread');
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-4 w-4 mr-1" />
|
||||
{emails.some(email => selectedEmails.includes(email.id.toString()) && !email.read)
|
||||
? 'Mark as Read'
|
||||
: 'Mark as Unread'
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
onClick={() => handleBulkAction('archive')}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-1" />
|
||||
Archive
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
{emails.length} emails
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 checkbox
|
||||
const renderEmailListItem = (email: Email) => (
|
||||
<div
|
||||
key={email.id}
|
||||
className={`flex items-start gap-3 p-2 hover:bg-gray-50/80 cursor-pointer ${
|
||||
selectedEmail?.id === email.id ? 'bg-blue-50/50' : ''
|
||||
} ${!email.read ? 'bg-blue-50/20' : ''}`}
|
||||
onClick={() => handleEmailSelect(email.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedEmails.includes(email.id.toString())}
|
||||
onCheckedChange={(checked) => {
|
||||
const e = { target: { checked }, stopPropagation: () => {} } as React.ChangeEvent<HTMLInputElement>;
|
||||
handleEmailCheckbox(e, email.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`text-sm truncate ${!email.read ? 'font-semibold text-gray-900' : 'text-gray-600'}`}>
|
||||
{currentView === 'Sent' ? email.to : (
|
||||
(() => {
|
||||
// Check if email is in format "name <email>"
|
||||
const fromMatch = email.from.match(/^([^<]+)\s*<([^>]+)>$/);
|
||||
if (fromMatch) {
|
||||
// If we have both name and email, return just the name
|
||||
return fromMatch[1].trim();
|
||||
}
|
||||
// If it's just an email address, return the full email
|
||||
return email.from;
|
||||
})()
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(email.date)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400 hover:text-yellow-400"
|
||||
onClick={(e) => toggleStarred(email.id, e)}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm text-gray-900 truncate">
|
||||
{email.subject || '(No subject)'}
|
||||
</h3>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{(() => {
|
||||
// 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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ |‌|»|«|>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// 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]);
|
||||
// If no preview from parsed content, try direct body
|
||||
if (!preview) {
|
||||
preview = email.body
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ |‌|»|«|>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Add infinite scroll handler
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
if (
|
||||
target.scrollHeight - target.scrollTop === target.clientHeight &&
|
||||
!isLoadingMore &&
|
||||
hasMore
|
||||
) {
|
||||
setPage(prev => prev + 1);
|
||||
loadEmails(true);
|
||||
}
|
||||
}, [isLoadingMore, hasMore]);
|
||||
// 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';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render the email list using sorted emails
|
||||
const renderEmailList = () => (
|
||||
<div className="w-[320px] bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col">
|
||||
{/* Email list header */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={selectedEmails.length > 0 && selectedEmails.length === emails.length}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{currentView === 'INBOX' ? 'Inbox' :
|
||||
currentView === 'starred' ? 'Starred' :
|
||||
currentView.charAt(0).toUpperCase() + currentView.slice(1).toLowerCase()}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedEmails.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
onClick={() => {
|
||||
// Mark as read/unread
|
||||
const isUnread = emails.some(email =>
|
||||
selectedEmails.includes(email.id.toString()) && !email.read
|
||||
);
|
||||
setEmails(emails.map(email =>
|
||||
selectedEmails.includes(email.id.toString())
|
||||
? { ...email, read: !isUnread }
|
||||
: email
|
||||
));
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-4 w-4 mr-1" />
|
||||
{emails.some(email => selectedEmails.includes(email.id.toString()) && !email.read)
|
||||
? 'Mark as Read'
|
||||
: 'Mark as Unread'
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
onClick={() => {
|
||||
// Move to folder (Archive)
|
||||
setEmails(emails.map(email =>
|
||||
selectedEmails.includes(email.id.toString())
|
||||
? { ...email, folder: 'Archive' }
|
||||
: email
|
||||
));
|
||||
setSelectedEmails([]);
|
||||
}}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-1" />
|
||||
Move to
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
{emails.length} emails
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderEmailListHeader()}
|
||||
|
||||
{/* Email list with scroll handler */}
|
||||
<div
|
||||
@ -1143,120 +916,7 @@ export default function MailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{sortedEmails.map((email) => (
|
||||
<div
|
||||
key={email.id}
|
||||
className={`flex items-start gap-3 p-2 hover:bg-gray-50/80 cursor-pointer ${
|
||||
selectedEmail?.id === email.id ? 'bg-blue-50/50' : ''
|
||||
} ${!email.read ? 'bg-blue-50/20' : ''}`}
|
||||
onClick={() => handleEmailSelect(email.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedEmails.includes(email.id.toString())}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedEmails([...selectedEmails, email.id.toString()]);
|
||||
} else {
|
||||
setSelectedEmails(selectedEmails.filter(id => id !== email.id.toString()));
|
||||
}
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`text-sm truncate ${!email.read ? 'font-semibold text-gray-900' : 'text-gray-600'}`}>
|
||||
{currentView === 'Sent' ? email.to : (
|
||||
(() => {
|
||||
// Check if email is in format "name <email>"
|
||||
const fromMatch = email.from.match(/^([^<]+)\s*<([^>]+)>$/);
|
||||
if (fromMatch) {
|
||||
// If we have both name and email, return just the name
|
||||
return fromMatch[1].trim();
|
||||
}
|
||||
// If it's just an email address, return the full email
|
||||
return email.from;
|
||||
})()
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(email.date)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400 hover:text-yellow-400"
|
||||
onClick={(e) => toggleStarred(email.id, e)}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm text-gray-900 truncate">
|
||||
{email.subject || '(No subject)'}
|
||||
</h3>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{(() => {
|
||||
// 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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[^>]*>[\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';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{emails.map((email) => renderEmailListItem(email))}
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-blue-500"></div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user