Neah version mail design fix 5

This commit is contained in:
alma 2025-04-16 18:48:43 +02:00
parent b0dc6f9aed
commit 4b6a901442

View File

@ -666,6 +666,7 @@ export default function MailPage() {
} }
}; };
// Add these improved handlers
const handleEmailCheckbox = (e: React.ChangeEvent<HTMLInputElement>, emailId: number) => { const handleEmailCheckbox = (e: React.ChangeEvent<HTMLInputElement>, emailId: number) => {
e.stopPropagation(); e.stopPropagation();
if (e.target.checked) { if (e.target.checked) {
@ -675,457 +676,229 @@ export default function MailPage() {
} }
}; };
// Update the toggleStarred function // Handles marking an individual email as read/unread
const toggleStarred = async (emailId: number, e: React.MouseEvent) => { const handleMarkAsRead = (emailId: string, isRead: boolean) => {
e.stopPropagation(); setEmails(emails.map(email =>
email.id.toString() === emailId ? { ...email, read: isRead } : email
// 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
)
);
}
}; };
// Handle reply with MIME encoding // Handles bulk actions for selected emails
const handleReply = async (type: 'reply' | 'replyAll' | 'forward') => { const handleBulkAction = (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => {
const selectedEmailData = getSelectedEmail(); selectedEmails.forEach(emailId => {
if (!selectedEmailData) return; const email = emails.find(e => e.id.toString() === emailId);
if (email) {
setShowCompose(true); switch (action) {
const subject = `${type === 'forward' ? 'Fwd: ' : 'Re: '}${selectedEmailData.subject}`; case 'delete':
let to = ''; setEmails(emails.filter(e => e.id.toString() !== emailId));
let cc = ''; break;
let content = ''; case 'mark-read':
handleMarkAsRead(emailId, true);
// Parse the original email content using MIME decoder break;
const parsedEmail = parseFullEmail(selectedEmailData.body); case 'mark-unread':
const decodedBody = parsedEmail?.text || parsedEmail?.html || selectedEmailData.body; handleMarkAsRead(emailId, false);
break;
// Format the date properly case 'archive':
const emailDate = new Date(selectedEmailData.date).toLocaleString('en-US', { setEmails(emails.map(e =>
weekday: 'short', e.id.toString() === emailId ? { ...e, folder: 'Archive' } : e
year: 'numeric', ));
month: 'short', break;
day: 'numeric', }
hour: '2-digit', }
minute: '2-digit',
hour12: true
}); });
setSelectedEmails([]);
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);
}
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedEmails.length === emails.length) { if (selectedEmails.length === emails.length) {
setSelectedEmails([]); setSelectedEmails([]);
setShowBulkActions(false);
} else { } else {
const allEmailIds = emails.map(email => email.id.toString()); setSelectedEmails(emails.map(email => email.id.toString()));
setSelectedEmails(allEmailIds);
setShowBulkActions(true);
} }
}; };
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => { // Update the email list header
if (e.target.files) { const renderEmailListHeader = () => (
setAttachments(Array.from(e.target.files).map(file => ({ <div className="p-4 border-b border-gray-100">
name: file.name, <div className="flex items-center justify-between">
type: file.type, <div className="flex items-center gap-3">
content: URL.createObjectURL(file), <Checkbox
encoding: 'base64' checked={emails.length > 0 && selectedEmails.length === emails.length}
}))); onCheckedChange={toggleSelectAll}
} className="mt-0.5"
}; />
<h2 className="text-xl font-semibold text-gray-900">
// Add debug logging to help track the filtering {currentView === 'INBOX' ? 'Inbox' :
useEffect(() => { currentView === 'starred' ? 'Starred' :
console.log('Current view:', currentView); currentView.charAt(0).toUpperCase() + currentView.slice(1).toLowerCase()}
console.log('Total emails:', emails.length); </h2>
console.log('Filter criteria:', { </div>
starred: emails.filter(e => e.starred).length, <div className="flex items-center gap-2">
sent: emails.filter(e => e.folder === 'Sent').length, {selectedEmails.length > 0 && (
trash: emails.filter(e => e.folder === 'Trash').length, <>
spam: emails.filter(e => e.folder === 'Spam').length, <Button
drafts: emails.filter(e => e.folder === 'Drafts').length, variant="ghost"
archives: emails.filter(e => e.folder === 'Archives' || e.folder === 'Archive').length size="sm"
}); className="text-gray-600 hover:text-gray-900"
}, [currentView, emails]); onClick={() => {
const someUnread = emails.some(email =>
// Add a function to move to trash selectedEmails.includes(email.id.toString()) && !email.read
const moveToTrash = async (emailId: number) => { );
// Update the email in state handleBulkAction(someUnread ? 'mark-read' : 'mark-unread');
setEmails(prevEmails => }}
prevEmails.map(email => >
email.id === emailId <EyeOff className="h-4 w-4 mr-1" />
? { ...email, read: true, starred: false, folder: 'Trash' } {emails.some(email => selectedEmails.includes(email.id.toString()) && !email.read)
: email ? 'Mark as Read'
) : 'Mark as Unread'
); }
</Button>
try { <Button
const response = await fetch('/api/mail/move-to-trash', { variant="ghost"
method: 'POST', size="sm"
headers: { 'Content-Type': 'application/json' }, className="text-gray-600 hover:text-gray-900"
body: JSON.stringify({ emailId }) onClick={() => handleBulkAction('archive')}
}); >
<Archive className="h-4 w-4 mr-1" />
if (!response.ok) { Archive
throw new Error('Failed to move to trash'); </Button>
} <Button
} catch (error) { variant="ghost"
console.error('Error moving to trash:', error); size="sm"
// Revert the change if the server request fails className="text-red-600 hover:text-red-700"
setEmails(prevEmails => onClick={() => handleBulkAction('delete')}
prevEmails.map(email => >
email.id === emailId <Trash2 className="h-4 w-4 mr-1" />
? { ...email, read: false, starred: true, folder: 'starred' } Delete
: email </Button>
) </>
); )}
} <span className="text-sm text-gray-500 ml-2">
}; {emails.length} emails
</span>
// Add this debug component to help us see what's happening </div>
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>
</div> </div>
); </div>
}; );
// Update sidebar items when available folders change // Update the email list item checkbox
useEffect(() => { const renderEmailListItem = (email: Email) => (
if (availableFolders.length > 0) { <div
const newItems = [ key={email.id}
...initialSidebarItems, className={`flex items-start gap-3 p-2 hover:bg-gray-50/80 cursor-pointer ${
...availableFolders selectedEmail?.id === email.id ? 'bg-blue-50/50' : ''
.filter(folder => !['INBOX'].includes(folder)) // Exclude folders already in initial items } ${!email.read ? 'bg-blue-50/20' : ''}`}
.map(folder => ({ onClick={() => handleEmailSelect(email.id)}
view: folder as MailFolder, >
label: folder.charAt(0).toUpperCase() + folder.slice(1).toLowerCase(), <Checkbox
icon: getFolderIcon(folder), checked={selectedEmails.includes(email.id.toString())}
folder: folder onCheckedChange={(checked) => {
})) const e = { target: { checked }, stopPropagation: () => {} } as React.ChangeEvent<HTMLInputElement>;
]; handleEmailCheckbox(e, email.id);
setSidebarItems(newItems); }}
} onClick={(e) => e.stopPropagation()}
}, [availableFolders]); 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(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// Sort emails by date (most recent first) // If no preview from parsed content, try direct body
const sortedEmails = useMemo(() => { if (!preview) {
return [...emails].sort((a, b) => { preview = email.body
return new Date(b.date).getTime() - new Date(a.date).getTime(); .replace(/<[^>]+>/g, '')
}); .replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
}, [emails]); .replace(/\s+/g, ' ')
.trim();
}
// Add infinite scroll handler // Remove email artifacts and clean up
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { preview = preview
const target = e.currentTarget; .replace(/^>+/gm, '')
if ( .replace(/Content-Type:[^\n]+/g, '')
target.scrollHeight - target.scrollTop === target.clientHeight && .replace(/Content-Transfer-Encoding:[^\n]+/g, '')
!isLoadingMore && .replace(/--[a-zA-Z0-9]+(-[a-zA-Z0-9]+)?/g, '')
hasMore .replace(/boundary=[^\n]+/g, '')
) { .replace(/charset=[^\n]+/g, '')
setPage(prev => prev + 1); .replace(/[\r\n]+/g, ' ')
loadEmails(true); .trim();
}
}, [isLoadingMore, hasMore]); // 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 // Render the email list using sorted emails
const renderEmailList = () => ( const renderEmailList = () => (
<div className="w-[320px] bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col"> <div className="w-[320px] bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col">
{/* Email list header */} {/* Email list header */}
<div className="p-4 border-b border-gray-100"> {renderEmailListHeader()}
<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>
{/* Email list with scroll handler */} {/* Email list with scroll handler */}
<div <div
@ -1143,120 +916,7 @@ export default function MailPage() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{sortedEmails.map((email) => ( {emails.map((email) => renderEmailListItem(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(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// If no preview from parsed content, try direct body
if (!preview) {
preview = email.body
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/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>
))}
{isLoadingMore && ( {isLoadingMore && (
<div className="flex items-center justify-center p-4"> <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> <div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-blue-500"></div>