courrier multi account restore compose

This commit is contained in:
alma 2025-04-30 15:29:08 +02:00
parent 363e999dcd
commit a7b023e359
4 changed files with 499 additions and 174 deletions

View File

@ -5,14 +5,18 @@ import { getImapConnection } from '@/lib/services/email-service';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { getRedisClient } from '@/lib/redis'; import { getRedisClient } from '@/lib/redis';
// Cache TTL for unread counts (30 seconds) // Cache TTL for unread counts (increased to 2 minutes for better performance)
const UNREAD_COUNTS_CACHE_TTL = 30; const UNREAD_COUNTS_CACHE_TTL = 120;
// Key for unread counts cache // Key for unread counts cache
const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`; const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`;
// Refresh lock key to prevent parallel refreshes
const REFRESH_LOCK_KEY = (userId: string) => `email:unread-refresh:${userId}`;
// Lock TTL to prevent stuck locks (30 seconds)
const REFRESH_LOCK_TTL = 30;
/** /**
* API route for fetching unread counts for email folders * API route for fetching unread counts for email folders
* Optimized with proper caching and connection reuse * Optimized with proper caching, connection reuse, and background refresh
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
@ -33,88 +37,72 @@ export async function GET(request: Request) {
if (cachedCounts) { if (cachedCounts) {
// Use cached results if available // Use cached results if available
console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`); console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`);
// If the cache is about to expire, schedule a background refresh
const ttl = await redis.ttl(UNREAD_COUNTS_CACHE_KEY(userId));
if (ttl < UNREAD_COUNTS_CACHE_TTL / 2) {
// Only refresh if not already refreshing (use a lock)
const lockAcquired = await redis.set(
REFRESH_LOCK_KEY(userId),
Date.now().toString(),
'EX',
REFRESH_LOCK_TTL,
'NX' // Set only if key doesn't exist
);
if (lockAcquired) {
console.log(`[UNREAD_API] Scheduling background refresh for user ${userId}`);
// Use Promise to run in background
setTimeout(() => {
refreshUnreadCounts(userId, redis)
.catch(err => console.error(`[UNREAD_API] Background refresh error: ${err}`))
.finally(() => {
// Release lock regardless of outcome
redis.del(REFRESH_LOCK_KEY(userId)).catch(() => {});
});
}, 0);
}
}
return NextResponse.json(JSON.parse(cachedCounts)); return NextResponse.json(JSON.parse(cachedCounts));
} }
console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`); console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`);
// Get all accounts from the database directly // Try to acquire lock to prevent parallel refreshes
const accounts = await prisma.mailCredentials.findMany({ const lockAcquired = await redis.set(
where: { userId }, REFRESH_LOCK_KEY(userId),
select: { Date.now().toString(),
id: true,
email: true
}
});
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
if (accounts.length === 0) {
return NextResponse.json({ default: {} });
}
// Mapping to hold the unread counts
const unreadCounts: Record<string, Record<string, number>> = {};
// For each account, get the unread counts for standard folders
for (const account of accounts) {
const accountId = account.id;
try {
// Get IMAP connection for this account
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
const client = await getImapConnection(userId, accountId);
unreadCounts[accountId] = {};
// Standard folders to check
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
// Get mailboxes for this account to check if folders exist
const mailboxes = await client.list();
const availableFolders = mailboxes.map(mb => mb.path);
// Check each standard folder if it exists
for (const folder of standardFolders) {
// Skip if folder doesn't exist in this account
if (!availableFolders.includes(folder) &&
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
continue;
}
try {
// Check folder status without opening it (more efficient)
const status = await client.status(folder, { unseen: true });
if (status && typeof status.unseen === 'number') {
// Store the unread count
unreadCounts[accountId][folder] = status.unseen;
// Also store with prefixed version for consistency
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
}
} catch (folderError) {
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
// Continue to next folder even if this one fails
}
}
// Don't close the connection - let the connection pool handle it
// This avoids opening and closing connections repeatedly
} catch (accountError) {
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
}
}
// Save to cache for 30 seconds to avoid hammering the IMAP server
await redis.set(
UNREAD_COUNTS_CACHE_KEY(userId),
JSON.stringify(unreadCounts),
'EX', 'EX',
UNREAD_COUNTS_CACHE_TTL REFRESH_LOCK_TTL,
'NX' // Set only if key doesn't exist
); );
return NextResponse.json(unreadCounts); if (!lockAcquired) {
console.log(`[UNREAD_API] Another process is refreshing unread counts for ${userId}`);
// Return empty counts with short cache time if we can't acquire lock
// The next request will likely get cached data
return NextResponse.json({ _status: 'pending_refresh' });
}
try {
// Fetch new counts
const unreadCounts = await fetchUnreadCounts(userId);
// Save to cache with longer TTL (2 minutes)
await redis.set(
UNREAD_COUNTS_CACHE_KEY(userId),
JSON.stringify(unreadCounts),
'EX',
UNREAD_COUNTS_CACHE_TTL
);
return NextResponse.json(unreadCounts);
} finally {
// Always release lock
await redis.del(REFRESH_LOCK_KEY(userId));
}
} catch (error: any) { } catch (error: any) {
console.error("[UNREAD_API] Error fetching unread counts:", error); console.error("[UNREAD_API] Error fetching unread counts:", error);
return NextResponse.json( return NextResponse.json(
@ -124,6 +112,103 @@ export async function GET(request: Request) {
} }
} }
/**
* Background refresh function to update cache without blocking the API response
*/
async function refreshUnreadCounts(userId: string, redis: any): Promise<void> {
try {
console.log(`[UNREAD_API] Background refresh started for user ${userId}`);
const unreadCounts = await fetchUnreadCounts(userId);
// Save to cache
await redis.set(
UNREAD_COUNTS_CACHE_KEY(userId),
JSON.stringify(unreadCounts),
'EX',
UNREAD_COUNTS_CACHE_TTL
);
console.log(`[UNREAD_API] Background refresh completed for user ${userId}`);
} catch (error) {
console.error(`[UNREAD_API] Background refresh failed for user ${userId}:`, error);
throw error;
}
}
/**
* Core function to fetch unread counts from IMAP
*/
async function fetchUnreadCounts(userId: string): Promise<Record<string, Record<string, number>>> {
// Get all accounts from the database directly
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
select: {
id: true,
email: true
}
});
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
if (accounts.length === 0) {
return { default: {} };
}
// Mapping to hold the unread counts
const unreadCounts: Record<string, Record<string, number>> = {};
// For each account, get the unread counts for standard folders
for (const account of accounts) {
const accountId = account.id;
try {
// Get IMAP connection for this account
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
const client = await getImapConnection(userId, accountId);
unreadCounts[accountId] = {};
// Standard folders to check
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
// Get mailboxes for this account to check if folders exist
const mailboxes = await client.list();
const availableFolders = mailboxes.map(mb => mb.path);
// Check each standard folder if it exists
for (const folder of standardFolders) {
// Skip if folder doesn't exist in this account
if (!availableFolders.includes(folder) &&
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
continue;
}
try {
// Check folder status without opening it (more efficient)
const status = await client.status(folder, { unseen: true });
if (status && typeof status.unseen === 'number') {
// Store the unread count
unreadCounts[accountId][folder] = status.unseen;
// Also store with prefixed version for consistency
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
}
} catch (folderError) {
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
// Continue to next folder even if this one fails
}
}
// Don't close the connection - let the connection pool handle it
} catch (accountError) {
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
}
}
return unreadCounts;
}
/** /**
* Helper to get all account IDs for a user * Helper to get all account IDs for a user
*/ */

