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) => {
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(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/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(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/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(/&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>
))}
{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>