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:
2025-06-12 15:53:12 +02:00
parent c8d8042797
commit 64c35b70f8
11 changed files with 798 additions and 886 deletions

227
server/utils/imap-pool.ts Normal file
View 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;
}
}

View 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;