View File

@ -625,19 +625,44 @@ export const useEmailState = () => {
// Skip fetching if an email was viewed recently (within last 5 seconds) // Skip fetching if an email was viewed recently (within last 5 seconds)
const now = Date.now(); const now = Date.now();
const lastViewedTimestamp = (window as any).__lastViewedEmailTimestamp || 0; const lastViewedTimestamp = (window as any).__lastViewedEmailTimestamp || 0;
if (lastViewedTimestamp && now - lastViewedTimestamp < 5000) { // Increased from 2000ms for better performance if (lastViewedTimestamp && now - lastViewedTimestamp < 5000) {
return; return;
} }
// Reset failure tracking if it's been more than 1 minute since last failure // Try to get from sessionStorage first for faster response
if ((window as any).__unreadCountFailures && now - (window as any).__unreadCountFailures > 60000) { try {
(window as any).__unreadCountFailures = 0; const storageKey = `unread_counts_${session.user.id}`;
const storedData = sessionStorage.getItem(storageKey);
if (storedData) {
const { data, timestamp } = JSON.parse(storedData);
// Use stored data if it's less than 30 seconds old
if (now - timestamp < 30000) {
logEmailOp('FETCH_UNREAD', 'Using sessionStorage data', { age: Math.round((now - timestamp)/1000) + 's' });
dispatch({ type: 'SET_UNREAD_COUNTS', payload: data });
return;
}
}
} catch (err) {
// Ignore storage errors
} }
// Exponential backoff for failures // Reset failure tracking if it's been more than 1 minute since last failure
if ((window as any).__unreadCountFailures > 0) { if ((window as any).__unreadCountFailures?.lastFailureTime &&
const backoffMs = Math.min(30000, 1000 * Math.pow(2, (window as any).__unreadCountFailures - 1)); now - (window as any).__unreadCountFailures.lastFailureTime > 60000) {
if ((window as any).__unreadCountFailures && now - (window as any).__unreadCountFailures < backoffMs) { (window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 };
}
// Exponential backoff for failures with proper tracking object
if (!(window as any).__unreadCountFailures) {
(window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 };
}
if ((window as any).__unreadCountFailures.count > 0) {
const failures = (window as any).__unreadCountFailures.count;
const backoffMs = Math.min(30000, 1000 * Math.pow(2, failures - 1));
if (now - (window as any).__unreadCountFailures.lastFailureTime < backoffMs) {
logEmailOp('BACKOFF', `Skipping unread fetch, in backoff period (${backoffMs}ms)`);
return; return;
} }
} }
@ -650,13 +675,19 @@ export const useEmailState = () => {
const response = await fetch('/api/courrier/unread-counts', { const response = await fetch('/api/courrier/unread-counts', {
method: 'GET', method: 'GET',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
// Add cache control headers
cache: 'no-cache',
next: { revalidate: 0 }
}); });
if (!response.ok) { if (!response.ok) {
// If request failed, increment failure count but cap it // If request failed, track failures properly
(window as any).__unreadCountFailures = Math.min((window as any).__unreadCountFailures || 0 + 1, 10); (window as any).__unreadCountFailures.count =
const failures = (window as any).__unreadCountFailures; Math.min((window as any).__unreadCountFailures.count + 1, 10);
(window as any).__unreadCountFailures.lastFailureTime = now;
const failures = (window as any).__unreadCountFailures.count;
if (failures > 3) { if (failures > 3) {
// After 3 failures, slow down requests with exponential backoff // After 3 failures, slow down requests with exponential backoff
@ -676,14 +707,40 @@ export const useEmailState = () => {
} }
} else { } else {
// Reset failure counter on success // Reset failure counter on success
(window as any).__unreadCountFailures = 0; (window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 };
const data = await response.json(); const data = await response.json();
const timeAfterCall = performance.now(); const timeAfterCall = performance.now();
logEmailOp('FETCH_UNREAD', `Received unread counts in ${(timeAfterCall - timeBeforeCall).toFixed(2)}ms`, data);
// Skip if we got the "pending_refresh" status
if (data._status === 'pending_refresh') {
logEmailOp('FETCH_UNREAD', 'Server is refreshing counts, will try again soon');
// Retry after a short delay
setTimeout(() => {
fetchUnreadCounts();
}, 2000);
return;
}
logEmailOp('FETCH_UNREAD', `Received unread counts in ${(timeAfterCall - timeBeforeCall).toFixed(2)}ms`);
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
dispatch({ type: 'SET_UNREAD_COUNTS', payload: data }); dispatch({ type: 'SET_UNREAD_COUNTS', payload: data });
// Store in sessionStorage for faster future access
try {
sessionStorage.setItem(
`unread_counts_${session.user.id}`,
JSON.stringify({
data,
timestamp: now
})
);
} catch (err) {
// Ignore storage errors
}
} }
} }
} catch (error) { } catch (error) {
@ -705,14 +762,13 @@ export const useEmailState = () => {
const now = Date.now(); const now = Date.now();
const lastUpdate = (window as any).__lastUnreadUpdate; const lastUpdate = (window as any).__lastUnreadUpdate;
const MIN_UPDATE_INTERVAL = 2000; // 2 seconds minimum between updates const MIN_UPDATE_INTERVAL = 10000; // 10 seconds minimum between updates (increased from 2s)
if (now - lastUpdate.timestamp < MIN_UPDATE_INTERVAL) { if (now - lastUpdate.timestamp < MIN_UPDATE_INTERVAL) {
return; // Skip if updated too recently return; // Skip if updated too recently
} }
// Rather than calculating locally, let's fetch from the API // Rather than calculating locally, fetch from the API
// This ensures we get accurate server-side counts
fetchUnreadCounts(); fetchUnreadCounts();
// Update timestamp of last update // Update timestamp of last update
@ -726,7 +782,7 @@ export const useEmailState = () => {
// Debounce unread count updates to prevent rapid multiple updates // Debounce unread count updates to prevent rapid multiple updates
let updateTimeoutId: ReturnType<typeof setTimeout>; let updateTimeoutId: ReturnType<typeof setTimeout>;
const debounceMs = 2000; // Increase debounce to 2 seconds const debounceMs = 5000; // Increase debounce to 5 seconds (from 2s)
// Function to call after debounce period // Function to call after debounce period
const debouncedUpdate = () => { const debouncedUpdate = () => {
@ -738,9 +794,17 @@ export const useEmailState = () => {
// Clear any existing timeout and start a new one // Clear any existing timeout and start a new one
debouncedUpdate(); debouncedUpdate();
// Also set up a periodic refresh every minute if the tab is active
const periodicRefreshId = setInterval(() => {
if (document.visibilityState === 'visible') {
updateUnreadCounts();
}
}, 60000); // 1 minute
// Cleanup timeout on unmount or state change // Cleanup timeout on unmount or state change
return () => { return () => {
clearTimeout(updateTimeoutId); clearTimeout(updateTimeoutId);
clearInterval(periodicRefreshId);
}; };
// Deliberately exclude unreadCountMap to prevent infinite loops // Deliberately exclude unreadCountMap to prevent infinite loops
}, [state.emails, updateUnreadCounts]); }, [state.emails, updateUnreadCounts]);
@ -801,11 +865,3 @@ export const useEmailState = () => {
viewEmail viewEmail
}; };
}; };
async function cacheEmails(userId, folder, accountId, page, perPage, emails) {
const client = await getRedisClient();
const key = `${userId}:${accountId}:${folder}:${page}:${perPage}`;
// Cache with TTL of 15 minutes (900 seconds)
await client.setEx(key, 900, JSON.stringify(emails));
}

