From c787d6a1a5da0482ea6f00e7b0d10d638c5b167d Mon Sep 17 00:00:00 2001 From: alma Date: Sun, 27 Apr 2025 18:02:22 +0200 Subject: [PATCH] courrier multi account --- app/api/courrier/account-folders/route.ts | 104 ++++++++++++---------- app/api/courrier/route.ts | 9 +- app/api/courrier/session/route.ts | 74 +++++++++++++-- app/courrier/page.tsx | 71 +++++++++++---- hooks/use-courrier.ts | 39 ++++---- lib/services/email-service.ts | 52 ++++++++++- lib/services/prefetch-service.ts | 7 +- 7 files changed, 264 insertions(+), 92 deletions(-) diff --git a/app/api/courrier/account-folders/route.ts b/app/api/courrier/account-folders/route.ts index 564c5a4d..efbc8695 100644 --- a/app/api/courrier/account-folders/route.ts +++ b/app/api/courrier/account-folders/route.ts @@ -83,70 +83,80 @@ export async function GET(request: Request) { ); } } else { - // Get folders for all accounts + // Get all accounts for this user const accounts = await prisma.mailCredentials.findMany({ where: { userId }, select: { id: true, - email: true + email: true, + password: true, + host: true, + port: true } }); - // For demo purposes, rather than connecting to all accounts, - // get the default set of folders from the first cached account - const credentials = await getCachedEmailCredentials(userId); - - if (!credentials) { + if (accounts.length === 0) { return NextResponse.json({ - accounts: accounts.map(account => ({ - id: account.id, - email: account.email, - folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders - })) + accounts: [] }); } - // Connect to IMAP server - const client = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - tls: { - rejectUnauthorized: false - } - }); - - try { - await client.connect(); - - // Get folders - const folders = await getMailboxes(client); - - // Close connection - await client.logout(); - - return NextResponse.json({ - accounts: accounts.map(account => ({ + // Fetch folders for each account individually + const accountsWithFolders = await Promise.all(accounts.map(async (account) => { + try { + // Connect to IMAP server for this specific account + const client = new ImapFlow({ + host: account.host, + port: account.port, + secure: true, + auth: { + user: account.email, + pass: account.password, + }, + logger: false, + tls: { + rejectUnauthorized: false + } + }); + + await client.connect(); + + // Get folders for this account + const folders = await getMailboxes(client); + + // Close connection + await client.logout(); + + // Add display_name and color from database + const metadata = await prisma.$queryRaw` + SELECT display_name, color + FROM "MailCredentials" + WHERE id = ${account.id} + `; + + const displayMetadata = Array.isArray(metadata) && metadata.length > 0 ? metadata[0] : {}; + + return { id: account.id, email: account.email, + display_name: displayMetadata.display_name || account.email, + color: displayMetadata.color || "#0082c9", folders - })) - }); - } catch (error) { - return NextResponse.json({ - accounts: accounts.map(account => ({ + }; + } catch (error) { + console.error(`Error fetching folders for account ${account.email}:`, error); + // Return fallback folders on error + return { id: account.id, email: account.email, folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders on error - })), - error: error instanceof Error ? error.message : 'Unknown error' - }); - } + }; + } + })); + + return NextResponse.json({ + accounts: accountsWithFolders + }); } } catch (error) { return NextResponse.json( diff --git a/app/api/courrier/route.ts b/app/api/courrier/route.ts index efe91621..9c20a1c2 100644 --- a/app/api/courrier/route.ts +++ b/app/api/courrier/route.ts @@ -35,25 +35,28 @@ export async function GET(request: Request) { const perPage = parseInt(searchParams.get("perPage") || "20"); const folder = searchParams.get("folder") || "INBOX"; const searchQuery = searchParams.get("search") || ""; + const accountId = searchParams.get("accountId") || ""; // Try to get from Redis cache first, but only if it's not a search query if (!searchQuery) { + const cacheKey = accountId ? `${session.user.id}:${accountId}:${folder}` : `${session.user.id}:${folder}`; const cachedEmails = await getCachedEmailList(session.user.id, folder, page, perPage); if (cachedEmails) { - console.log(`Using Redis cached emails for ${session.user.id}:${folder}:${page}:${perPage}`); + console.log(`Using Redis cached emails for ${cacheKey}:${page}:${perPage}`); return NextResponse.json(cachedEmails); } } console.log(`Redis cache miss for ${session.user.id}:${folder}:${page}:${perPage}, fetching emails from IMAP`); - // Use the email service to fetch emails + // Use the email service to fetch emails, passing the accountId if provided const emailsResult = await getEmails( session.user.id, folder, page, perPage, - searchQuery + searchQuery, + accountId ); // The result is already cached in the getEmails function diff --git a/app/api/courrier/session/route.ts b/app/api/courrier/session/route.ts index d1f2ce42..9171a932 100644 --- a/app/api/courrier/session/route.ts +++ b/app/api/courrier/session/route.ts @@ -1,15 +1,71 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { getUserEmailCredentials } from '@/lib/services/email-service'; +import { getUserEmailCredentials, getMailboxes } from '@/lib/services/email-service'; import { prefetchUserEmailData } from '@/lib/services/prefetch-service'; import { getCachedEmailCredentials, getRedisStatus, warmupRedisCache, getCachedImapSession, cacheImapSession } from '@/lib/redis'; import { prisma } from '@/lib/prisma'; +import { ImapFlow } from 'imapflow'; // Keep track of last prefetch time for each user const lastPrefetchMap = new Map(); const PREFETCH_COOLDOWN_MS = 30000; // 30 seconds cooldown between prefetches +// Cache to store account folders to avoid repeated calls to the IMAP server +const accountFoldersCache = new Map(); +const FOLDERS_CACHE_TTL = 5 * 60 * 1000; // 5 minute cache + +/** + * Get folders for a specific account + */ +async function getAccountFolders(accountId: string, account: any): Promise { + // Check cache first + const cacheKey = `folders:${accountId}`; + const cachedData = accountFoldersCache.get(cacheKey); + const now = Date.now(); + + if (cachedData && (now - cachedData.timestamp < FOLDERS_CACHE_TTL)) { + return cachedData.folders; + } + + try { + // Connect to IMAP server for this account + const client = new ImapFlow({ + host: account.host, + port: account.port, + secure: true, + auth: { + user: account.email, + pass: account.password, + }, + logger: false, + tls: { + rejectUnauthorized: false + } + }); + + await client.connect(); + + // Get folders for this account + const folders = await getMailboxes(client); + + // Close connection + await client.logout(); + + // Cache the result + accountFoldersCache.set(cacheKey, { + folders, + timestamp: now + }); + + return folders; + } catch (error) { + console.error(`Error fetching folders for account ${account.email}:`, error); + // Return fallback folders on error + return ['INBOX', 'Sent', 'Drafts', 'Trash']; + } +} + /** * This endpoint is called when the app initializes to check if the user has email credentials * and to start prefetching email data in the background if they do @@ -63,6 +119,7 @@ export async function GET() { select: { id: true, email: true, + password: true, host: true, port: true } @@ -84,10 +141,17 @@ export async function GET() { // Cast the raw result to an array and get the first item const metadata = Array.isArray(rawAccount) ? rawAccount[0] : rawAccount; + // Get folders for this specific account + const accountFolders = await getAccountFolders(account.id, { + ...account, + ...metadata + }); + return { ...account, display_name: metadata?.display_name || account.email, - color: metadata?.color || "#0082c9" + color: metadata?.color || "#0082c9", + folders: accountFolders }; })); @@ -141,7 +205,7 @@ export async function GET() { }); } - // Return all accounts information + // Return all accounts information with their specific folders return NextResponse.json({ authenticated: true, hasEmailCredentials: true, @@ -150,13 +214,13 @@ export async function GET() { prefetchStarted, credentialsSource, lastVisit: cachedSession?.lastVisit, - mailboxes: cachedSession?.mailboxes || [], + mailboxes: cachedSession?.mailboxes || [], // For backward compatibility allAccounts: accountsWithMetadata.map(account => ({ id: account.id, email: account.email, display_name: account.display_name || account.email, color: account.color || "#0082c9", - folders: cachedSession?.mailboxes || [] // Add folders directly to each account + folders: account.folders || [] // Use account-specific folders })) }); } catch (dbError) { diff --git a/app/courrier/page.tsx b/app/courrier/page.tsx index b2b24720..654f790e 100644 --- a/app/courrier/page.tsx +++ b/app/courrier/page.tsx @@ -402,7 +402,13 @@ export default function CourrierPage() { // Also prefetch additional pages to make scrolling smoother if (session?.user?.id) { // Prefetch next 2 pages beyond the current next page - prefetchFolderEmails(session.user.id, currentFolder, 2, nextPage + 1).catch(err => { + prefetchFolderEmails( + session.user.id, + currentFolder, + 2, + nextPage + 1, + selectedAccount?.id + ).catch(err => { console.error(`Error prefetching additional pages for ${currentFolder}:`, err); }); } @@ -465,10 +471,16 @@ export default function CourrierPage() { }; // Handle mailbox change with prefetching - const handleMailboxChange = (folder: string) => { + const handleMailboxChange = (folder: string, accountId?: string) => { // Reset to page 1 when changing folders setPage(1); + // If we have a specific accountId, store it with the folder + if (accountId) { + // Store the current account ID with the folder change + console.log(`Changing folder to ${folder} for account ${accountId}`); + } + // Change folder in the state changeFolder(folder); setCurrentView(folder); @@ -476,7 +488,7 @@ export default function CourrierPage() { // Start prefetching additional pages for this folder if (session?.user?.id && folder) { // First two pages are most important - prefetch immediately - prefetchFolderEmails(session.user.id, folder, 3).catch(err => { + prefetchFolderEmails(session.user.id, folder, 3, 1, accountId).catch(err => { console.error(`Error prefetching ${folder}:`, err); }); } @@ -795,23 +807,51 @@ export default function CourrierPage() { if (selectedAccount?.id === account.id) { setShowFolders(!showFolders); } else { + // When selecting a new account, make sure we show its folders setShowFolders(true); + + // Reset to the inbox folder of the new account by default + if (account.folders && account.folders.length > 0) { + // Find INBOX or default to first folder + const inboxFolder = account.folders.find(f => + f.toLowerCase() === 'inbox') || account.folders[0]; + + // Change to this account's inbox folder + handleMailboxChange(inboxFolder, account.id); + } } + setSelectedAccount(account); // Force the account to have folders if it doesn't already if (account.id !== 'all-accounts' && (!account.folders || account.folders.length === 0)) { - const accountWithFolders = { - ...account, - folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk'] - }; - setSelectedAccount(accountWithFolders); - - // Also update the account in the accounts array - const newAccounts = accounts.map(a => - a.id === account.id ? accountWithFolders : a - ); - setAccounts(newAccounts); + // Fetch folders for this account if not already available + fetch(`/api/courrier/account-folders?accountId=${account.id}`) + .then(res => res.json()) + .then(data => { + if (data.folders && Array.isArray(data.folders)) { + const accountWithFolders = { + ...account, + folders: data.folders + }; + setSelectedAccount(accountWithFolders); + + // Also update the account in the accounts array + const newAccounts = accounts.map(a => + a.id === account.id ? accountWithFolders : a + ); + setAccounts(newAccounts); + } + }) + .catch(err => { + console.error(`Error fetching folders for account ${account.id}:`, err); + // Use default folders if API fails + const accountWithFolders = { + ...account, + folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk'] + }; + setSelectedAccount(accountWithFolders); + }); } }} > @@ -842,7 +882,8 @@ export default function CourrierPage() { }`} onClick={(e) => { e.stopPropagation(); - handleMailboxChange(folder); + // Pass the account ID along with the folder name + handleMailboxChange(folder, selectedAccount?.id); }} >
diff --git a/hooks/use-courrier.ts b/hooks/use-courrier.ts index a4e066c6..42515a6c 100644 --- a/hooks/use-courrier.ts +++ b/hooks/use-courrier.ts @@ -77,7 +77,7 @@ export const useCourrier = () => { const { toast } = useToast(); // Load emails from the server - const loadEmails = useCallback(async (isLoadMore = false) => { + const loadEmails = useCallback(async (isLoadMore = false, accountId?: string) => { if (!session?.user?.id) return; setIsLoading(true); @@ -87,6 +87,22 @@ export const useCourrier = () => { const currentRequestPage = page; try { + // Build query params + const queryParams = new URLSearchParams({ + folder: currentFolder, + page: currentRequestPage.toString(), + perPage: perPage.toString() + }); + + if (searchQuery) { + queryParams.set('search', searchQuery); + } + + // Add accountId if provided + if (accountId) { + queryParams.set('accountId', accountId); + } + // First try Redis cache with low timeout const cachedEmails = await getCachedEmailsWithTimeout(session.user.id, currentFolder, currentRequestPage, perPage, 100); if (cachedEmails) { @@ -157,17 +173,6 @@ export const useCourrier = () => { return; } - // Build query params - const queryParams = new URLSearchParams({ - folder: currentFolder, - page: currentRequestPage.toString(), - perPage: perPage.toString() - }); - - if (searchQuery) { - queryParams.set('search', searchQuery); - } - // Fetch emails from API const response = await fetch(`/api/courrier?${queryParams.toString()}`); @@ -513,12 +518,16 @@ export const useCourrier = () => { } }, [emails, selectedEmailIds]); - // Change the current folder - const changeFolder = useCallback((folder: MailFolder) => { + // Change folder and load emails + const changeFolder = useCallback((folder: string, accountId?: string) => { setCurrentFolder(folder); - setPage(1); + setPage(1); // Reset to first page when changing folders setSelectedEmail(null); setSelectedEmailIds([]); + + // Load the emails for this folder + // The loadEmails function will be called by the useEffect above + // due to the dependency on currentFolder }, []); // Search emails diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index 01ab7bdd..fc0175bb 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -234,20 +234,64 @@ export async function getEmails( folder: string = 'INBOX', page: number = 1, perPage: number = 20, - searchQuery: string = '' + searchQuery: string = '', + accountId?: string ): Promise { // Try to get from cache first if (!searchQuery) { + const cacheKey = accountId ? `${userId}:${accountId}:${folder}` : `${userId}:${folder}`; const cachedResult = await getCachedEmailList(userId, folder, page, perPage); if (cachedResult) { - console.log(`Using cached email list for ${userId}:${folder}:${page}:${perPage}`); + console.log(`Using cached email list for ${cacheKey}:${page}:${perPage}`); return cachedResult; } } - console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}, fetching from IMAP`); + console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}${accountId ? ` for account ${accountId}` : ''}, fetching from IMAP`); + + // If accountId is provided, connect to that specific account + let client: ImapFlow; + + if (accountId) { + try { + // Get account from database + const account = await prisma.mailCredentials.findUnique({ + where: { + id: accountId, + userId + } + }); + + if (!account) { + throw new Error(`Account with ID ${accountId} not found`); + } + + // Connect to IMAP server for this specific account + client = new ImapFlow({ + host: account.host, + port: account.port, + secure: true, // Default to secure connection + auth: { + user: account.email, + pass: account.password, + }, + logger: false, + tls: { + rejectUnauthorized: false + } + }); + + await client.connect(); + } catch (error) { + console.error(`Error connecting to account ${accountId}:`, error); + // Fallback to default connection + client = await getImapConnection(userId); + } + } else { + // Use the default connection logic + client = await getImapConnection(userId); + } - const client = await getImapConnection(userId); let mailboxes: string[] = []; try { diff --git a/lib/services/prefetch-service.ts b/lib/services/prefetch-service.ts index 02c4073b..362b84c4 100644 --- a/lib/services/prefetch-service.ts +++ b/lib/services/prefetch-service.ts @@ -226,9 +226,10 @@ export async function prefetchFolderEmails( userId: string, folder: string, pages: number = 3, - startPage: number = 1 + startPage: number = 1, + accountId?: string ): Promise { - const prefetchKey = `folder:${folder}:${startPage}`; + const prefetchKey = `folder:${folder}:${startPage}${accountId ? `:${accountId}` : ''}`; // Skip if already in progress or in cooldown if (!shouldPrefetch(userId, prefetchKey)) { @@ -236,7 +237,7 @@ export async function prefetchFolderEmails( } try { - console.log(`Prefetching ${pages} pages of emails for folder ${folder} starting from page ${startPage}`); + console.log(`Prefetching ${pages} pages of emails for folder ${folder} starting from page ${startPage}${accountId ? ` for account ${accountId}` : ''}`); // Calculate the range of pages to prefetch const pagesToFetch = Array.from(