NeahNew/lib/redis.ts
2025-05-04 17:14:11 +02:00

744 lines
20 KiB
TypeScript

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<void> {
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<void> {
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<EmailCredentials | null> {
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<void> {
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<ImapSessionData | null> {
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<void> {
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<any | null> {
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<void> {
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<any | null> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<EmailCredentials | null> {
return getEmailCredentials(userId, accountId);
}
/**
* Cache calendar data for a user
*/
export async function cacheCalendarData(
userId: string,
data: any
): Promise<void> {
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<any | null> {
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<void> {
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<void> {
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<any | null> {
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<void> {
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<void> {
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<any | null> {
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<void> {
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<void> {
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<any | null> {
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<void> {
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);
}
}