View File

@ -3,23 +3,52 @@ import CryptoJS from 'crypto-js';
// Initialize Redis client // Initialize Redis client
let redisClient: Redis | null = null; let redisClient: Redis | null = null;
let isConnecting = false;
let connectionAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
/** /**
* Get a Redis client instance (singleton pattern) * Get a Redis client instance (singleton pattern) with improved connection management
*/ */
export function getRedisClient(): Redis { export function getRedisClient(): Redis {
if (redisClient && redisClient.status === 'ready') {
return redisClient;
}
if (isConnecting) {
// If we're already trying to connect, return the existing client
// This prevents multiple simultaneous connection attempts
if (redisClient) return redisClient;
// This is a fallback in case we're connecting but don't have a client yet
console.warn('Redis connection in progress, creating temporary client');
}
if (!redisClient) { if (!redisClient) {
isConnecting = true;
connectionAttempts = 0;
// Set Redis connection parameters from environment variables only // Set Redis connection parameters from environment variables only
const redisOptions = { const redisOptions = {
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined, port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined,
password: process.env.REDIS_PASSWORD, password: process.env.REDIS_PASSWORD,
retryStrategy: (times: number) => { retryStrategy: (times: number) => {
connectionAttempts = times;
if (times > MAX_RECONNECT_ATTEMPTS) {
console.error(`Redis connection failed after ${times} attempts, giving up`);
return null; // Stop trying to reconnect
}
const delay = Math.min(times * 100, 5000); const delay = Math.min(times * 100, 5000);
console.log(`Redis reconnect attempt ${times}, retrying in ${delay}ms`);
return delay; return delay;
}, },
maxRetriesPerRequest: 5, maxRetriesPerRequest: 5,
enableOfflineQueue: true enableOfflineQueue: true,
connectTimeout: 10000, // 10 seconds
disconnectTimeout: 2000, // 2 seconds
keepAlive: 10000, // 10 seconds
keyPrefix: '' // No prefix to keep keys clean
}; };
console.log('Connecting to Redis using environment variables'); console.log('Connecting to Redis using environment variables');
@ -27,14 +56,34 @@ export function getRedisClient(): Redis {
redisClient.on('error', (err) => { redisClient.on('error', (err) => {
console.error('Redis connection error:', err); console.error('Redis connection error:', err);
// Only set to null if we've exceeded max attempts
if (connectionAttempts > MAX_RECONNECT_ATTEMPTS) {
console.error('Redis connection failed permanently, will create new client on next request');
redisClient = null;
isConnecting = false;
}
}); });
redisClient.on('connect', () => { redisClient.on('connect', () => {
console.log('Successfully connected to Redis'); console.log('Successfully connected to Redis');
isConnecting = false;
connectionAttempts = 0;
}); });
redisClient.on('reconnecting', () => { redisClient.on('reconnecting', () => {
console.log('Reconnecting to Redis...'); console.log('Reconnecting to Redis...');
isConnecting = true;
});
redisClient.on('ready', () => {
console.log('Redis connection warmed up');
isConnecting = false;
});
redisClient.on('end', () => {
console.log('Redis connection ended');
// Don't set to null here - let the error handler decide
}); });
} }

View File

@ -31,33 +31,74 @@ export interface EmailListResult {
} }
// Connection pool to reuse IMAP clients // Connection pool to reuse IMAP clients
const connectionPool: Record<string, { client: ImapFlow; lastUsed: number }> = {}; const connectionPool: Record<string, {
const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes client: ImapFlow;
lastUsed: number;
isConnecting: boolean;
connectionPromise?: Promise<ImapFlow>;
}> = {};
const CONNECTION_TIMEOUT = 15 * 60 * 1000; // Increased to 15 minutes for long-lived connections
const MAX_POOL_SIZE = 20; // Maximum number of connections to keep in the pool
const CONNECTION_CHECK_INTERVAL = 60 * 1000; // Check every minute
// Clean up idle connections periodically // Clean up idle connections periodically
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
const connectionKeys = Object.keys(connectionPool);
Object.entries(connectionPool).forEach(([key, { client, lastUsed }]) => { // If we're over the pool size limit, sort by last used and remove oldest
if (now - lastUsed > CONNECTION_TIMEOUT) { if (connectionKeys.length > MAX_POOL_SIZE) {
console.log(`Closing idle IMAP connection for ${key}`); const sortedConnections = connectionKeys
.map(key => ({ key, lastUsed: connectionPool[key].lastUsed }))
.sort((a, b) => a.lastUsed - b.lastUsed);
// Keep the most recently used connections up to the max pool size
const connectionsToRemove = sortedConnections.slice(0, sortedConnections.length - MAX_POOL_SIZE);
connectionsToRemove.forEach(({ key }) => {
const connection = connectionPool[key];
try { try {
if (client.usable) { if (connection.client.usable) {
client.logout().catch(err => { connection.client.logout().catch(err => {
console.error(`Error closing connection for ${key}:`, err); console.error(`Error closing excess connection for ${key}:`, err);
});
}
} catch (error) {
console.error(`Error checking excess connection status for ${key}:`, error);
} finally {
delete connectionPool[key];
console.log(`Removed excess connection for ${key} from pool (pool size: ${Object.keys(connectionPool).length})`);
}
});
}
// Close idle connections
Object.entries(connectionPool).forEach(([key, connection]) => {
// Skip connections that are currently being established
if (connection.isConnecting) return;
if (now - connection.lastUsed > CONNECTION_TIMEOUT) {
console.log(`Closing idle IMAP connection for ${key} (idle for ${Math.round((now - connection.lastUsed)/1000)}s)`);
try {
if (connection.client.usable) {
connection.client.logout().catch(err => {
console.error(`Error closing idle connection for ${key}:`, err);
}); });
} }
} catch (error) { } catch (error) {
console.error(`Error checking connection status for ${key}:`, error); console.error(`Error checking connection status for ${key}:`, error);
} finally { } finally {
delete connectionPool[key]; delete connectionPool[key];
console.log(`Removed idle connection for ${key} from pool (pool size: ${Object.keys(connectionPool).length})`);
} }
} }
}); });
}, 60 * 1000); // Check every minute }, CONNECTION_CHECK_INTERVAL);
/** /**
* Get IMAP connection for a user, reusing existing connections when possible * Get IMAP connection for a user, reusing existing connections when possible
* with improved connection handling and error recovery
*/ */
export async function getImapConnection( export async function getImapConnection(
userId: string, userId: string,
@ -69,22 +110,87 @@ export async function getImapConnection(
if (!accountId || accountId === 'default') { if (!accountId || accountId === 'default') {
console.log(`No specific account provided or 'default' requested, trying to find first account for user ${userId}`); console.log(`No specific account provided or 'default' requested, trying to find first account for user ${userId}`);
// Query to find all accounts for this user // Try getting the account ID from cache to avoid database query
const accounts = await prisma.mailCredentials.findMany({ const sessionData = await getCachedImapSession(userId);
where: { userId }, if (sessionData && sessionData.defaultAccountId) {
orderBy: { createdAt: 'asc' }, accountId = sessionData.defaultAccountId;
take: 1 console.log(`Using cached default account ID: ${accountId}`);
});
if (accounts && accounts.length > 0) {
const firstAccount = accounts[0];
console.log(`Using first available account: ${firstAccount.id} (${firstAccount.email})`);
accountId = firstAccount.id;
} else { } else {
throw new Error('No email accounts configured for this user'); // Query to find all accounts for this user
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
take: 1
});
if (accounts && accounts.length > 0) {
const firstAccount = accounts[0];
console.log(`Using first available account: ${firstAccount.id} (${firstAccount.email})`);
accountId = firstAccount.id;
// Cache default account ID for future use
if (sessionData) {
await cacheImapSession(userId, {
...sessionData,
defaultAccountId: accountId,
lastActive: Date.now()
});
} else {
await cacheImapSession(userId, {
lastActive: Date.now(),
defaultAccountId: accountId
});
}
} else {
throw new Error('No email accounts configured for this user');
}
} }
} }
// Use accountId in connection key to ensure different accounts get different connections
const connectionKey = `${userId}:${accountId}`;
// If we already have a connection for this key
if (connectionPool[connectionKey]) {
const connection = connectionPool[connectionKey];
// If a connection is being established, wait for it
if (connection.isConnecting && connection.connectionPromise) {
console.log(`Connection in progress for ${connectionKey}, waiting for existing connection`);
try {
const client = await connection.connectionPromise;
connection.lastUsed = Date.now();
return client;
} catch (error) {
console.error(`Error waiting for connection for ${connectionKey}:`, error);
// Fall through to create new connection
}
}
// Try to use existing connection if it's usable
try {
if (connection.client.usable) {
// Touch the connection to mark it as recently used
connection.lastUsed = Date.now();
console.log(`Reusing existing IMAP connection for ${connectionKey}`);
// Update session data in Redis
await updateSessionData(userId, accountId);
return connection.client;
} else {
console.log(`Existing connection for ${connectionKey} not usable, recreating`);
// Will create a new connection below
}
} catch (error) {
console.warn(`Error checking existing connection for ${connectionKey}:`, error);
// Will create a new connection below
}
}
// If we get here, we need a new connection
console.log(`Creating new IMAP connection for ${connectionKey}`);
// First try to get credentials from Redis cache // First try to get credentials from Redis cache
let credentials = await getCachedEmailCredentials(userId, accountId); let credentials = await getCachedEmailCredentials(userId, accountId);
@ -111,43 +217,51 @@ export async function getImapConnection(
throw new Error('Invalid email credentials configuration'); throw new Error('Invalid email credentials configuration');
} }
// Use accountId in connection key to ensure different accounts get different connections // Create connection record with connecting state
const connectionKey = `${userId}:${accountId}`; connectionPool[connectionKey] = {
const existingConnection = connectionPool[connectionKey]; client: null as any, // Will be set once connected
lastUsed: Date.now(),
isConnecting: true
};
// Try to get session data from Redis // Create the connection promise
const sessionData = await getCachedImapSession(userId); const connectionPromise = createImapConnection(credentials, connectionKey);
connectionPool[connectionKey].connectionPromise = connectionPromise;
// Return existing connection if available and connected try {
if (existingConnection) { const client = await connectionPromise;
try { console.log(`Successfully connected to IMAP server for ${connectionKey}`);
if (existingConnection.client.usable) {
existingConnection.lastUsed = Date.now();
console.log(`Reusing existing IMAP connection for ${connectionKey}`);
// Update session data in Redis // Update connection record
if (sessionData) { connectionPool[connectionKey] = {
await cacheImapSession(userId, { client,
...sessionData, lastUsed: Date.now(),
lastActive: Date.now() isConnecting: false
}); };
}
return existingConnection.client; // Update session data in Redis
} await updateSessionData(userId, accountId);
} catch (error) {
console.warn(`Existing connection for ${connectionKey} is not usable, creating new connection`); return client;
// Will create a new connection below } catch (error) {
} const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`IMAP connection error for ${connectionKey}:`, errorMessage);
// Clean up failed connection
delete connectionPool[connectionKey];
throw new Error(`Failed to connect to IMAP server: ${errorMessage}`);
} }
}
console.log(`Creating new IMAP connection for ${connectionKey}`); /**
* Helper function to create a new IMAP connection
// Create new connection */
async function createImapConnection(credentials: EmailCredentials, connectionKey: string): Promise<ImapFlow> {
const client = new ImapFlow({ const client = new ImapFlow({
host: credentials.host, host: credentials.host,
port: credentials.port, port: credentials.port,
secure: true, secure: credentials.secure ?? true,
auth: { auth: {
user: credentials.email, user: credentials.email,
pass: credentials.password, pass: credentials.password,
@ -156,24 +270,45 @@ export async function getImapConnection(
emitLogs: false, emitLogs: false,
tls: { tls: {
rejectUnauthorized: false rejectUnauthorized: false
},
// Connection timeout settings
disableAutoIdle: false, // Keep idle to auto-refresh connection
idleTimeout: 60000, // 1 minute
idleRefreshTimeout: 30000, // 30 seconds
idleRefreshIntervalMs: 30 * 1000, // 30 seconds
});
await client.connect();
// Add error handler
client.on('error', (err) => {
console.error(`IMAP connection error for ${connectionKey}:`, err);
// Remove from pool on error
if (connectionPool[connectionKey]) {
delete connectionPool[connectionKey];
} }
}); });
try { return client;
await client.connect(); }
console.log(`Successfully connected to IMAP server for ${connectionKey}`);
// Store in connection pool /**
connectionPool[connectionKey] = { * Update session data in Redis
client, */
lastUsed: Date.now() async function updateSessionData(userId: string, accountId?: string): Promise<void> {
}; const sessionData = await getCachedImapSession(userId);
return client; if (sessionData) {
} catch (error: unknown) { await cacheImapSession(userId, {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ...sessionData,
console.error(`IMAP connection error for ${connectionKey}:`, errorMessage); lastActive: Date.now(),
throw new Error(`Failed to connect to IMAP server: ${errorMessage}`); ...(accountId && { lastAccountId: accountId })
});
} else {
await cacheImapSession(userId, {
lastActive: Date.now(),
...(accountId && { lastAccountId: accountId })
});
} }
} }