import Redis from 'ioredis'; import CryptoJS from 'crypto-js'; // Initialize Redis client let redisClient: Redis | null = null; let isConnecting = false; let connectionAttempts = 0; const MAX_RECONNECT_ATTEMPTS = 5; /** * Get a Redis client instance (singleton pattern) with improved connection management */ 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) { isConnecting = true; connectionAttempts = 0; // Set Redis connection parameters from environment variables only const redisOptions = { host: process.env.REDIS_HOST, port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined, password: process.env.REDIS_PASSWORD, 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); console.log(`Redis reconnect attempt ${times}, retrying in ${delay}ms`); return delay; }, maxRetriesPerRequest: 5, 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'); redisClient = new Redis(redisOptions); redisClient.on('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', () => { console.log('Successfully connected to Redis'); isConnecting = false; connectionAttempts = 0; }); redisClient.on('reconnecting', () => { 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 }); } return redisClient; } /** * Close Redis connection (useful for serverless environments) */ export async function closeRedisConnection(): Promise { if (redisClient) { await redisClient.quit(); redisClient = null; } } // Encryption key from environment variable or fallback const getEncryptionKey = () => { return process.env.REDIS_ENCRYPTION_KEY || 'default-encryption-key-change-in-production'; }; /** * Encrypt sensitive data before storing in Redis */ export function encryptData(data: string): string { return CryptoJS.AES.encrypt(data, getEncryptionKey()).toString(); } /** * Decrypt sensitive data retrieved from Redis */ export function decryptData(encryptedData: string): string { const bytes = CryptoJS.AES.decrypt(encryptedData, getEncryptionKey()); return bytes.toString(CryptoJS.enc.Utf8); } // Cache key definitions export const KEYS = { CREDENTIALS: (userId: string, accountId: string) => `email:credentials:${userId}:${accountId}`, SESSION: (userId: string) => `email:session:${userId}`, EMAIL_LIST: (userId: string, accountId: string, folder: string, page: number, perPage: number) => `email:list:${userId}:${accountId}:${folder}:${page}:${perPage}`, EMAIL_CONTENT: (userId: string, accountId: string, emailId: string) => `email:content:${userId}:${accountId}:${emailId}`, // New widget cache keys CALENDAR: (userId: string) => `widget:calendar:${userId}`, NEWS: (limit = '100') => `widget:news:${limit}`, // Include limit in cache key TASKS: (userId: string) => `widget:tasks:${userId}`, MESSAGES: (userId: string) => `widget:messages:${userId}` }; // TTL constants in seconds export const TTL = { CREDENTIALS: 60 * 60 * 24, // 24 hours SESSION: 60 * 60 * 4, // 4 hours (increased from 30 minutes) EMAIL_LIST: 60 * 5, // 5 minutes EMAIL_CONTENT: 60 * 15, // 15 minutes // New widget cache TTLs CALENDAR: 60 * 10, // 10 minutes for calendar events NEWS: 60 * 15, // 15 minutes for news TASKS: 60 * 10, // 10 minutes for tasks MESSAGES: 60 * 2 // 2 minutes for messages (more frequent updates) }; interface EmailCredentials { email: string; password?: string; host: string; port: number; secure?: boolean; encryptedPassword?: string; smtp_host?: string; smtp_port?: number; smtp_secure?: boolean; display_name?: string; color?: string; useOAuth?: boolean; accessToken?: string; refreshToken?: string; tokenExpiry?: number; } interface ImapSessionData { connectionId?: string; lastActive: number; mailboxes?: string[]; lastVisit?: number; defaultAccountId?: string; } /** * Cache email credentials in Redis */ export async function cacheEmailCredentials( userId: string, accountId: string, credentials: EmailCredentials ): Promise { const redis = getRedisClient(); const key = KEYS.CREDENTIALS(userId, accountId); // Validate credentials before caching if (!credentials.email || !credentials.host || (!credentials.password && !credentials.useOAuth)) { 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 }), // Include OAuth fields ...(credentials.useOAuth !== undefined && { useOAuth: credentials.useOAuth }), ...(credentials.accessToken && { accessToken: credentials.accessToken }), ...(credentials.refreshToken && { refreshToken: credentials.refreshToken }), ...(credentials.tokenExpiry && { tokenExpiry: credentials.tokenExpiry }) }; // Encrypt password if provided 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); // Continue anyway since we might have OAuth tokens } } 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 email credentials from Redis */ export async function getEmailCredentials( userId: string, accountId: string ): Promise { const redis = getRedisClient(); const key = KEYS.CREDENTIALS(userId, accountId); try { const credStr = await redis.get(key); if (!credStr) { return null; } const creds = JSON.parse(credStr) as EmailCredentials; let password: string | undefined; // Handle OAuth accounts (they might not have a password) if (creds.encryptedPassword) { try { // Decrypt the password password = decryptData(creds.encryptedPassword); } catch (decryptError) { console.error(`Failed to decrypt password for user ${userId}:`, decryptError); // For OAuth accounts, we can continue without a password if (!creds.useOAuth) { return null; } } } // Return the full credentials with decrypted password if available const result: EmailCredentials = { email: creds.email, host: creds.host, port: creds.port, secure: creds.secure ?? true, ...(password && { password }), ...(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 }), // Include OAuth fields ...(creds.useOAuth !== undefined && { useOAuth: creds.useOAuth }), ...(creds.accessToken && { accessToken: creds.accessToken }), ...(creds.refreshToken && { refreshToken: creds.refreshToken }), ...(creds.tokenExpiry && { tokenExpiry: creds.tokenExpiry }) }; return result; } catch (error) { console.error(`Error getting credentials for user ${userId}:`, error); return null; } } /** * Cache IMAP session data for quick reconnection */ export async function cacheImapSession( userId: string, sessionData: ImapSessionData ): Promise { const redis = getRedisClient(); const key = KEYS.SESSION(userId); // Always update the lastActive timestamp sessionData.lastActive = Date.now(); await redis.set(key, JSON.stringify(sessionData), 'EX', TTL.SESSION); } /** * Get cached IMAP session data */ export async function getCachedImapSession( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.SESSION(userId); const cachedData = await redis.get(key); if (!cachedData) return null; return JSON.parse(cachedData) as ImapSessionData; } /** * Cache email list in Redis */ export async function cacheEmailList( userId: string, accountId: string, folder: string, page: number, perPage: number, data: any ): Promise { const redis = getRedisClient(); const key = KEYS.EMAIL_LIST(userId, accountId, folder, page, perPage); await redis.set(key, JSON.stringify(data), 'EX', TTL.EMAIL_LIST); } /** * Get cached email list from Redis */ export async function getCachedEmailList( userId: string, accountId: string, folder: string, page: number, perPage: number ): Promise { const redis = getRedisClient(); const key = KEYS.EMAIL_LIST(userId, accountId, folder, page, perPage); const cachedData = await redis.get(key); if (!cachedData) return null; return JSON.parse(cachedData); } /** * Cache email content in Redis */ export async function cacheEmailContent( userId: string, accountId: string, emailId: string, data: any ): Promise { const redis = getRedisClient(); const key = KEYS.EMAIL_CONTENT(userId, accountId, emailId); await redis.set(key, JSON.stringify(data), 'EX', TTL.EMAIL_CONTENT); } /** * Get cached email content from Redis */ export async function getCachedEmailContent( userId: string, accountId: string, emailId: string ): Promise { const redis = getRedisClient(); const key = KEYS.EMAIL_CONTENT(userId, accountId, emailId); const cachedData = await redis.get(key); if (!cachedData) return null; return JSON.parse(cachedData); } /** * Invalidate all email caches for a folder */ export async function invalidateFolderCache( userId: string, accountId: string, folder: string ): Promise { const redis = getRedisClient(); const pattern = `email:list:${userId}:${accountId}:${folder}:*`; // Use SCAN to find and delete keys matching the pattern let cursor = '0'; do { const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); cursor = nextCursor; if (keys.length > 0) { await redis.del(...keys); } } while (cursor !== '0'); } /** * Invalidate email content cache */ export async function invalidateEmailContentCache( userId: string, accountId: string, emailId: string ): Promise { const redis = getRedisClient(); const key = KEYS.EMAIL_CONTENT(userId, accountId, emailId); await redis.del(key); } /** * Warm up Redis connection to avoid cold starts */ export async function warmupRedisCache(): Promise { try { // Ping Redis to establish connection early const redis = getRedisClient(); await redis.ping(); console.log('Redis connection warmed up'); return true; } catch (error) { console.error('Error warming up Redis:', error); return false; } } /** * Get Redis connection status */ export async function getRedisStatus(): Promise<{ status: 'connected' | 'error'; ping?: string; error?: string; }> { try { const redis = getRedisClient(); const pong = await redis.ping(); return { status: 'connected', ping: pong }; } catch (error) { return { status: 'error', error: error instanceof Error ? error.message : String(error) }; } } /** * Invalidate all user email caches (email lists and content) */ export async function invalidateUserEmailCache( userId: string ): Promise { const redis = getRedisClient(); // Patterns to delete const patterns = [ `email:list:${userId}:*`, `email:content:${userId}:*` ]; for (const pattern of patterns) { let cursor = '0'; do { const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); cursor = nextCursor; if (keys.length > 0) { await redis.del(...keys); } } while (cursor !== '0'); } } /** * Get cached email credentials from Redis * @deprecated Use getEmailCredentials instead */ export async function getCachedEmailCredentials( userId: string, accountId: string ): Promise { return getEmailCredentials(userId, accountId); } /** * Cache calendar data for a user */ export async function cacheCalendarData( userId: string, data: any ): Promise { const redis = getRedisClient(); const key = KEYS.CALENDAR(userId); try { await redis.set(key, JSON.stringify(data), 'EX', TTL.CALENDAR); console.log(`Calendar data cached for user ${userId}`); } catch (error) { console.error(`Error caching calendar data for user ${userId}:`, error); } } /** * Get cached calendar data for a user */ export async function getCachedCalendarData( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.CALENDAR(userId); try { const cachedData = await redis.get(key); if (!cachedData) { return null; } return JSON.parse(cachedData); } catch (error) { console.error(`Error getting cached calendar data for user ${userId}:`, error); return null; } } /** * Invalidate calendar cache for a user */ export async function invalidateCalendarCache( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.CALENDAR(userId); try { await redis.del(key); console.log(`Calendar cache invalidated for user ${userId}`); } catch (error) { console.error(`Error invalidating calendar cache for user ${userId}:`, error); } } /** * Cache news data (global, not user-specific) */ export async function cacheNewsData( data: any, limit = '100' ): Promise { const redis = getRedisClient(); const key = KEYS.NEWS(limit); try { await redis.set(key, JSON.stringify(data), 'EX', TTL.NEWS); console.log(`News data cached successfully (${data.length} articles, limit=${limit})`); } catch (error) { console.error('Error caching news data:', error); } } /** * Get cached news data */ export async function getCachedNewsData(limit = '100'): Promise { const redis = getRedisClient(); const key = KEYS.NEWS(limit); try { const cachedData = await redis.get(key); if (!cachedData) { return null; } const parsedData = JSON.parse(cachedData); console.log(`Retrieved ${parsedData.length} articles from cache with limit=${limit}`); return parsedData; } catch (error) { console.error('Error getting cached news data:', error); return null; } } /** * Invalidate news cache */ export async function invalidateNewsCache(limit?: string): Promise { const redis = getRedisClient(); try { if (limit) { // Invalidate specific limit cache const key = KEYS.NEWS(limit); await redis.del(key); console.log(`News cache invalidated for limit=${limit}`); } else { // Try to invalidate for some common limits const limits = ['5', '50', '100', '200']; for (const lim of limits) { const key = KEYS.NEWS(lim); await redis.del(key); } console.log('All news caches invalidated'); } } catch (error) { console.error('Error invalidating news cache:', error); } } /** * Cache tasks data for a user */ export async function cacheTasksData( userId: string, data: any ): Promise { const redis = getRedisClient(); const key = KEYS.TASKS(userId); try { await redis.set(key, JSON.stringify(data), 'EX', TTL.TASKS); console.log(`Tasks data cached for user ${userId}`); } catch (error) { console.error(`Error caching tasks data for user ${userId}:`, error); } } /** * Get cached tasks data for a user */ export async function getCachedTasksData( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.TASKS(userId); try { const cachedData = await redis.get(key); if (!cachedData) { return null; } return JSON.parse(cachedData); } catch (error) { console.error(`Error getting cached tasks data for user ${userId}:`, error); return null; } } /** * Invalidate tasks cache for a user */ export async function invalidateTasksCache( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.TASKS(userId); try { await redis.del(key); console.log(`Tasks cache invalidated for user ${userId}`); } catch (error) { console.error(`Error invalidating tasks cache for user ${userId}:`, error); } } /** * Cache messages data for a user */ export async function cacheMessagesData( userId: string, data: any ): Promise { const redis = getRedisClient(); const key = KEYS.MESSAGES(userId); try { await redis.set(key, JSON.stringify(data), 'EX', TTL.MESSAGES); console.log(`Messages data cached for user ${userId}`); } catch (error) { console.error(`Error caching messages data for user ${userId}:`, error); } } /** * Get cached messages data for a user */ export async function getCachedMessagesData( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.MESSAGES(userId); try { const cachedData = await redis.get(key); if (!cachedData) { return null; } return JSON.parse(cachedData); } catch (error) { console.error(`Error getting cached messages data for user ${userId}:`, error); return null; } } /** * Invalidate messages cache for a user */ export async function invalidateMessagesCache( userId: string ): Promise { const redis = getRedisClient(); const key = KEYS.MESSAGES(userId); try { await redis.del(key); console.log(`Messages cache invalidated for user ${userId}`); } catch (error) { console.error(`Error invalidating messages cache for user ${userId}:`, error); } }