From 6ab432e0ffd2d297e9459bf56c405c2d35f606f2 Mon Sep 17 00:00:00 2001 From: alma Date: Mon, 28 Apr 2025 12:23:57 +0200 Subject: [PATCH] courrier multi account restore compose --- app/api/courrier/session/route.ts | 12 +- app/courrier/page.tsx | 177 ++++++++++++++++++++---------- lib/redis.ts | 171 +++++++++++++++++++---------- lib/services/email-service.ts | 36 +++--- lib/types.ts | 24 ++-- 5 files changed, 264 insertions(+), 156 deletions(-) diff --git a/app/api/courrier/session/route.ts b/app/api/courrier/session/route.ts index 2ccfd6dc..9171a932 100644 --- a/app/api/courrier/session/route.ts +++ b/app/api/courrier/session/route.ts @@ -131,8 +131,6 @@ export async function GET() { // Get additional fields that might be in the database but not in the type const accountsWithMetadata = await Promise.all(allAccounts.map(async (account) => { - console.log(`[DEBUG] Processing metadata for account ${account.email}`); - // Get the raw account data to access fields not in the type const rawAccount = await prisma.$queryRaw` SELECT display_name, color @@ -142,7 +140,6 @@ export async function GET() { // Cast the raw result to an array and get the first item const metadata = Array.isArray(rawAccount) ? rawAccount[0] : rawAccount; - console.log(`[DEBUG] Metadata for ${account.email}:`, metadata); // Get folders for this specific account const accountFolders = await getAccountFolders(account.id, { @@ -150,8 +147,6 @@ export async function GET() { ...metadata }); - console.log(`[DEBUG] Found ${accountFolders.length} folders for ${account.email}`); - return { ...account, display_name: metadata?.display_name || account.email, @@ -160,12 +155,7 @@ export async function GET() { }; })); - console.log(`[DEBUG] Final processed accounts:`, accountsWithMetadata.map(acc => ({ - id: acc.id, - email: acc.email, - display_name: acc.display_name, - folderCount: acc.folders?.length || 0 - }))); + console.log(`Found ${allAccounts.length} email accounts for user ${userId}`); // If not in cache and no accounts found, check database for single account (backward compatibility) if (!credentials && allAccounts.length === 0) { diff --git a/app/courrier/page.tsx b/app/courrier/page.tsx index 408ce776..208547f3 100644 --- a/app/courrier/page.tsx +++ b/app/courrier/page.tsx @@ -125,7 +125,8 @@ export default function CourrierPage() { // Email accounts for the sidebar const [accounts, setAccounts] = useState([ - { id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' } + { id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' }, + { id: 'loading-account', name: 'Loading...', email: '', color: 'bg-blue-500', folders: [] } ]); const [selectedAccount, setSelectedAccount] = useState(null); @@ -136,16 +137,17 @@ export default function CourrierPage() { useEffect(() => { console.log('Mailboxes updated:', mailboxes); setAccounts(prev => { - // Keep the "All" account and update the actual email account - const updated = [prev[0]]; // Keep the "All" account - if (prev.length > 1) { - // Update the existing email account - updated.push({ - ...prev[1], - folders: mailboxes - }); + const updated = [...prev]; + if (updated.length > 1) { + // Only update folders, preserve other properties including ID + if (updated[1]) { + updated[1] = { + ...updated[1], + folders: mailboxes + }; + } + console.log('Updated accounts with new mailboxes:', updated); } - console.log('Updated accounts with new mailboxes:', updated); return updated; }); }, [mailboxes]); @@ -194,10 +196,11 @@ export default function CourrierPage() { if (!accounts || accounts.length === 0) { console.warn('Accounts array is empty, restoring defaults'); setAccounts([ - { id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' } + { id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' }, + { id: 'loading-account', name: 'Loading...', email: '', color: 'bg-blue-500', folders: mailboxes } ]); } - }, [accounts]); + }, [accounts, mailboxes]); // Initialize session and start prefetching useEffect(() => { @@ -245,52 +248,100 @@ export default function CourrierPage() { console.log('allAccounts is array:', Array.isArray(data.allAccounts)); console.log('allAccounts length:', data.allAccounts?.length || 0); + // Inspect each account's structure + if (data.allAccounts && Array.isArray(data.allAccounts)) { + data.allAccounts.forEach((account: any, idx: number) => { + console.log(`Account ${idx + 1}:`, { + id: account.id, + email: account.email, + display_name: account.display_name, + foldersExist: !!account.folders, + foldersIsArray: Array.isArray(account.folders), + foldersLength: account.folders?.length || 0, + folders: account.folders + }); + }); + } + + if (!isMounted) return; + if (data.authenticated) { if (data.hasEmailCredentials) { console.log('Session initialized, prefetch status:', data.prefetchStarted ? 'running' : 'not started'); setPrefetchStarted(Boolean(data.prefetchStarted)); // Create a copy of the current accounts to update - let updatedAccounts: Account[] = [{ id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' }]; + const updatedAccounts = [...accounts]; // Check if we have multiple accounts returned if (data.allAccounts && Array.isArray(data.allAccounts) && data.allAccounts.length > 0) { console.log('[DEBUG] Multiple accounts found:', data.allAccounts.length); - // Process each account + // First, validate the structure of each account data.allAccounts.forEach((account: any, index: number) => { - console.log(`[DEBUG] Processing account ${index + 1}:`, { + console.log(`[DEBUG] Account ${index+1} structure check:`, { id: account.id, email: account.email, display_name: account.display_name, hasFolders: !!account.folders, foldersIsArray: Array.isArray(account.folders), - foldersCount: Array.isArray(account.folders) ? account.folders.length : 0 + foldersCount: Array.isArray(account.folders) ? account.folders.length : 0, + folders: account.folders || [] }); + }); + + // Keep the All account at position 0 + if (updatedAccounts.length > 0) { + // Replace the loading account with the real one + data.allAccounts.forEach((account: any, index: number) => { + // Ensure folders are valid + const accountFolders = (account.folders && Array.isArray(account.folders)) + ? account.folders + : (data.mailboxes && Array.isArray(data.mailboxes)) + ? data.mailboxes + : ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']; + + // If we're updating the first real account (index 0 in API, position 1 in our array) + if (index === 0 && updatedAccounts.length > 1) { + // Update the loading account in place to maintain references + updatedAccounts[1] = { + id: account.id, // Use the real account ID + name: account.display_name || account.email, + email: account.email, + color: account.color || 'bg-blue-500', + folders: accountFolders + }; + console.log(`[DEBUG] Updated loading account to real account: ${account.email} with ID ${account.id}`); + } else { + // Add additional accounts as new entries + updatedAccounts.push({ + id: account.id || `account-${index}`, + name: account.display_name || account.email, + email: account.email, + color: account.color || 'bg-blue-500', + folders: accountFolders + }); + } + }); + } else { + // Fallback if accounts array is empty for some reason + updatedAccounts.push({ id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' }); - // Ensure folders are valid - const accountFolders = (account.folders && Array.isArray(account.folders)) - ? account.folders + const firstAccount = data.allAccounts[0]; + const accountFolders = (firstAccount.folders && Array.isArray(firstAccount.folders)) + ? firstAccount.folders : (data.mailboxes && Array.isArray(data.mailboxes)) ? data.mailboxes : ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']; - // Add the account to the list updatedAccounts.push({ - id: account.id || `account-${index}`, - name: account.display_name || account.email, - email: account.email, - color: account.color || 'bg-blue-500', + id: firstAccount.id, + name: firstAccount.display_name || firstAccount.email, + email: firstAccount.email, + color: firstAccount.color || 'bg-blue-500', folders: accountFolders }); - }); - - console.log('[DEBUG] Final accounts array:', updatedAccounts.map(acc => ({ - id: acc.id, - name: acc.name, - email: acc.email, - folderCount: acc.folders?.length || 0 - }))); + } } else if (data.email) { // Fallback to single account if allAccounts is not available console.log(`[DEBUG] Fallback to single account: ${data.email}`); @@ -302,14 +353,26 @@ export default function CourrierPage() { const folderList = (data.mailboxes && data.mailboxes.length > 0) ? data.mailboxes : fallbackFolders; - // Add the single account - updatedAccounts.push({ - id: 'default-account', - name: data.displayName || data.email, - email: data.email, - color: 'bg-blue-500', - folders: folderList - }); + // Update the loading account if it exists + if (updatedAccounts.length > 1) { + updatedAccounts[1] = { + id: 'default-account', // Use consistent ID + name: data.displayName || data.email, + email: data.email, + color: 'bg-blue-500', + folders: folderList + }; + } else { + // Fallback if accounts array is empty + updatedAccounts.push({ id: 'all-accounts', name: 'All', email: '', color: 'bg-gray-500' }); + updatedAccounts.push({ + id: 'default-account', + name: data.displayName || data.email, + email: data.email, + color: 'bg-blue-500', + folders: folderList + }); + } } console.log('Setting accounts:', updatedAccounts); @@ -329,25 +392,25 @@ export default function CourrierPage() { console.log('Auto-selecting account:', updatedAccounts[1]); setSelectedAccount(updatedAccounts[1]); setShowFolders(true); - - // Load emails for the selected account - if (session?.user?.id) { - await loadEmails(); - } } - // If the user hasn't opened this page recently, trigger a background refresh - if (data.lastVisit && Date.now() - data.lastVisit > 5 * 60 * 1000) { - // It's been more than 5 minutes, refresh in background - try { - const refreshResponse = await fetch('/api/courrier/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folder: currentFolder }) - }); - console.log('Background refresh triggered'); - } catch (error) { - console.error('Failed to trigger background refresh', error); + // Preload first page of emails for faster initial rendering + if (session?.user?.id) { + await loadEmails(); + + // If the user hasn't opened this page recently, trigger a background refresh + if (data.lastVisit && Date.now() - data.lastVisit > 5 * 60 * 1000) { + // It's been more than 5 minutes, refresh in background + try { + const refreshResponse = await fetch('/api/courrier/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder: currentFolder }) + }); + console.log('Background refresh triggered'); + } catch (error) { + console.error('Failed to trigger background refresh', error); + } } } } else { diff --git a/lib/redis.ts b/lib/redis.ts index 6f520983..3ed63293 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -1,8 +1,5 @@ import Redis from 'ioredis'; import CryptoJS from 'crypto-js'; -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); // Initialize Redis client let redisClient: Redis | null = null; @@ -93,20 +90,17 @@ export const TTL = { }; interface EmailCredentials { - id: string; - userId: string; email: string; - password: string; + password?: string; host: string; port: number; - secure: boolean; - smtp_host: string | null; - smtp_port: number | null; - smtp_secure: boolean | null; - display_name: string | null; - color: string | null; - createdAt: Date; - updatedAt: Date; + secure?: boolean; + encryptedPassword?: string; + smtp_host?: string; + smtp_port?: number; + smtp_secure?: boolean; + display_name?: string; + color?: string; } interface ImapSessionData { @@ -119,61 +113,106 @@ interface ImapSessionData { /** * Cache email credentials in Redis */ -export async function cacheEmailCredentials(userId: string, credentials: EmailCredentials): Promise { - const client = await getRedisClient(); - const key = `email_credentials:${userId}`; - await client.set(key, JSON.stringify(credentials), 'EX', 3600); // 1 hour expiry +export async function cacheEmailCredentials( + userId: string, + credentials: EmailCredentials +): Promise { + const redis = getRedisClient(); + const key = KEYS.CREDENTIALS(userId); + + // Validate credentials before caching + if (!credentials.email || !credentials.host || !credentials.password) { + console.error(`Cannot cache incomplete credentials for user ${userId}`); + return; + } + + try { + console.log(`Caching credentials for user ${userId}`); + + // Create a copy without the password to store + const secureCredentials: EmailCredentials = { + email: credentials.email, + host: credentials.host, + port: credentials.port, + secure: credentials.secure ?? true, + // Include the extended fields + ...(credentials.smtp_host && { smtp_host: credentials.smtp_host }), + ...(credentials.smtp_port && { smtp_port: credentials.smtp_port }), + ...(credentials.smtp_secure !== undefined && { smtp_secure: credentials.smtp_secure }), + ...(credentials.display_name && { display_name: credentials.display_name }), + ...(credentials.color && { color: credentials.color }) + }; + + // Encrypt password + if (credentials.password) { + try { + const encrypted = encryptData(credentials.password); + console.log(`Successfully encrypted password for user ${userId}`); + secureCredentials.encryptedPassword = encrypted; + } catch (encryptError) { + console.error(`Failed to encrypt password for user ${userId}:`, encryptError); + // Don't proceed with caching if encryption fails + return; + } + } else { + console.warn(`No password provided for user ${userId}, skipping credential caching`); + return; + } + + await redis.set(key, JSON.stringify(secureCredentials), 'EX', TTL.CREDENTIALS); + console.log(`Credentials cached for user ${userId}`); + } catch (error) { + console.error(`Error caching credentials for user ${userId}:`, error); + } } /** - * Get cached email credentials from Redis - */ -export async function getCachedEmailCredentials(userId: string): Promise { - const client = await getRedisClient(); - const key = `email_credentials:${userId}`; - const data = await client.get(key); - return data ? JSON.parse(data) : null; -} - -/** - * Get email credentials from Redis or database + * Get email credentials from Redis */ export async function getEmailCredentials(userId: string): Promise { - // Try to get from cache first - const cached = await getCachedEmailCredentials(userId); - if (cached) { - return cached; - } - - // If not in cache, get from database - const credentials = await prisma.mailCredentials.findFirst({ - where: { userId }, - select: { - id: true, - userId: true, - email: true, - password: true, - host: true, - port: true, - secure: true, - smtp_host: true, - smtp_port: true, - smtp_secure: true, - display_name: true, - color: true, - createdAt: true, - updatedAt: true + const redis = getRedisClient(); + const key = KEYS.CREDENTIALS(userId); + + try { + const credStr = await redis.get(key); + + if (!credStr) { + return null; } - }); - - if (!credentials) { + + const creds = JSON.parse(credStr) as EmailCredentials; + + if (!creds.encryptedPassword) { + console.warn(`No encrypted password found for user ${userId}`); + return null; + } + + try { + // Decrypt the password + const password = decryptData(creds.encryptedPassword); + + // Return the full credentials with decrypted password + return { + email: creds.email, + password, + host: creds.host, + port: creds.port, + secure: creds.secure ?? true, + // Include the extended fields if they exist in the cache + ...(creds.smtp_host && { smtp_host: creds.smtp_host }), + ...(creds.smtp_port && { smtp_port: creds.smtp_port }), + ...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }), + ...(creds.display_name && { display_name: creds.display_name }), + ...(creds.color && { color: creds.color }) + }; + } catch (decryptError) { + console.error(`Failed to decrypt password for user ${userId}:`, decryptError); + return null; + } + } catch (error) { + console.error(`Error retrieving credentials for user ${userId}:`, error); return null; } - - // Cache the credentials - await cacheEmailCredentials(userId, credentials); - - return credentials; } /** @@ -370,4 +409,14 @@ export async function invalidateUserEmailCache( } } while (cursor !== '0'); } +} + +/** + * Get cached email credentials from Redis + * @deprecated Use getEmailCredentials instead + */ +export async function getCachedEmailCredentials( + userId: string +): Promise { + return getEmailCredentials(userId); } \ No newline at end of file diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index 1dcfbbc3..7e9134cd 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -18,7 +18,6 @@ import { invalidateEmailContentCache } from '@/lib/redis'; import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@/lib/types'; -import { PrismaClient } from '@prisma/client'; // Types specific to this service export interface EmailListResult { @@ -68,7 +67,7 @@ export async function getImapConnection(userId: string): Promise { } // Cache credentials for future use - await cacheEmailCredentials(userId, [credentials]); + await cacheEmailCredentials(userId, credentials); } // Validate credentials @@ -151,11 +150,9 @@ export async function getImapConnection(userId: string): Promise { * Get user's email credentials from database */ export async function getUserEmailCredentials(userId: string): Promise { - const credentials = await prisma.mailCredentials.findFirst({ + const credentials = await prisma.mailCredentials.findUnique({ where: { userId }, select: { - id: true, - userId: true, email: true, password: true, host: true, @@ -165,20 +162,27 @@ export async function getUserEmailCredentials(userId: string): Promise