mail page rest

This commit is contained in:
alma 2025-04-21 14:42:46 +02:00
parent 58fc60904e
commit 1311ffb815
2 changed files with 493 additions and 280 deletions

View File

@ -27,6 +27,7 @@ import {
AlertOctagon, Archive, RefreshCw AlertOctagon, Archive, RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useSession } from 'next-auth/react';
interface Account { interface Account {
id: number; id: number;
@ -40,7 +41,7 @@ interface Email {
id: number; id: number;
accountId: number; accountId: number;
from: string; from: string;
fromName?: string; fromName: string;
to: string; to: string;
subject: string; subject: string;
body: string; body: string;
@ -60,6 +61,31 @@ interface Attachment {
encoding: string; encoding: string;
} }
interface ParsedEmailContent {
text: string | null;
html: string | null;
attachments: Array<{
filename: string;
contentType: string;
encoding: string;
content: string;
}>;
}
interface ParsedEmailMetadata {
subject: string;
from: string;
to: string;
date: string;
contentType: string;
text: string | null;
html: string | null;
raw: {
headers: string;
body: string;
};
}
// Improved MIME Decoder Implementation for Infomaniak // Improved MIME Decoder Implementation for Infomaniak
function extractBoundary(headers: string): string | null { function extractBoundary(headers: string): string | null {
const boundaryMatch = headers.match(/boundary="?([^"\r\n;]+)"?/i) || const boundaryMatch = headers.match(/boundary="?([^"\r\n;]+)"?/i) ||
@ -96,113 +122,144 @@ function decodeQuotedPrintable(text: string, charset: string): string {
} }
} }
function parseFullEmail(emailRaw: string) { function parseFullEmail(emailRaw: string): ParsedEmailContent {
// Check if this is a multipart message by looking for boundary definition console.log('=== parseFullEmail Debug ===');
const boundaryMatch = emailRaw.match(/boundary="?([^"\r\n;]+)"?/i) || console.log('Input email length:', emailRaw.length);
emailRaw.match(/boundary=([^\r\n;]+)/i); console.log('First 200 chars:', emailRaw.substring(0, 200));
if (boundaryMatch) {
const boundary = boundaryMatch[1].trim();
// Check if there's a preamble before the first boundary
let mainHeaders = '';
let mainContent = emailRaw;
// Extract the headers before the first boundary if they exist
const firstBoundaryPos = emailRaw.indexOf('--' + boundary);
if (firstBoundaryPos > 0) {
const headerSeparatorPos = emailRaw.indexOf('\r\n\r\n');
if (headerSeparatorPos > 0 && headerSeparatorPos < firstBoundaryPos) {
mainHeaders = emailRaw.substring(0, headerSeparatorPos);
}
}
return processMultipartEmail(emailRaw, boundary, mainHeaders);
} else {
// This is a single part message
return processSinglePartEmail(emailRaw);
}
}
function processMultipartEmail(emailRaw: string, boundary: string, mainHeaders: string = ''): { // Split headers and body
text: string; const headerBodySplit = emailRaw.split(/\r?\n\r?\n/);
html: string; const headers = headerBodySplit[0];
attachments: { filename: string; contentType: string; encoding: string; content: string; }[]; const body = headerBodySplit.slice(1).join('\n\n');
headers?: string;
} { // Parse content type from headers
const result = { const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)/i);
text: '', const contentType = contentTypeMatch ? contentTypeMatch[1].trim().toLowerCase() : 'text/plain';
html: '',
attachments: [] as { filename: string; contentType: string; encoding: string; content: string; }[], // Initialize result
headers: mainHeaders const result: ParsedEmailContent = {
text: null,
html: null,
attachments: []
}; };
// Split by boundary (more robust pattern) // Handle multipart content
const boundaryRegex = new RegExp(`--${boundary}(?:--)?(\\r?\\n|$)`, 'g'); if (contentType.includes('multipart')) {
const boundaryMatch = emailRaw.match(/boundary="?([^"\r\n;]+)"?/i) ||
// Get all boundary positions emailRaw.match(/boundary=([^\r\n;]+)/i);
const matches = Array.from(emailRaw.matchAll(boundaryRegex));
const boundaryPositions = matches.map(match => match.index!);
// Extract content between boundaries
for (let i = 0; i < boundaryPositions.length - 1; i++) {
const startPos = boundaryPositions[i] + matches[i][0].length;
const endPos = boundaryPositions[i + 1];
if (endPos > startPos) { if (boundaryMatch) {
const partContent = emailRaw.substring(startPos, endPos).trim(); const boundary = boundaryMatch[1].trim();
const parts = emailRaw.split(new RegExp(`--${boundary}(?:--)?(\\r?\\n|$)`));
if (partContent) { for (const part of parts) {
const decoded = processSinglePartEmail(partContent); if (!part.trim()) continue;
if (decoded.contentType.includes('text/plain')) { const partHeaderBodySplit = part.split(/\r?\n\r?\n/);
result.text = decoded.text || ''; const partHeaders = partHeaderBodySplit[0];
} else if (decoded.contentType.includes('text/html')) { const partBody = partHeaderBodySplit.slice(1).join('\n\n');
result.html = cleanHtml(decoded.html || '');
} else if ( const partContentTypeMatch = partHeaders.match(/Content-Type:\s*([^;]+)/i);
decoded.contentType.startsWith('image/') || const partContentType = partContentTypeMatch ? partContentTypeMatch[1].trim().toLowerCase() : 'text/plain';
decoded.contentType.startsWith('application/')
) { if (partContentType.includes('text/plain')) {
const filename = extractFilename(partContent); result.text = decodeEmailBody(partBody, partContentType);
} else if (partContentType.includes('text/html')) {
result.html = decodeEmailBody(partBody, partContentType);
} else if (partContentType.startsWith('image/') || partContentType.startsWith('application/')) {
const filenameMatch = partHeaders.match(/filename="?([^"\r\n;]+)"?/i);
const filename = filenameMatch ? filenameMatch[1] : 'attachment';
result.attachments.push({ result.attachments.push({
filename, filename,
contentType: decoded.contentType, contentType: partContentType,
encoding: decoded.raw?.headers ? parseEmailHeaders(decoded.raw.headers).encoding : '7bit', encoding: 'base64',
content: decoded.raw?.body || '' content: partBody
}); });
} }
} }
} }
} else {
// Single part content
if (contentType.includes('text/html')) {
result.html = decodeEmailBody(body, contentType);
} else {
result.text = decodeEmailBody(body, contentType);
}
} }
// If no content was found, try to extract content directly
if (!result.text && !result.html) {
// Try to extract HTML content
const htmlMatch = emailRaw.match(/<html[^>]*>[\s\S]*?<\/html>/i);
if (htmlMatch) {
result.html = decodeEmailBody(htmlMatch[0], 'text/html');
} else {
// Try to extract plain text
const textContent = emailRaw
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\r\n/g, '\n')
.replace(/=\n/g, '')
.replace(/=3D/g, '=')
.replace(/=09/g, '\t')
.trim();
if (textContent) {
result.text = textContent;
}
}
}
return result; return result;
} }
function processSinglePartEmail(rawEmail: string) { function decodeEmailBody(content: string, contentType: string): string {
// Split headers and body try {
const headerBodySplit = rawEmail.split(/\r?\n\r?\n/); // Remove email client-specific markers
const headers = headerBodySplit[0]; content = content.replace(/\r\n/g, '\n')
const body = headerBodySplit.slice(1).join('\n\n'); .replace(/=\n/g, '')
.replace(/=3D/g, '=')
// Parse headers to get content type, encoding, etc. .replace(/=09/g, '\t');
const emailInfo = parseEmailHeaders(headers);
// If it's HTML content
// Decode the body based on its encoding if (contentType.includes('text/html')) {
const decodedBody = decodeMIME(body, emailInfo.encoding, emailInfo.charset); return extractTextFromHtml(content);
return {
subject: extractHeader(headers, 'Subject'),
from: extractHeader(headers, 'From'),
to: extractHeader(headers, 'To'),
date: extractHeader(headers, 'Date'),
contentType: emailInfo.contentType,
text: emailInfo.contentType.includes('html') ? null : decodedBody,
html: emailInfo.contentType.includes('html') ? decodedBody : null,
raw: {
headers,
body
} }
};
return content;
} catch (error) {
console.error('Error decoding email body:', error);
return content;
}
}
function extractTextFromHtml(html: string): string {
// Remove scripts and style tags
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Convert <br> and <p> to newlines
html = html.replace(/<br[^>]*>/gi, '\n')
.replace(/<p[^>]*>/gi, '\n')
.replace(/<\/p>/gi, '\n');
// Remove all other HTML tags
html = html.replace(/<[^>]+>/g, '');
// Decode HTML entities
html = html.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
// Clean up whitespace
return html.replace(/\n\s*\n/g, '\n\n').trim();
} }
function extractHeader(headers: string, headerName: string): string { function extractHeader(headers: string, headerName: string): string {
@ -380,7 +437,7 @@ function cleanHtml(html: string): string {
function decodeMimeContent(content: string): string { function decodeMimeContent(content: string): string {
if (!content) return ''; if (!content) return '';
// Check if this is an Infomaniak multipart message // Check if this is a multipart message
if (content.includes('Content-Type: multipart/')) { if (content.includes('Content-Type: multipart/')) {
const boundary = content.match(/boundary="([^"]+)"/)?.[1]; const boundary = content.match(/boundary="([^"]+)"/)?.[1];
if (boundary) { if (boundary) {
@ -411,29 +468,96 @@ function decodeMimeContent(content: string): string {
return cleanHtml(content); return cleanHtml(content);
} }
// Add this helper function function renderEmailContent(email: Email) {
const renderEmailContent = (email: Email) => { console.log('=== renderEmailContent Debug ===');
const decodedContent = decodeMimeContent(email.body); console.log('Email ID:', email.id);
if (email.body.includes('Content-Type: text/html')) { console.log('Subject:', email.subject);
return <div dangerouslySetInnerHTML={{ __html: decodedContent }} />; console.log('Body length:', email.body.length);
} console.log('First 100 chars:', email.body.substring(0, 100));
return <div className="whitespace-pre-wrap">{decodedContent}</div>;
};
// Add this helper function try {
const decodeEmailContent = (content: string, charset: string = 'utf-8') => { // First try to parse the full email
return convertCharset(content, charset); const parsed = parseFullEmail(email.body);
}; console.log('Parsed content:', {
hasText: !!parsed.text,
hasHtml: !!parsed.html,
hasAttachments: parsed.attachments.length > 0
});
function cleanEmailContent(content: string): string { // Determine content and type
// Remove or fix malformed URLs let content = '';
return content.replace(/=3D"(http[^"]+)"/g, (match, url) => { let isHtml = false;
try {
return `"${decodeURIComponent(url)}"`; if (parsed.html) {
} catch { // Use our existing MIME decoding for HTML content
return ''; content = decodeMIME(parsed.html, 'quoted-printable', 'utf-8');
isHtml = true;
} else if (parsed.text) {
// Use our existing MIME decoding for plain text content
content = decodeMIME(parsed.text, 'quoted-printable', 'utf-8');
isHtml = false;
} else {
// Try to extract content directly from body using our existing functions
const htmlMatch = email.body.match(/<html[^>]*>[\s\S]*?<\/html>/i);
if (htmlMatch) {
content = decodeMIME(htmlMatch[0], 'quoted-printable', 'utf-8');
isHtml = true;
} else {
// Use our existing text extraction function
content = extractTextFromHtml(email.body);
isHtml = false;
}
} }
});
if (!content) {
console.log('No content available after all attempts');
return <div className="text-gray-500">No content available</div>;
}
// Handle attachments
const attachmentElements = parsed.attachments.map((attachment, index) => (
<div key={index} className="mt-4 p-4 border rounded-lg bg-gray-50">
<div className="flex items-center">
<Paperclip className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-600">{attachment.filename}</span>
</div>
</div>
));
return (
<div className="prose max-w-none dark:prose-invert">
{isHtml ? (
<div
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{
__html: content
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<base[^>]*>/gi, '')
.replace(/<meta[^>]*>/gi, '')
.replace(/<link[^>]*>/gi, '')
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<body[^>]*>/gi, '')
.replace(/<\/body>/gi, '')
.replace(/<html[^>]*>/gi, '')
.replace(/<\/html>/gi, '')
}}
/>
) : (
<div className="whitespace-pre-wrap font-sans text-base leading-relaxed">
{content.split('\n').map((line, i) => (
<p key={i} className="mb-2">{line}</p>
))}
</div>
)}
{attachmentElements}
</div>
);
} catch (e) {
console.error('Error parsing email:', e);
return <div className="text-gray-500">Error displaying email content</div>;
}
} }
// Define the exact folder names from IMAP // Define the exact folder names from IMAP
@ -470,8 +594,9 @@ const initialSidebarItems = [
} }
]; ];
export default function MailPage() { export default function CourrierPage() {
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [accounts, setAccounts] = useState<Account[]>([ const [accounts, setAccounts] = useState<Account[]>([
{ id: 0, name: 'All', email: '', color: 'bg-gray-500' }, { id: 0, name: 'All', email: '', color: 'bg-gray-500' },
@ -514,7 +639,46 @@ export default function MailPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const emailsPerPage = 24; const [isLoadingInitial, setIsLoadingInitial] = useState(true);
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
const [isLoadingCompose, setIsLoadingCompose] = useState(false);
const [isLoadingReply, setIsLoadingReply] = useState(false);
const [isLoadingForward, setIsLoadingForward] = useState(false);
const [isLoadingDelete, setIsLoadingDelete] = useState(false);
const [isLoadingMove, setIsLoadingMove] = useState(false);
const [isLoadingStar, setIsLoadingStar] = useState(false);
const [isLoadingUnstar, setIsLoadingUnstar] = useState(false);
const [isLoadingMarkRead, setIsLoadingMarkRead] = useState(false);
const [isLoadingMarkUnread, setIsLoadingMarkUnread] = useState(false);
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false);
const emailsPerPage = 20;
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Email[]>([]);
const [showSearchResults, setShowSearchResults] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const [composeEmail, setComposeEmail] = useState({
to: '',
subject: '',
body: '',
});
const [isSending, setIsSending] = useState(false);
const [isReplying, setIsReplying] = useState(false);
const [isForwarding, setIsForwarding] = useState(false);
const [replyToEmail, setReplyToEmail] = useState<Email | null>(null);
const [forwardEmail, setForwardEmail] = useState<Email | null>(null);
const [replyBody, setReplyBody] = useState('');
const [forwardBody, setForwardBody] = useState('');
const [replyAttachments, setReplyAttachments] = useState<File[]>([]);
const [forwardAttachments, setForwardAttachments] = useState<File[]>([]);
const [isSendingReply, setIsSendingReply] = useState(false);
const [isSendingForward, setIsSendingForward] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [isStarring, setIsStarring] = useState(false);
const [isUnstarring, setIsUnstarring] = useState(false);
const [isMarkingRead, setIsMarkingRead] = useState(false);
const [isMarkingUnread, setIsMarkingUnread] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Debug logging for email distribution // Debug logging for email distribution
useEffect(() => { useEffect(() => {
@ -584,7 +748,7 @@ export default function MailPage() {
} }
// Process emails keeping exact folder names // Process emails keeping exact folder names
const processedEmails = data.emails.map((email: any) => ({ const processedEmails = (data.emails || []).map((email: any) => ({
id: Number(email.id), id: Number(email.id),
accountId: 1, accountId: 1,
from: email.from || '', from: email.from || '',
@ -654,25 +818,61 @@ export default function MailPage() {
}; };
// Update handleEmailSelect to set selectedEmail correctly // Update handleEmailSelect to set selectedEmail correctly
const handleEmailSelect = (emailId: number) => { const handleEmailSelect = async (emailId: number) => {
const email = emails.find(e => e.id === emailId); const email = emails.find(e => e.id === emailId);
if (email) { if (!email) {
setSelectedEmail(email); console.error('Email not found in list');
if (!email.read) { return;
// Mark as read in state }
setEmails(emails.map(e =>
e.id === emailId ? { ...e, read: true } : e // Set the selected email first to show preview immediately
)); setSelectedEmail(email);
// Update read status on server // Fetch the full email content
fetch('/api/mail/mark-read', { const response = await fetch(`/api/mail/${emailId}`);
method: 'POST', if (!response.ok) {
headers: { 'Content-Type': 'application/json' }, throw new Error('Failed to fetch full email content');
body: JSON.stringify({ emailId }) }
}).catch(error => {
console.error('Error marking email as read:', error); const fullEmail = await response.json();
});
// Update the email in the list and selected email with full content
setEmails(prevEmails => prevEmails.map(email =>
email.id === emailId
? { ...email, body: fullEmail.body }
: email
));
setSelectedEmail(prev => prev ? { ...prev, body: fullEmail.body } : prev);
// Try to mark as read in the background
try {
const markReadResponse = await fetch(`/api/mail/mark-read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session?.user?.id}` // Add session token
},
body: JSON.stringify({
emailId,
isRead: true,
}),
});
if (markReadResponse.ok) {
// Only update the emails list if the API call was successful
setEmails((prevEmails: Email[]) =>
prevEmails.map((email: Email): Email =>
email.id === emailId
? { ...email, read: true }
: email
)
);
} else {
console.error('Failed to mark email as read:', await markReadResponse.text());
} }
} catch (error) {
console.error('Error marking email as read:', error);
} }
}; };
@ -850,20 +1050,20 @@ export default function MailPage() {
// Update the email count in the header to show filtered count // Update the email count in the header to show filtered count
const renderEmailListHeader = () => ( const renderEmailListHeader = () => (
<div className="border-b border-gray-100"> <div className="border-b border-gray-100">
<div className="px-4 py-2"> <div className="px-4 py-1">
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" /> <Search className="absolute left-2 top-2 h-4 w-4 text-gray-400" />
<Input <Input
type="search" type="search"
placeholder="Search in folder..." placeholder="Search in folder..."
className="pl-8 h-9 bg-gray-50" className="pl-8 h-8 bg-gray-50"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between px-4 h-14"> <div className="flex items-center justify-between px-4 h-10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={filteredEmails.length > 0 && selectedEmails.length === filteredEmails.length} checked={filteredEmails.length > 0 && selectedEmails.length === filteredEmails.length}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
@ -1028,39 +1228,7 @@ export default function MailPage() {
</div> </div>
<div className="prose max-w-none"> <div className="prose max-w-none">
{(() => { {renderEmailContent(selectedEmail)}
try {
const parsed = parseFullEmail(selectedEmail.body);
return (
<div>
{/* Display HTML content if available, otherwise fallback to text */}
<div dangerouslySetInnerHTML={{
__html: parsed.html || parsed.text || decodeMimeContent(selectedEmail.body)
}} />
{/* Display attachments if present */}
{parsed.attachments && parsed.attachments.length > 0 && (
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
<div className="grid grid-cols-2 gap-4">
{parsed.attachments.map((attachment, index) => (
<div key={index} className="flex items-center space-x-2 p-2 border rounded">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600 truncate">
{attachment.filename}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
} catch (e) {
console.error('Error parsing email:', e);
return selectedEmail.body;
}
})()}
</div> </div>
</ScrollArea> </ScrollArea>
</> </>
@ -1093,111 +1261,142 @@ export default function MailPage() {
}, [availableFolders]); }, [availableFolders]);
// Update the email list item to match header checkbox alignment // Update the email list item to match header checkbox alignment
const renderEmailListItem = (email: Email) => ( const renderEmailListItem = (email: Email) => {
<div console.log('=== Email List Item Debug ===');
key={email.id} console.log('Email ID:', email.id);
className={`flex items-center gap-3 px-4 py-2 hover:bg-gray-50/80 cursor-pointer ${ console.log('Subject:', email.subject);
selectedEmail?.id === email.id ? 'bg-blue-50/50' : '' console.log('Body length:', email.body.length);
} ${!email.read ? 'bg-blue-50/20' : ''}`} console.log('First 100 chars of body:', email.body.substring(0, 100));
onClick={() => handleEmailSelect(email.id)}
> const preview = generateEmailPreview(email);
<Checkbox console.log('Generated preview:', preview);
checked={selectedEmails.includes(email.id.toString())}
onCheckedChange={(checked) => { return (
const e = { target: { checked }, stopPropagation: () => {} } as React.ChangeEvent<HTMLInputElement>; <div
handleEmailCheckbox(e, email.id); key={email.id}
}} className={`flex items-center gap-3 px-4 py-2 hover:bg-gray-50/80 cursor-pointer ${
onClick={(e) => e.stopPropagation()} selectedEmail?.id === email.id ? 'bg-blue-50/50' : ''
className="mt-0.5" } ${!email.read ? 'bg-blue-50/20' : ''}`}
/> onClick={() => handleEmailSelect(email.id)}
<div className="flex-1 min-w-0"> >
<div className="flex items-center justify-between gap-2"> <Checkbox
<div className="flex items-center gap-2 min-w-0"> checked={selectedEmails.includes(email.id.toString())}
<span className={`text-sm truncate ${!email.read ? 'font-semibold text-gray-900' : 'text-gray-600'}`}> onCheckedChange={(checked) => {
{currentView === 'Sent' ? email.to : ( const e = { target: { checked }, stopPropagation: () => {} } as React.ChangeEvent<HTMLInputElement>;
(() => { handleEmailCheckbox(e, email.id);
const fromMatch = email.from.match(/^([^<]+)\s*<([^>]+)>$/); }}
return fromMatch ? fromMatch[1].trim() : email.from; onClick={(e) => e.stopPropagation()}
})() className="mt-0.5"
)} />
</span> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<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 : (
(() => {
const fromMatch = email.from.match(/^([^<]+)\s*<([^>]+)>$/);
return fromMatch ? fromMatch[1].trim() : 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> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <h3 className="text-sm text-gray-900 truncate">
<span className="text-xs text-gray-500 whitespace-nowrap"> {email.subject || '(No subject)'}
{formatDate(email.date)} </h3>
</span> <div className="text-xs text-gray-500 truncate">
<Button {preview}
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>
</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>
</div> );
); };
const generateEmailPreview = (email: Email): string => {
console.log('=== generateEmailPreview Debug ===');
console.log('Email ID:', email.id);
console.log('Subject:', email.subject);
console.log('Body length:', email.body.length);
console.log('First 200 chars of body:', email.body.substring(0, 200));
try {
const parsed = parseFullEmail(email.body);
console.log('Parsed content:', {
hasText: !!parsed.text,
hasHtml: !!parsed.html,
textPreview: parsed.text?.substring(0, 100) || 'No text',
htmlPreview: parsed.html?.substring(0, 100) || 'No HTML'
});
let preview = '';
if (parsed.text) {
preview = parsed.text;
console.log('Using text content for preview');
} else if (parsed.html) {
preview = parsed.html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
console.log('Using HTML content for preview');
}
if (!preview) {
console.log('No preview from parsed content, using raw body');
preview = email.body
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;|&zwnj;|&raquo;|&laquo;|&gt;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
console.log('Final preview before cleaning:', preview.substring(0, 100) + '...');
// Clean up the preview
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 += '...';
}
console.log('Final preview:', preview);
return preview;
} catch (e) {
console.error('Error generating preview:', e);
return 'No preview available';
}
};
// Render the sidebar navigation // Render the sidebar navigation
const renderSidebarNav = () => ( const renderSidebarNav = () => (
@ -1876,4 +2075,4 @@ export default function MailPage() {
{renderDeleteConfirmDialog()} {renderDeleteConfirmDialog()}
</> </>
); );
} }

View File

@ -1,6 +1,7 @@
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
let client: ImapFlow | null = null; let client: ImapFlow | null = null;
@ -8,19 +9,32 @@ export async function getImapClient() {
if (client) return client; if (client) return client;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.email) { if (!session?.user?.id) {
throw new Error('No authenticated user'); throw new Error('No authenticated user');
} }
const credentials = await prisma.mailCredentials.findUnique({
where: {
userId: session.user.id
}
});
if (!credentials) {
throw new Error('No mail credentials found. Please configure your email account.');
}
client = new ImapFlow({ client = new ImapFlow({
host: process.env.IMAP_HOST || 'imap.gmail.com', host: credentials.host,
port: parseInt(process.env.IMAP_PORT || '993', 10), port: credentials.port,
secure: true, secure: true,
auth: { auth: {
user: session.user.email, user: credentials.email,
pass: session.user.accessToken pass: credentials.password,
}, },
logger: false logger: false,
tls: {
rejectUnauthorized: false
}
}); });
await client.connect(); await client.connect();
@ -55,4 +69,4 @@ export async function markAsRead(emailIds: number[], isRead: boolean) {
} finally { } finally {
lock.release(); lock.release();
} }
} }