599 lines
19 KiB
TypeScript
599 lines
19 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useSession } from 'next-auth/react';
|
|
import { useToast } from './use-toast';
|
|
import { formatEmailForReplyOrForward } from '@/lib/utils/email-formatter';
|
|
import { getCachedEmailsWithTimeout, refreshEmailsInBackground } from '@/lib/services/prefetch-service';
|
|
import { EmailAddress, EmailAttachment } from '@/lib/types';
|
|
|
|
export interface Email {
|
|
id: string;
|
|
from: EmailAddress[];
|
|
to: EmailAddress[];
|
|
cc?: EmailAddress[];
|
|
bcc?: EmailAddress[];
|
|
subject: string;
|
|
content: {
|
|
text: string;
|
|
html: string;
|
|
};
|
|
preview?: string;
|
|
date: Date;
|
|
flags: {
|
|
seen: boolean;
|
|
answered: boolean;
|
|
flagged: boolean;
|
|
draft: boolean;
|
|
deleted: boolean;
|
|
};
|
|
size: number;
|
|
hasAttachments: boolean;
|
|
folder: string;
|
|
contentFetched: boolean;
|
|
accountId?: string;
|
|
messageId?: string;
|
|
attachments?: EmailAttachment[];
|
|
}
|
|
|
|
export interface EmailListResult {
|
|
emails: Email[];
|
|
totalEmails: number;
|
|
page: number;
|
|
perPage: number;
|
|
totalPages: number;
|
|
folder: string;
|
|
mailboxes: string[];
|
|
}
|
|
|
|
export interface EmailData {
|
|
to: string;
|
|
cc?: string;
|
|
bcc?: string;
|
|
subject: string;
|
|
body: string;
|
|
attachments?: Array<{
|
|
name: string;
|
|
content: string;
|
|
type: string;
|
|
}>;
|
|
}
|
|
|
|
export type MailFolder = string;
|
|
|
|
// Hook for managing email operations
|
|
export const useCourrier = () => {
|
|
// State for email data
|
|
const [emails, setEmails] = useState<Email[]>([]);
|
|
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null);
|
|
const [selectedEmailIds, setSelectedEmailIds] = useState<string[]>([]);
|
|
const [currentFolder, setCurrentFolder] = useState<MailFolder>('INBOX');
|
|
const [mailboxes, setMailboxes] = useState<string[]>([]);
|
|
|
|
// State for UI
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(20);
|
|
const [totalEmails, setTotalEmails] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
|
|
// Auth and notifications
|
|
const { data: session } = useSession();
|
|
const { toast } = useToast();
|
|
|
|
// Load emails from the server
|
|
const loadEmails = useCallback(async (isLoadMore = false, accountId?: string) => {
|
|
if (!session?.user?.id) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Construct query parameters
|
|
const queryParams = new URLSearchParams({
|
|
folder: currentFolder,
|
|
page: page.toString(),
|
|
perPage: perPage.toString()
|
|
});
|
|
|
|
if (searchQuery) {
|
|
queryParams.set('search', searchQuery);
|
|
}
|
|
|
|
// Add accountId to query params if provided
|
|
if (accountId) {
|
|
queryParams.set('accountId', accountId);
|
|
}
|
|
|
|
// Try to get cached emails first
|
|
const currentRequestPage = page;
|
|
const cachedEmails = await getCachedEmailsWithTimeout(
|
|
session.user.id,
|
|
accountId || 'default',
|
|
currentFolder,
|
|
currentRequestPage,
|
|
perPage,
|
|
accountId && accountId !== 'all-accounts' && accountId !== 'loading-account' ? accountId : undefined
|
|
);
|
|
|
|
if (cachedEmails) {
|
|
// Ensure cached data has emails array property
|
|
if (Array.isArray(cachedEmails.emails)) {
|
|
if (isLoadMore) {
|
|
// When loading more, always append to the existing list
|
|
setEmails(prevEmails => {
|
|
// Create a Set of existing email IDs to avoid duplicates
|
|
const existingIds = new Set(prevEmails.map(email => email.id));
|
|
// Filter out any duplicates before appending
|
|
const newEmails = cachedEmails.emails.filter((email: Email) => !existingIds.has(email.id));
|
|
|
|
// Log pagination info
|
|
console.log(`Added ${newEmails.length} cached emails from page ${currentRequestPage} to existing ${prevEmails.length} emails`);
|
|
|
|
// Combine emails and sort them by date (newest first)
|
|
const combinedEmails = [...prevEmails, ...newEmails];
|
|
return combinedEmails.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
});
|
|
} else {
|
|
// For initial load, replace emails
|
|
console.log(`Setting ${cachedEmails.emails.length} cached emails for page ${currentRequestPage}`);
|
|
// Ensure emails are sorted by date (newest first)
|
|
setEmails(cachedEmails.emails.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime()));
|
|
}
|
|
|
|
// Set pagination info from cache if available
|
|
if (cachedEmails.totalEmails) setTotalEmails(cachedEmails.totalEmails);
|
|
if (cachedEmails.totalPages) setTotalPages(cachedEmails.totalPages);
|
|
|
|
// Update available mailboxes if provided
|
|
if (cachedEmails.mailboxes && cachedEmails.mailboxes.length > 0) {
|
|
setMailboxes(cachedEmails.mailboxes);
|
|
}
|
|
} else if (Array.isArray(cachedEmails)) {
|
|
// Direct array response
|
|
if (isLoadMore) {
|
|
setEmails(prevEmails => {
|
|
// Create a Set of existing email IDs to avoid duplicates
|
|
const existingIds = new Set(prevEmails.map(email => email.id));
|
|
// Filter out any duplicates before appending
|
|
const newEmails = cachedEmails.filter((email: Email) => !existingIds.has(email.id));
|
|
|
|
// Log pagination info
|
|
console.log(`Added ${newEmails.length} cached emails from page ${currentRequestPage} to existing ${prevEmails.length} emails`);
|
|
|
|
// Combine emails and sort them by date (newest first)
|
|
const combinedEmails = [...prevEmails, ...newEmails];
|
|
return combinedEmails.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
});
|
|
} else {
|
|
// For initial load, replace emails
|
|
console.log(`Setting ${cachedEmails.length} cached emails for page ${currentRequestPage}`);
|
|
// Ensure emails are sorted by date (newest first)
|
|
setEmails(cachedEmails.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime()));
|
|
}
|
|
} else {
|
|
console.warn('Invalid cache format:', cachedEmails);
|
|
}
|
|
|
|
setIsLoading(false);
|
|
|
|
// Still refresh in background for fresh data
|
|
refreshEmailsInBackground(session.user.id, currentFolder, currentRequestPage, perPage).catch(err => {
|
|
console.error('Background refresh error:', err);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Fetch emails from API
|
|
const response = await fetch(`/api/courrier?${queryParams.toString()}`);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to fetch emails');
|
|
}
|
|
|
|
const data: EmailListResult = await response.json();
|
|
|
|
// Update state with the fetched data
|
|
if (isLoadMore) {
|
|
setEmails(prev => {
|
|
// Create a Set of existing email IDs to avoid duplicates
|
|
const existingIds = new Set(prev.map(email => email.id));
|
|
// Filter out any duplicates before appending
|
|
const newEmails = data.emails.filter((email: Email) => !existingIds.has(email.id));
|
|
|
|
// Log pagination info
|
|
console.log(`Added ${newEmails.length} fetched emails from page ${currentRequestPage} to existing ${prev.length} emails`);
|
|
|
|
// Combine emails and sort them by date (newest first)
|
|
const combinedEmails = [...prev, ...newEmails];
|
|
return combinedEmails.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
});
|
|
} else {
|
|
// Ensure we always set an array even if API returns invalid data
|
|
console.log(`Setting ${data.emails?.length || 0} fetched emails for page ${currentRequestPage}`);
|
|
// Ensure emails are sorted by date (newest first)
|
|
if (Array.isArray(data.emails)) {
|
|
setEmails(data.emails.sort((a: Email, b: Email) => new Date(b.date).getTime() - new Date(a.date).getTime()));
|
|
} else {
|
|
setEmails([]);
|
|
}
|
|
}
|
|
|
|
setTotalEmails(data.totalEmails);
|
|
setTotalPages(data.totalPages);
|
|
|
|
// Update available mailboxes if provided
|
|
if (data.mailboxes && data.mailboxes.length > 0) {
|
|
setMailboxes(data.mailboxes);
|
|
}
|
|
|
|
// Clear selection if not loading more
|
|
if (!isLoadMore) {
|
|
setSelectedEmail(null);
|
|
setSelectedEmailIds([]);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error loading emails for page ${page}:`, err);
|
|
// Set emails to empty array on error to prevent runtime issues
|
|
if (!isLoadMore) {
|
|
setEmails([]);
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Failed to load emails');
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error",
|
|
description: err instanceof Error ? err.message : 'Failed to load emails'
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [currentFolder, page, perPage, searchQuery, session?.user?.id, toast]);
|
|
|
|
// Load emails when folder or page changes
|
|
useEffect(() => {
|
|
if (session?.user?.id) {
|
|
// If page is greater than 1, we're loading more emails
|
|
const isLoadingMore = page > 1;
|
|
loadEmails(isLoadingMore);
|
|
|
|
// If we're loading the first page, publish an event to reset scroll position
|
|
if (page === 1 && typeof window !== 'undefined') {
|
|
// Use a custom event to communicate with the EmailList component
|
|
const event = new CustomEvent('reset-email-scroll');
|
|
window.dispatchEvent(event);
|
|
}
|
|
}
|
|
}, [currentFolder, page, perPage, session?.user?.id, loadEmails]);
|
|
|
|
// Fetch a single email's content
|
|
const fetchEmailContent = useCallback(async (emailId: string, accountId?: string, folderOverride?: string) => {
|
|
try {
|
|
const folderToUse = folderOverride || currentFolder;
|
|
const query = new URLSearchParams({
|
|
folder: folderToUse,
|
|
});
|
|
if (accountId) query.set('accountId', accountId);
|
|
const response = await fetch(`/api/courrier/${emailId}?${query.toString()}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch email content: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Error fetching email content:', error);
|
|
throw error;
|
|
}
|
|
}, [currentFolder]);
|
|
|
|
// Select an email to view
|
|
const handleEmailSelect = useCallback(async (emailId: string, accountId?: string, folderOverride?: string) => {
|
|
setIsLoading(true);
|
|
try {
|
|
// Find the email in the current list
|
|
const email = emails.find(e => e.id === emailId && (!accountId || e.accountId === accountId) && (!folderOverride || e.folder === folderOverride));
|
|
if (!email) {
|
|
throw new Error('Email not found');
|
|
}
|
|
// If content is not fetched, get the full content
|
|
if (!email.contentFetched) {
|
|
const fullEmail = await fetchEmailContent(emailId, accountId, folderOverride);
|
|
// Merge the full content with the email
|
|
const updatedEmail = {
|
|
...email,
|
|
content: fullEmail.content,
|
|
attachments: fullEmail.attachments,
|
|
contentFetched: true
|
|
};
|
|
// Update the email in the list
|
|
setEmails(emails.map(e => e.id === emailId ? updatedEmail : e));
|
|
setSelectedEmail(updatedEmail);
|
|
} else {
|
|
setSelectedEmail(email);
|
|
}
|
|
// Mark the email as read if it's not already
|
|
if (!email.flags.seen) {
|
|
markEmailAsRead(emailId, true);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error selecting email:', err);
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error",
|
|
description: "Could not load email content"
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [emails, fetchEmailContent, toast]);
|
|
|
|
// Mark an email as read/unread
|
|
const markEmailAsRead = useCallback(async (emailId: string, isRead: boolean) => {
|
|
try {
|
|
const response = await fetch(`/api/courrier/${emailId}/mark-read`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
isRead,
|
|
folder: currentFolder
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to mark email as read');
|
|
}
|
|
|
|
// Update the email in the list
|
|
setEmails(emails.map(email =>
|
|
email.id === emailId ? { ...email, flags: { ...email.flags, seen: isRead } } : email
|
|
));
|
|
|
|
// If the selected email is the one being marked, update it too
|
|
if (selectedEmail && selectedEmail.id === emailId) {
|
|
setSelectedEmail({ ...selectedEmail, flags: { ...selectedEmail.flags, seen: isRead } });
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error marking email as read:', error);
|
|
return false;
|
|
}
|
|
}, [emails, selectedEmail, currentFolder]);
|
|
|
|
// Toggle starred status for an email
|
|
const toggleStarred = useCallback(async (emailId: string) => {
|
|
const email = emails.find(e => e.id === emailId);
|
|
if (!email) return;
|
|
|
|
const newStarredStatus = !email.flags.flagged;
|
|
|
|
try {
|
|
const response = await fetch(`/api/courrier/${emailId}/star`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
starred: newStarredStatus,
|
|
folder: currentFolder
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to toggle star status');
|
|
}
|
|
|
|
// Update the email in the list
|
|
setEmails(emails.map(email =>
|
|
email.id === emailId ? { ...email, flags: { ...email.flags, flagged: newStarredStatus } } : email
|
|
));
|
|
|
|
// If the selected email is the one being starred, update it too
|
|
if (selectedEmail && selectedEmail.id === emailId) {
|
|
setSelectedEmail({ ...selectedEmail, flags: { ...selectedEmail.flags, flagged: newStarredStatus } });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling star status:', error);
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error",
|
|
description: "Could not update star status"
|
|
});
|
|
}
|
|
}, [emails, selectedEmail, currentFolder, toast]);
|
|
|
|
// Send an email
|
|
const sendEmail = useCallback(async (emailData: EmailData) => {
|
|
if (!session?.user?.id) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error",
|
|
description: "You must be logged in to send emails"
|
|
});
|
|
return { success: false, error: "Not authenticated" };
|
|
}
|
|
|
|
setIsSending(true);
|
|
|
|
try {
|
|
const response = await fetch('/api/courrier/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(emailData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(result.error || 'Failed to send email');
|
|
}
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: "Email sent successfully"
|
|
});
|
|
|
|
return { success: true, messageId: result.messageId };
|
|
} catch (error) {
|
|
console.error('Error sending email:', error);
|
|
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error",
|
|
description: error instanceof Error ? error.message : 'Failed to send email'
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to send email'
|
|
};
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
}, [session?.user?.id, toast]);
|
|
|
|
// Delete selected emails
|
|
const deleteEmails = useCallback(async (emailIds: string[]) => {
|
|
if (emailIds.length === 0) return;
|
|
|
|
setIsDeleting(true);
|
|
|
|
try {
|
|
const response = await fetch('/api/courrier/delete', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
emailIds,
|
|
folder: currentFolder
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to delete emails');
|
|
}
|
|
|
|
// Remove the deleted emails from the list
|
|
setEmails(emails.filter(email => !emailIds.includes(email.id)));
|
|
|
|
// Clear selection if the selected email was deleted
|
|
if (selectedEmail && emailIds.includes(selectedEmail.id)) {
|
|
setSelectedEmail(null);
|
|
}
|
|
|
|
// Clear selected IDs
|
|
setSelectedEmailIds([]);
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: `${emailIds.length} email(s) deleted`
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting emails:', error);
|
|
|
|
toast({
|
|
variant: "destructive",
|
|
title: "Error",
|
|
description: "Failed to delete emails"
|
|
});
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
}, [emails, selectedEmail, currentFolder, toast]);
|
|
|
|
// Toggle selection of an email
|
|
const toggleEmailSelection = useCallback((emailId: string) => {
|
|
setSelectedEmailIds(prev => {
|
|
if (prev.includes(emailId)) {
|
|
return prev.filter(id => id !== emailId);
|
|
} else {
|
|
return [...prev, emailId];
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// Select all emails
|
|
const toggleSelectAll = useCallback(() => {
|
|
if (selectedEmailIds.length === emails.length) {
|
|
setSelectedEmailIds([]);
|
|
} else {
|
|
setSelectedEmailIds(emails.map(email => email.id));
|
|
}
|
|
}, [emails, selectedEmailIds]);
|
|
|
|
// Change folder and load emails
|
|
const changeFolder = useCallback((folder: string, accountId?: string) => {
|
|
setCurrentFolder(folder);
|
|
setPage(1); // Reset to first page when changing folders
|
|
setSelectedEmail(null);
|
|
setSelectedEmailIds([]);
|
|
|
|
// Load the emails for this folder with the correct accountId
|
|
if (session?.user?.id) {
|
|
loadEmails(false, accountId);
|
|
}
|
|
}, [session?.user?.id, loadEmails]);
|
|
|
|
// Search emails
|
|
const searchEmails = useCallback((query: string) => {
|
|
setSearchQuery(query);
|
|
setPage(1);
|
|
loadEmails();
|
|
}, [loadEmails]);
|
|
|
|
// Format an email for reply or forward
|
|
const formatEmailForAction = useCallback((email: Email) => {
|
|
return {
|
|
id: email.id,
|
|
subject: email.subject,
|
|
from: email.from[0]?.address || '',
|
|
to: email.to.map(addr => addr.address).join(', '),
|
|
cc: email.cc?.map(addr => addr.address).join(', '),
|
|
bcc: email.bcc?.map(addr => addr.address).join(', '),
|
|
content: email.content.text || email.content.html || '',
|
|
attachments: email.attachments
|
|
};
|
|
}, []);
|
|
|
|
// Return all the functionality and state values
|
|
return {
|
|
// Data
|
|
emails,
|
|
selectedEmail,
|
|
selectedEmailIds,
|
|
currentFolder,
|
|
mailboxes,
|
|
isLoading,
|
|
isSending,
|
|
isDeleting,
|
|
error,
|
|
searchQuery,
|
|
page,
|
|
perPage,
|
|
totalEmails,
|
|
totalPages,
|
|
|
|
// Functions
|
|
loadEmails,
|
|
handleEmailSelect,
|
|
markEmailAsRead,
|
|
toggleStarred,
|
|
sendEmail,
|
|
deleteEmails,
|
|
toggleEmailSelection,
|
|
toggleSelectAll,
|
|
changeFolder,
|
|
searchEmails,
|
|
formatEmailForAction,
|
|
setPage,
|
|
setPerPage,
|
|
setSearchQuery,
|
|
};
|
|
};
|