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) => {
|
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(/ |‌|»|«|>/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(/ |‌|»|«|>/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(/ |‌|»|«|>/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>
|
|
||||||
))}
|
|
||||||
{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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user