Improve email session management and add IMAP connection pooling
- Switch from localStorage to sessionStorage for email sessions - Add session validation on component mount - Implement IMAP connection pool with folder search capabilities - Add operation locking utility for concurrent request handling - Refactor EOI section component structure - Update API endpoints for better email thread management
This commit is contained in:
227
server/utils/imap-pool.ts
Normal file
227
server/utils/imap-pool.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import Imap from 'imap';
|
||||
import { getCredentialsFromSession, decryptCredentials } from './encryption';
|
||||
|
||||
interface ConnectionPoolItem {
|
||||
connection: any;
|
||||
sessionId: string;
|
||||
lastUsed: number;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
class IMAPConnectionPool {
|
||||
private connections: Map<string, ConnectionPoolItem> = new Map();
|
||||
private maxConnections = 5;
|
||||
private connectionTimeout = 300000; // 5 minutes
|
||||
private reconnectAttempts = 3;
|
||||
private reconnectDelay = 1000; // 1 second
|
||||
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor() {
|
||||
// Cleanup expired connections every minute
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupExpiredConnections();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
async getConnection(sessionId: string): Promise<any> {
|
||||
// Check if we have an existing connection
|
||||
const existing = this.connections.get(sessionId);
|
||||
if (existing && existing.isConnected) {
|
||||
// Test connection health
|
||||
if (await this.testConnection(existing.connection)) {
|
||||
existing.lastUsed = Date.now();
|
||||
return existing.connection;
|
||||
} else {
|
||||
// Connection is dead, remove it
|
||||
this.removeConnection(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new connection
|
||||
return await this.createConnection(sessionId);
|
||||
}
|
||||
|
||||
private async createConnection(sessionId: string): Promise<any> {
|
||||
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
||||
if (!encryptedCredentials) {
|
||||
throw new Error('No credentials found for session');
|
||||
}
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = decryptCredentials(encryptedCredentials);
|
||||
} catch (error) {
|
||||
throw new Error('Failed to decrypt credentials');
|
||||
}
|
||||
|
||||
const imapConfig = {
|
||||
user: credentials.email,
|
||||
password: credentials.password,
|
||||
host: process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com',
|
||||
port: parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'),
|
||||
tls: true,
|
||||
tlsOptions: {
|
||||
rejectUnauthorized: false
|
||||
},
|
||||
connTimeout: 15000, // 15 seconds connection timeout
|
||||
authTimeout: 10000, // 10 seconds auth timeout
|
||||
keepalive: {
|
||||
interval: 10000, // Send keepalive every 10 seconds
|
||||
idleInterval: 300000, // IDLE for 5 minutes
|
||||
forceNoop: true
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = new Imap(imapConfig);
|
||||
let connectionAttempts = 0;
|
||||
|
||||
const attemptConnection = () => {
|
||||
connectionAttempts++;
|
||||
|
||||
imap.once('ready', () => {
|
||||
console.log(`[IMAPPool] Connection established for session ${sessionId}`);
|
||||
|
||||
// Store connection
|
||||
this.connections.set(sessionId, {
|
||||
connection: imap,
|
||||
sessionId,
|
||||
lastUsed: Date.now(),
|
||||
isConnected: true
|
||||
});
|
||||
|
||||
// Set up error handlers
|
||||
imap.on('error', (err: any) => {
|
||||
console.error(`[IMAPPool] Connection error for session ${sessionId}:`, err);
|
||||
this.markConnectionAsDead(sessionId);
|
||||
});
|
||||
|
||||
imap.on('end', () => {
|
||||
console.log(`[IMAPPool] Connection ended for session ${sessionId}`);
|
||||
this.removeConnection(sessionId);
|
||||
});
|
||||
|
||||
resolve(imap);
|
||||
});
|
||||
|
||||
imap.once('error', (err: any) => {
|
||||
console.error(`[IMAPPool] Connection attempt ${connectionAttempts} failed:`, err);
|
||||
|
||||
if (connectionAttempts < this.reconnectAttempts) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
imap.connect();
|
||||
} catch (connectError) {
|
||||
console.error(`[IMAPPool] Reconnect attempt failed:`, connectError);
|
||||
attemptConnection();
|
||||
}
|
||||
}, this.reconnectDelay * connectionAttempts);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
imap.connect();
|
||||
} catch (connectError) {
|
||||
console.error(`[IMAPPool] Initial connect failed:`, connectError);
|
||||
if (connectionAttempts < this.reconnectAttempts) {
|
||||
setTimeout(attemptConnection, this.reconnectDelay);
|
||||
} else {
|
||||
reject(connectError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
attemptConnection();
|
||||
});
|
||||
}
|
||||
|
||||
private async testConnection(imap: any): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
if (!imap || imap.state !== 'authenticated') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test with a simple NOOP command
|
||||
imap.seq.fetch('1:1', { bodies: 'HEADER' }, (err: any) => {
|
||||
// Even if fetch fails, if no connection error then connection is likely OK
|
||||
resolve(!err || err.code !== 'ECONNRESET');
|
||||
});
|
||||
|
||||
// Timeout the test after 5 seconds
|
||||
setTimeout(() => resolve(false), 5000);
|
||||
} catch (error) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private markConnectionAsDead(sessionId: string): void {
|
||||
const connection = this.connections.get(sessionId);
|
||||
if (connection) {
|
||||
connection.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private removeConnection(sessionId: string): void {
|
||||
const connection = this.connections.get(sessionId);
|
||||
if (connection) {
|
||||
try {
|
||||
if (connection.connection && typeof connection.connection.end === 'function') {
|
||||
connection.connection.end();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[IMAPPool] Error closing connection:`, error);
|
||||
}
|
||||
this.connections.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupExpiredConnections(): void {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, connection] of this.connections.entries()) {
|
||||
if (now - connection.lastUsed > this.connectionTimeout) {
|
||||
console.log(`[IMAPPool] Cleaning up expired connection for session ${sessionId}`);
|
||||
this.removeConnection(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async closeConnection(sessionId: string): Promise<void> {
|
||||
this.removeConnection(sessionId);
|
||||
}
|
||||
|
||||
async closeAllConnections(): Promise<void> {
|
||||
for (const sessionId of this.connections.keys()) {
|
||||
this.removeConnection(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
this.closeAllConnections();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let globalIMAPPool: IMAPConnectionPool | null = null;
|
||||
|
||||
export function getIMAPPool(): IMAPConnectionPool {
|
||||
if (!globalIMAPPool) {
|
||||
globalIMAPPool = new IMAPConnectionPool();
|
||||
}
|
||||
return globalIMAPPool;
|
||||
}
|
||||
|
||||
export function destroyIMAPPool(): void {
|
||||
if (globalIMAPPool) {
|
||||
globalIMAPPool.destroy();
|
||||
globalIMAPPool = null;
|
||||
}
|
||||
}
|
||||
164
server/utils/operation-lock.ts
Normal file
164
server/utils/operation-lock.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
interface QueuedOperation<T> {
|
||||
operation: () => Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: any) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Queuing system for graceful handling of rapid operations
|
||||
class OperationQueue {
|
||||
private queues: Map<string, QueuedOperation<any>[]> = new Map();
|
||||
private processing: Map<string, boolean> = new Map();
|
||||
private maxQueueSize = 10;
|
||||
private batchDelay = 100; // 100ms delay for batching rapid operations
|
||||
|
||||
async enqueue<T>(queueKey: string, operation: () => Promise<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Get or create queue for this key
|
||||
let queue = this.queues.get(queueKey);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.queues.set(queueKey, queue);
|
||||
}
|
||||
|
||||
// Check queue size limit
|
||||
if (queue.length >= this.maxQueueSize) {
|
||||
reject(new Error(`Queue for ${queueKey} is full`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Add operation to queue
|
||||
queue.push({
|
||||
operation,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Start processing if not already processing
|
||||
if (!this.processing.get(queueKey)) {
|
||||
this.processQueue(queueKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(queueKey: string): Promise<void> {
|
||||
this.processing.set(queueKey, true);
|
||||
|
||||
try {
|
||||
const queue = this.queues.get(queueKey);
|
||||
if (!queue || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For berth operations, we can batch rapid selections
|
||||
if (queueKey.startsWith('berth-')) {
|
||||
await this.processBerthQueue(queueKey, queue);
|
||||
} else {
|
||||
// For other operations, process one by one
|
||||
await this.processSequentialQueue(queueKey, queue);
|
||||
}
|
||||
} finally {
|
||||
this.processing.set(queueKey, false);
|
||||
|
||||
// Check if more operations were added during processing
|
||||
const queue = this.queues.get(queueKey);
|
||||
if (queue && queue.length > 0) {
|
||||
// Delay slightly before processing next batch
|
||||
setTimeout(() => this.processQueue(queueKey), this.batchDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processBerthQueue(queueKey: string, queue: QueuedOperation<any>[]): Promise<void> {
|
||||
// Wait a short time to collect rapid selections
|
||||
await new Promise(resolve => setTimeout(resolve, this.batchDelay));
|
||||
|
||||
// Get all pending operations
|
||||
const operations = [...queue];
|
||||
queue.length = 0; // Clear the queue
|
||||
|
||||
if (operations.length === 0) return;
|
||||
|
||||
console.log(`[OperationQueue] Processing ${operations.length} berth operations for ${queueKey}`);
|
||||
|
||||
// Process operations in order, but allow them to execute
|
||||
for (const { operation, resolve, reject } of operations) {
|
||||
try {
|
||||
const result = await operation();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
console.error(`[OperationQueue] Operation failed for ${queueKey}:`, error);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processSequentialQueue(queueKey: string, queue: QueuedOperation<any>[]): Promise<void> {
|
||||
while (queue.length > 0) {
|
||||
const { operation, resolve, reject } = queue.shift()!;
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
console.error(`[OperationQueue] Operation failed for ${queueKey}:`, error);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old queues
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 300000; // 5 minutes
|
||||
|
||||
for (const [queueKey, queue] of this.queues.entries()) {
|
||||
// Remove operations older than maxAge
|
||||
for (let i = queue.length - 1; i >= 0; i--) {
|
||||
if (now - queue[i].timestamp > maxAge) {
|
||||
const { reject } = queue[i];
|
||||
reject(new Error('Operation timeout'));
|
||||
queue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty queues
|
||||
if (queue.length === 0) {
|
||||
this.queues.delete(queueKey);
|
||||
this.processing.delete(queueKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let globalOperationQueue: OperationQueue | null = null;
|
||||
|
||||
export function getOperationQueue(): OperationQueue {
|
||||
if (!globalOperationQueue) {
|
||||
globalOperationQueue = new OperationQueue();
|
||||
|
||||
// Clean up expired queues every minute
|
||||
setInterval(() => {
|
||||
globalOperationQueue?.cleanup();
|
||||
}, 60000);
|
||||
}
|
||||
return globalOperationQueue;
|
||||
}
|
||||
|
||||
// Helper function for berth operations (allows rapid selection queuing)
|
||||
export async function withBerthQueue<T>(interestId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const queue = getOperationQueue();
|
||||
return queue.enqueue(`berth-${interestId}`, operation);
|
||||
}
|
||||
|
||||
// Helper function for EOI operations
|
||||
export async function withEOIQueue<T>(interestId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const queue = getOperationQueue();
|
||||
return queue.enqueue(`eoi-${interestId}`, operation);
|
||||
}
|
||||
|
||||
// Legacy function names for backward compatibility
|
||||
export const withBerthLock = withBerthQueue;
|
||||
export const withEOILock = withEOIQueue;
|
||||
Reference in New Issue
Block a user