courrier multi account restore compose

This commit is contained in:
alma 2025-04-28 12:23:57 +02:00
parent 4bfb348082
commit 6ab432e0ff
5 changed files with 264 additions and 156 deletions

View File

@ -131,8 +131,6 @@ export async function GET() {
// Get additional fields that might be in the database but not in the type // Get additional fields that might be in the database but not in the type
const accountsWithMetadata = await Promise.all(allAccounts.map(async (account) => { 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 // Get the raw account data to access fields not in the type
const rawAccount = await prisma.$queryRaw` const rawAccount = await prisma.$queryRaw`
SELECT display_name, color SELECT display_name, color
@ -142,7 +140,6 @@ export async function GET() {
// Cast the raw result to an array and get the first item // Cast the raw result to an array and get the first item
const metadata = Array.isArray(rawAccount) ? rawAccount[0] : rawAccount; const metadata = Array.isArray(rawAccount) ? rawAccount[0] : rawAccount;
console.log(`[DEBUG] Metadata for ${account.email}:`, metadata);
// Get folders for this specific account // Get folders for this specific account
const accountFolders = await getAccountFolders(account.id, { const accountFolders = await getAccountFolders(account.id, {
@ -150,8 +147,6 @@ export async function GET() {
...metadata ...metadata
}); });
console.log(`[DEBUG] Found ${accountFolders.length} folders for ${account.email}`);
return { return {
...account, ...account,
display_name: metadata?.display_name || account.email, display_name: metadata?.display_name || account.email,
@ -160,12 +155,7 @@ export async function GET() {
}; };
})); }));
console.log(`[DEBUG] Final processed accounts:`, accountsWithMetadata.map(acc => ({ console.log(`Found ${allAccounts.length} email accounts for user ${userId}`);
id: acc.id,
email: acc.email,
display_name: acc.display_name,
folderCount: acc.folders?.length || 0
})));
// If not in cache and no accounts found, check database for single account (backward compatibility) // If not in cache and no accounts found, check database for single account (backward compatibility)
if (!credentials && allAccounts.length === 0) { if (!credentials && allAccounts.length === 0) {

View File

@ -125,7 +125,8 @@ export default function CourrierPage() {
// Email accounts for the sidebar // Email accounts for the sidebar
const [accounts, setAccounts] = useState<Account[]>([ const [accounts, setAccounts] = useState<Account[]>([
{ 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<Account | null>(null); const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
@ -136,16 +137,17 @@ export default function CourrierPage() {
useEffect(() => { useEffect(() => {
console.log('Mailboxes updated:', mailboxes); console.log('Mailboxes updated:', mailboxes);
setAccounts(prev => { setAccounts(prev => {
// Keep the "All" account and update the actual email account const updated = [...prev];
const updated = [prev[0]]; // Keep the "All" account if (updated.length > 1) {
if (prev.length > 1) { // Only update folders, preserve other properties including ID
// Update the existing email account if (updated[1]) {
updated.push({ updated[1] = {
...prev[1], ...updated[1],
folders: mailboxes folders: mailboxes
}); };
}
console.log('Updated accounts with new mailboxes:', updated);
} }
console.log('Updated accounts with new mailboxes:', updated);
return updated; return updated;
}); });
}, [mailboxes]); }, [mailboxes]);
@ -194,10 +196,11 @@ export default function CourrierPage() {
if (!accounts || accounts.length === 0) { if (!accounts || accounts.length === 0) {
console.warn('Accounts array is empty, restoring defaults'); console.warn('Accounts array is empty, restoring defaults');
setAccounts([ 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 // Initialize session and start prefetching
useEffect(() => { useEffect(() => {
@ -245,52 +248,100 @@ export default function CourrierPage() {
console.log('allAccounts is array:', Array.isArray(data.allAccounts)); console.log('allAccounts is array:', Array.isArray(data.allAccounts));
console.log('allAccounts length:', data.allAccounts?.length || 0); 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.authenticated) {
if (data.hasEmailCredentials) { if (data.hasEmailCredentials) {
console.log('Session initialized, prefetch status:', data.prefetchStarted ? 'running' : 'not started'); console.log('Session initialized, prefetch status:', data.prefetchStarted ? 'running' : 'not started');
setPrefetchStarted(Boolean(data.prefetchStarted)); setPrefetchStarted(Boolean(data.prefetchStarted));
// Create a copy of the current accounts to update // 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 // Check if we have multiple accounts returned
if (data.allAccounts && Array.isArray(data.allAccounts) && data.allAccounts.length > 0) { if (data.allAccounts && Array.isArray(data.allAccounts) && data.allAccounts.length > 0) {
console.log('[DEBUG] Multiple accounts found:', data.allAccounts.length); 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) => { 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, id: account.id,
email: account.email, email: account.email,
display_name: account.display_name, display_name: account.display_name,
hasFolders: !!account.folders, hasFolders: !!account.folders,
foldersIsArray: Array.isArray(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 firstAccount = data.allAccounts[0];
const accountFolders = (account.folders && Array.isArray(account.folders)) const accountFolders = (firstAccount.folders && Array.isArray(firstAccount.folders))
? account.folders ? firstAccount.folders
: (data.mailboxes && Array.isArray(data.mailboxes)) : (data.mailboxes && Array.isArray(data.mailboxes))
? data.mailboxes ? data.mailboxes
: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']; : ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk'];
// Add the account to the list
updatedAccounts.push({ updatedAccounts.push({
id: account.id || `account-${index}`, id: firstAccount.id,
name: account.display_name || account.email, name: firstAccount.display_name || firstAccount.email,
email: account.email, email: firstAccount.email,
color: account.color || 'bg-blue-500', color: firstAccount.color || 'bg-blue-500',
folders: accountFolders 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) { } else if (data.email) {
// Fallback to single account if allAccounts is not available // Fallback to single account if allAccounts is not available
console.log(`[DEBUG] Fallback to single account: ${data.email}`); 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) ? const folderList = (data.mailboxes && data.mailboxes.length > 0) ?
data.mailboxes : fallbackFolders; data.mailboxes : fallbackFolders;
// Add the single account // Update the loading account if it exists
updatedAccounts.push({ if (updatedAccounts.length > 1) {
id: 'default-account', updatedAccounts[1] = {
name: data.displayName || data.email, id: 'default-account', // Use consistent ID
email: data.email, name: data.displayName || data.email,
color: 'bg-blue-500', email: data.email,
folders: folderList 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); console.log('Setting accounts:', updatedAccounts);
@ -329,25 +392,25 @@ export default function CourrierPage() {
console.log('Auto-selecting account:', updatedAccounts[1]); console.log('Auto-selecting account:', updatedAccounts[1]);
setSelectedAccount(updatedAccounts[1]); setSelectedAccount(updatedAccounts[1]);
setShowFolders(true); 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 // Preload first page of emails for faster initial rendering
if (data.lastVisit && Date.now() - data.lastVisit > 5 * 60 * 1000) { if (session?.user?.id) {
// It's been more than 5 minutes, refresh in background await loadEmails();
try {
const refreshResponse = await fetch('/api/courrier/refresh', { // If the user hasn't opened this page recently, trigger a background refresh
method: 'POST', if (data.lastVisit && Date.now() - data.lastVisit > 5 * 60 * 1000) {
headers: { 'Content-Type': 'application/json' }, // It's been more than 5 minutes, refresh in background
body: JSON.stringify({ folder: currentFolder }) try {
}); const refreshResponse = await fetch('/api/courrier/refresh', {
console.log('Background refresh triggered'); method: 'POST',
} catch (error) { headers: { 'Content-Type': 'application/json' },
console.error('Failed to trigger background refresh', error); body: JSON.stringify({ folder: currentFolder })
});
console.log('Background refresh triggered');
} catch (error) {
console.error('Failed to trigger background refresh', error);
}
} }
} }
} else { } else {

View File

@ -1,8 +1,5 @@
import Redis from 'ioredis'; import Redis from 'ioredis';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Initialize Redis client // Initialize Redis client
let redisClient: Redis | null = null; let redisClient: Redis | null = null;
@ -93,20 +90,17 @@ export const TTL = {
}; };
interface EmailCredentials { interface EmailCredentials {
id: string;
userId: string;
email: string; email: string;
password: string; password?: string;
host: string; host: string;
port: number; port: number;
secure: boolean; secure?: boolean;
smtp_host: string | null; encryptedPassword?: string;
smtp_port: number | null; smtp_host?: string;
smtp_secure: boolean | null; smtp_port?: number;
display_name: string | null; smtp_secure?: boolean;
color: string | null; display_name?: string;
createdAt: Date; color?: string;
updatedAt: Date;
} }
interface ImapSessionData { interface ImapSessionData {
@ -119,61 +113,106 @@ interface ImapSessionData {
/** /**
* Cache email credentials in Redis * Cache email credentials in Redis
*/ */
export async function cacheEmailCredentials(userId: string, credentials: EmailCredentials): Promise<void> { export async function cacheEmailCredentials(
const client = await getRedisClient(); userId: string,
const key = `email_credentials:${userId}`; credentials: EmailCredentials
await client.set(key, JSON.stringify(credentials), 'EX', 3600); // 1 hour expiry ): Promise<void> {
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 * Get email credentials from Redis
*/
export async function getCachedEmailCredentials(userId: string): Promise<EmailCredentials | null> {
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
*/ */
export async function getEmailCredentials(userId: string): Promise<EmailCredentials | null> { export async function getEmailCredentials(userId: string): Promise<EmailCredentials | null> {
// Try to get from cache first const redis = getRedisClient();
const cached = await getCachedEmailCredentials(userId); const key = KEYS.CREDENTIALS(userId);
if (cached) {
return cached; try {
} const credStr = await redis.get(key);
// If not in cache, get from database if (!credStr) {
const credentials = await prisma.mailCredentials.findFirst({ return null;
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 creds = JSON.parse(credStr) as EmailCredentials;
if (!credentials) {
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; return null;
} }
// Cache the credentials
await cacheEmailCredentials(userId, credentials);
return credentials;
} }
/** /**
@ -370,4 +409,14 @@ export async function invalidateUserEmailCache(
} }
} while (cursor !== '0'); } while (cursor !== '0');
} }
}
/**
* Get cached email credentials from Redis
* @deprecated Use getEmailCredentials instead
*/
export async function getCachedEmailCredentials(
userId: string
): Promise<EmailCredentials | null> {
return getEmailCredentials(userId);
} }

View File

@ -18,7 +18,6 @@ import {
invalidateEmailContentCache invalidateEmailContentCache
} from '@/lib/redis'; } from '@/lib/redis';
import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@/lib/types'; import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@/lib/types';
import { PrismaClient } from '@prisma/client';
// Types specific to this service // Types specific to this service
export interface EmailListResult { export interface EmailListResult {
@ -68,7 +67,7 @@ export async function getImapConnection(userId: string): Promise<ImapFlow> {
} }
// Cache credentials for future use // Cache credentials for future use
await cacheEmailCredentials(userId, [credentials]); await cacheEmailCredentials(userId, credentials);
} }
// Validate credentials // Validate credentials
@ -151,11 +150,9 @@ export async function getImapConnection(userId: string): Promise<ImapFlow> {
* Get user's email credentials from database * Get user's email credentials from database
*/ */
export async function getUserEmailCredentials(userId: string): Promise<EmailCredentials | null> { export async function getUserEmailCredentials(userId: string): Promise<EmailCredentials | null> {
const credentials = await prisma.mailCredentials.findFirst({ const credentials = await prisma.mailCredentials.findUnique({
where: { userId }, where: { userId },
select: { select: {
id: true,
userId: true,
email: true, email: true,
password: true, password: true,
host: true, host: true,
@ -165,20 +162,27 @@ export async function getUserEmailCredentials(userId: string): Promise<EmailCred
smtp_port: true, smtp_port: true,
smtp_secure: true, smtp_secure: true,
display_name: true, display_name: true,
color: true, color: true
createdAt: true,
updatedAt: true
} }
}); });
if (!credentials) { if (!credentials) {
return null; return null;
} }
// Cache the credentials // Return only the fields that exist in credentials
await cacheEmailCredentials(userId, credentials); return {
email: credentials.email,
return credentials; password: credentials.password,
host: credentials.host,
port: credentials.port,
...(credentials.secure !== undefined && { secure: credentials.secure }),
...(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 })
};
} }
/** /**
@ -209,7 +213,7 @@ export async function saveUserEmailCredentials(
}); });
// Cache the full credentials object in Redis (with all fields) // Cache the full credentials object in Redis (with all fields)
await cacheEmailCredentials(userId, [credentials]); await cacheEmailCredentials(userId, credentials);
} }
// Helper type for IMAP fetch options // Helper type for IMAP fetch options
@ -885,7 +889,7 @@ export async function forceRecacheUserCredentials(userId: string): Promise<boole
// Try to cache the credentials // Try to cache the credentials
try { try {
const { cacheEmailCredentials } = await import('@/lib/redis'); const { cacheEmailCredentials } = await import('@/lib/redis');
await cacheEmailCredentials(userId, [credentials]); await cacheEmailCredentials(userId, credentials);
console.log(`[CREDENTIAL FIX] Successfully cached credentials for user ${userId}`); console.log(`[CREDENTIAL FIX] Successfully cached credentials for user ${userId}`);
// Now verify the credentials were cached correctly // Now verify the credentials were cached correctly

View File

@ -1,18 +1,20 @@
export interface EmailCredentials { export interface EmailCredentials {
id: string; // IMAP Settings
userId: string;
email: string; email: string;
password: string; password?: string;
host: string; host: string;
port: number; port: number;
secure: boolean; secure?: boolean;
smtp_host: string | null; encryptedPassword?: string;
smtp_port: number | null;
smtp_secure: boolean | null; // SMTP Settings
display_name: string | null; smtp_host?: string;
color: string | null; smtp_port?: number;
createdAt: Date; smtp_secure?: boolean; // true for SSL, false for TLS
updatedAt: Date;
// Display Settings
display_name?: string;
color?: string;
} }
export interface EmailAddress { export interface EmailAddress {