courrier multi account restore compose

This commit is contained in:
alma 2025-04-29 09:47:29 +02:00
parent 9c62add6d2
commit a18751fe2a
4 changed files with 206 additions and 209 deletions

View File

@ -562,32 +562,62 @@ export default function CourrierPage() {
setShowComposeModal(true);
};
// Update handleMailboxChange to properly handle per-account folders
// Update handleMailboxChange to ensure consistent folder naming and prevent race conditions
const handleMailboxChange = (folder: string, accountId?: string) => {
if (accountId && accountId !== 'loading-account') {
const account = accounts.find(a => a.id === accountId);
if (!account) {
toast({
title: "Account not found",
description: `The account ${accountId} could not be found.`,
variant: "destructive",
});
return;
}
// Only allow navigation to folders in selectedAccount.folders
if (!account.folders.includes(folder)) {
toast({
title: "Folder not found",
description: `The folder ${folder} does not exist for this account.`,
variant: "destructive",
});
return;
}
setSelectedFolders(prev => ({ ...prev, [accountId]: folder }));
changeFolder(folder, accountId);
} else {
changeFolder(folder, accountId);
if (!accountId || accountId === 'loading-account') {
// Use a default behavior if no valid accountId is provided
console.warn('No valid accountId provided for folder change');
changeFolder(folder);
return;
}
const account = accounts.find(a => a.id === accountId);
if (!account) {
toast({
title: "Account not found",
description: `The account ${accountId} could not be found.`,
variant: "destructive",
});
return;
}
// Ensure folder has account prefix
const prefixedFolder = folder.includes(':') ? folder : `${accountId}:${folder}`;
// Ensure folder exists in account.folders (either in prefixed or unprefixed form)
const folderExists = account.folders?.some(f =>
f === prefixedFolder || f === folder ||
// Handle case where folder is base name and account folders are prefixed
(folder.includes(':') ? false : `${accountId}:${folder}` === f)
);
if (!folderExists && folder !== 'INBOX') {
toast({
title: "Folder not found",
description: `The folder ${folder} does not exist for this account.`,
variant: "destructive",
});
return;
}
// Update UI state first to prevent flickering
setSelectedAccount(account);
// Use a callback to ensure we have the latest state when updating selectedFolders
setSelectedFolders(prev => {
const updated = { ...prev, [accountId]: prefixedFolder };
console.log('Updated selectedFolders:', updated);
return updated;
});
// Set loading state to provide feedback
setLoading(true);
// Reset page when changing folders
setPage(1);
// Make sure we pass the prefixed folder to change folder
changeFolder(prefixedFolder, accountId);
};
// Update the folder button rendering to show selected state based on account
@ -899,168 +929,4 @@ export default function CourrierPage() {
<p className="text-sm text-gray-500 mt-1">
{searchQuery
? `No results found for "${searchQuery}"`
: `Your ${currentFolder.toLowerCase()} is empty`}
</p>
</div>
</div>
) : (
<EmailList
emails={emails}
selectedEmailIds={selectedEmailIds}
selectedEmail={selectedEmail}
onSelectEmail={(emailId) => handleEmailSelect(emailId, selectedAccount?.id || '', currentFolder)}
onToggleSelect={toggleEmailSelection}
onToggleSelectAll={toggleSelectAll}
onToggleStarred={toggleStarred}
onLoadMore={handleLoadMore}
hasMoreEmails={page < totalPages}
currentFolder={currentFolder}
isLoading={isLoading}
totalEmails={emails.length}
onBulkAction={handleBulkAction}
/>
)}
</div>
</div>
)}
</div>
</div>
{/* Panel 3: Email Detail - Always visible */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Content for Panel 3 based on state but always visible */}
<div className="flex-1 overflow-hidden bg-white">
{selectedEmail ? (
<EmailDetailView
email={selectedEmail as any}
onBack={() => {
handleEmailSelect('', '', '');
// Ensure sidebar stays visible
setSidebarOpen(true);
}}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onToggleStar={() => toggleStarred(selectedEmail.id)}
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<p>Select an email to view or</p>
<button
className="text-primary mt-2 hover:underline"
onClick={() => {
setComposeType('new');
setShowComposeModal(true);
}}
>
Compose a new message
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</main>
{/* Modals and Dialogs */}
<DeleteConfirmDialog
show={showDeleteConfirm}
selectedCount={selectedEmailIds.length}
onConfirm={handleDeleteConfirm}
onCancel={() => setShowDeleteConfirm(false)}
/>
{/* Compose Email Dialog */}
<Dialog open={showComposeModal} onOpenChange={(open) => !open && setShowComposeModal(false)}>
<DialogContent className="sm:max-w-[800px] p-0 h-[80vh]">
<DialogTitle asChild>
<span className="sr-only">New Message</span>
</DialogTitle>
<ComposeEmail
type={composeType}
initialEmail={composeType !== 'new' ? selectedEmail : undefined}
onSend={(emailData) => {
const result = sendEmail(emailData);
return result;
}}
onClose={() => setShowComposeModal(false)}
/>
</DialogContent>
</Dialog>
{/* Edit Password Modal */}
<Dialog open={showEditModal} onOpenChange={open => { if (!open) setShowEditModal(false); }}>
<DialogContent className="sm:max-w-[400px]">
<DialogTitle>Edit Account Password</DialogTitle>
<form onSubmit={async e => {
e.preventDefault();
if (!accountToEdit) return;
setEditLoading(true);
try {
const res = await fetch('/api/courrier/account', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountId: accountToEdit.id, newPassword }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to update password');
toast({ title: 'Password updated', description: 'Password changed successfully.' });
setShowEditModal(false);
setNewPassword('');
window.location.reload();
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to update password', variant: 'destructive' });
} finally {
setEditLoading(false);
}
}}>
<div className="mb-2">
<Label htmlFor="new-password">New Password</Label>
<Input id="new-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required className="mt-1" />
</div>
<div className="flex justify-end gap-2 mt-4">
<Button type="button" variant="outline" onClick={() => setShowEditModal(false)}>Cancel</Button>
<Button type="submit" disabled={editLoading}>{editLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete Account Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={open => { if (!open) setShowDeleteDialog(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this account? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowDeleteDialog(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" disabled={deleteLoading} onClick={async () => {
if (!accountToDelete) return;
setDeleteLoading(true);
try {
const res = await fetch(`/api/courrier/account?accountId=${accountToDelete.id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete account');
toast({ title: 'Account deleted', description: 'The account was deleted successfully.' });
setShowDeleteDialog(false);
window.location.reload();
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete account', variant: 'destructive' });
} finally {
setDeleteLoading(false);
}
}}>Delete</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
: `Your ${currentFolder.toLowerCase()} is empty`

105
app/hooks/use-courrier.ts Normal file
View File

@ -0,0 +1,105 @@
/**
* Change the current folder and load emails from that folder
*/
const changeFolder = async (folder: string, accountId?: string) => {
console.log(`Changing folder to ${folder} for account ${accountId || 'default'}`);
try {
// Reset selected email
setSelectedEmail(null);
setSelectedEmailIds([]);
// Record the new folder
setCurrentFolder(folder);
// Reset search query when changing folders
setSearchQuery('');
// Reset to page 1
setPage(1);
// Clear existing emails before loading new ones to prevent UI flicker
setEmails([]);
// Show loading state
setIsLoading(true);
// Load emails for the new folder with a deliberate delay to allow state to update
await new Promise(resolve => setTimeout(resolve, 100));
await loadEmails(folder, 1, 20, accountId);
} catch (error) {
console.error(`Error changing to folder ${folder}:`, error);
setError(`Failed to load emails from ${folder}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsLoading(false);
}
};
/**
* Load emails for the current folder
*/
const loadEmails = async (
folderOverride?: string,
pageOverride?: number,
perPageOverride?: number,
accountIdOverride?: string
) => {
const folderToUse = folderOverride || currentFolder;
const pageToUse = pageOverride || page;
const perPageToUse = perPageOverride || perPage;
const accountIdToUse = accountIdOverride !== undefined ? accountIdOverride :
folderToUse.includes(':') ? folderToUse.split(':')[0] : undefined;
console.log(`Loading emails: folder=${folderToUse}, page=${pageToUse}, accountId=${accountIdToUse || 'default'}`);
try {
setIsLoading(true);
setError('');
// Construct the API URL with a unique timestamp to prevent caching
let url = `/api/courrier/emails?folder=${encodeURIComponent(folderToUse)}&page=${pageToUse}&perPage=${perPageToUse}`;
// Add accountId parameter if specified
if (accountIdToUse) {
url += `&accountId=${encodeURIComponent(accountIdToUse)}`;
}
// Add cache-busting timestamp
url += `&_t=${Date.now()}`;
console.log(`Fetching emails from API: ${url}`);
const response = await fetch(url);
if (!response.ok) {
let errorText;
try {
const errorData = await response.json();
errorText = errorData.error || `Server error: ${response.status}`;
} catch {
errorText = `HTTP error: ${response.status}`;
}
throw new Error(errorText);
}
const data = await response.json();
if (pageOverride === 1 || !pageOverride) {
// Replace emails when loading first page
setEmails(data.emails);
} else {
// Append emails when loading subsequent pages
setEmails(prev => [...prev, ...data.emails]);
}
// Update pagination info
setTotalPages(data.totalPages);
setMailboxes(data.mailboxes || []);
return data;
} catch (error) {
console.error('Error loading emails:', error);
setError(`Failed to load emails: ${error instanceof Error ? error.message : 'Unknown error'}`);
return null;
} finally {
setIsLoading(false);
}
};

View File

@ -140,18 +140,20 @@ export default function EmailSidebar({
return folder.charAt(0).toUpperCase() + folder.slice(1).toLowerCase();
};
// Render folder button with exact same styling as in courrier page
// Improve the renderFolderButton function to ensure consistent handling
const renderFolderButton = (folder: string, accountId: string) => {
// Get the account prefix from the folder name
const folderAccountId = folder.includes(':') ? folder.split(':')[0] : accountId;
// Ensure folder always has accountId prefix for consistency
const prefixedFolder = folder.includes(':') ? folder : `${accountId}:${folder}`;
// Extract the base folder name and account ID for display and checking
const [folderAccountId, baseFolder] = prefixedFolder.includes(':')
? prefixedFolder.split(':')
: [accountId, folder];
// Only show folders that belong to this account
if (folderAccountId !== accountId) return null;
const isSelected = selectedFolders[accountId] === folder;
// Get the base folder name for display
const baseFolder = folder.includes(':') ? folder.split(':')[1] : folder;
const isSelected = selectedFolders[accountId] === prefixedFolder;
return (
<Button

View File

@ -252,6 +252,43 @@ export const useCourrier = () => {
}
}, [currentFolder, page, perPage, searchQuery, session?.user?.id, toast]);
// Change folder and load emails from that folder
const changeFolder = useCallback(async (folder: string, accountId?: string) => {
console.log(`Changing folder to ${folder} for account ${accountId || 'default'}`);
try {
// Reset selected email
setSelectedEmail(null);
setSelectedEmailIds([]);
// Record the new folder
setCurrentFolder(folder);
// Reset search query when changing folders
setSearchQuery('');
// Reset to page 1
setPage(1);
// Clear existing emails before loading new ones to prevent UI flicker
setEmails([]);
// Show loading state
setIsLoading(true);
// Use a small delay to ensure state updates have propagated
// This helps prevent race conditions when multiple folders are clicked quickly
await new Promise(resolve => setTimeout(resolve, 100));
// Call loadEmails with correct boolean parameter type
await loadEmails(false, accountId);
} catch (error) {
console.error(`Error changing to folder ${folder}:`, error);
setError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setIsLoading(false);
}
}, [loadEmails]);
// Load emails when folder or page changes
useEffect(() => {
if (session?.user?.id) {
@ -528,19 +565,6 @@ export const useCourrier = () => {
}
}, [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);