Add email communication system with encrypted credentials
- Add email components for composing, viewing threads, and credential setup - Implement server API endpoints for sending emails and fetching threads - Add encryption utilities for secure credential storage - Configure email settings in environment variables - Integrate email functionality into interest details modal
This commit is contained in:
266
server/api/email/fetch-thread.ts
Normal file
266
server/api/email/fetch-thread.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import Imap from 'imap';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
||||
import { listFiles, getFileStats } from '~/server/utils/minio';
|
||||
|
||||
interface EmailMessage {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
html?: string;
|
||||
timestamp: string;
|
||||
direction: 'sent' | 'received';
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { clientEmail, interestId, sessionId, limit = 50 } = body;
|
||||
|
||||
if (!clientEmail || !sessionId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Client email and sessionId are required"
|
||||
});
|
||||
}
|
||||
|
||||
// Get encrypted credentials from session
|
||||
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
||||
if (!encryptedCredentials) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Email credentials not found. Please reconnect."
|
||||
});
|
||||
}
|
||||
|
||||
// Decrypt credentials
|
||||
const { email: userEmail, password } = decryptCredentials(encryptedCredentials);
|
||||
|
||||
// First, get emails from MinIO cache if available
|
||||
const cachedEmails: EmailMessage[] = [];
|
||||
if (interestId) {
|
||||
try {
|
||||
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
|
||||
for (const file of files) {
|
||||
if (file.name.endsWith('.json') && !file.isFolder) {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NUXT_MINIO_ENDPOINT || 'http://localhost:9000'}/${useRuntimeConfig().minio.bucketName}/${file.name}`);
|
||||
const emailData = await response.json();
|
||||
cachedEmails.push(emailData);
|
||||
} catch (err) {
|
||||
console.error('Failed to read cached email:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to list cached emails:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure IMAP
|
||||
const imapConfig = {
|
||||
user: userEmail,
|
||||
password: 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
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch emails from IMAP
|
||||
const imapEmails: EmailMessage[] = await new Promise((resolve, reject) => {
|
||||
const emails: EmailMessage[] = [];
|
||||
const imap = new Imap(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
// Search for emails to/from the client
|
||||
imap.openBox('INBOX', true, (err, box) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchCriteria = [
|
||||
'OR',
|
||||
['FROM', clientEmail],
|
||||
['TO', clientEmail]
|
||||
];
|
||||
|
||||
imap.search(searchCriteria, (err, results) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
imap.end();
|
||||
resolve(emails);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit results
|
||||
const messagesToFetch = results.slice(-limit);
|
||||
|
||||
const fetch = imap.fetch(messagesToFetch, {
|
||||
bodies: '',
|
||||
struct: true,
|
||||
envelope: true
|
||||
});
|
||||
|
||||
fetch.on('message', (msg, seqno) => {
|
||||
msg.on('body', (stream, info) => {
|
||||
simpleParser(stream as any, async (err: any, parsed: any) => {
|
||||
if (err) {
|
||||
console.error('Parse error:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const email: EmailMessage = {
|
||||
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
||||
from: parsed.from?.text || '',
|
||||
to: Array.isArray(parsed.to)
|
||||
? parsed.to.map((addr: any) => addr.text).join(', ')
|
||||
: parsed.to?.text || '',
|
||||
subject: parsed.subject || '',
|
||||
body: parsed.text || '',
|
||||
html: parsed.html || undefined,
|
||||
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
||||
direction: parsed.from?.text.includes(userEmail) ? 'sent' : 'received'
|
||||
};
|
||||
|
||||
// Extract thread ID from headers if available
|
||||
if (parsed.headers.has('in-reply-to')) {
|
||||
email.threadId = parsed.headers.get('in-reply-to') as string;
|
||||
}
|
||||
|
||||
emails.push(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (err) => {
|
||||
console.error('Fetch error:', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Also check Sent folder
|
||||
imap.openBox('[Gmail]/Sent Mail', true, (err, box) => {
|
||||
if (err) {
|
||||
// Try common sent folder names
|
||||
['Sent', 'Sent Items', 'Sent Messages'].forEach(folderName => {
|
||||
imap.openBox(folderName, true, (err, box) => {
|
||||
if (!err) {
|
||||
// Search in sent folder
|
||||
imap.search([['TO', clientEmail]], (err, results) => {
|
||||
if (!err && results && results.length > 0) {
|
||||
// Process sent emails similarly
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (err: any) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
resolve(emails);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
|
||||
// Combine cached and IMAP emails, remove duplicates
|
||||
const allEmails = [...cachedEmails, ...imapEmails];
|
||||
const uniqueEmails = Array.from(
|
||||
new Map(allEmails.map(email => [email.id, email])).values()
|
||||
);
|
||||
|
||||
// Sort by timestamp
|
||||
uniqueEmails.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Group into threads
|
||||
const threads = groupIntoThreads(uniqueEmails);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
emails: uniqueEmails,
|
||||
threads: threads
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch email thread:', error);
|
||||
if (error instanceof Error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to fetch emails: ${error.message}`
|
||||
});
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Group emails into threads based on subject and references
|
||||
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
||||
const threads = new Map<string, EmailMessage[]>();
|
||||
|
||||
emails.forEach(email => {
|
||||
// Normalize subject by removing Re:, Fwd:, etc.
|
||||
const normalizedSubject = email.subject
|
||||
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
||||
.trim();
|
||||
|
||||
// Find existing thread or create new one
|
||||
let threadFound = false;
|
||||
for (const [threadId, threadEmails] of threads.entries()) {
|
||||
const threadSubject = threadEmails[0].subject
|
||||
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
||||
.trim();
|
||||
|
||||
if (threadSubject === normalizedSubject) {
|
||||
threadEmails.push(email);
|
||||
threadFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!threadFound) {
|
||||
threads.set(email.id, [email]);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array format
|
||||
return Array.from(threads.entries()).map(([threadId, emails]) => ({
|
||||
id: threadId,
|
||||
subject: emails[0].subject,
|
||||
emailCount: emails.length,
|
||||
latestTimestamp: emails[emails.length - 1].timestamp,
|
||||
emails: emails
|
||||
}));
|
||||
}
|
||||
144
server/api/email/send.ts
Normal file
144
server/api/email/send.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
||||
import { uploadFile } from '~/server/utils/minio';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const {
|
||||
to,
|
||||
subject,
|
||||
body: emailBody,
|
||||
interestId,
|
||||
sessionId,
|
||||
includeSignature = true,
|
||||
signatureConfig
|
||||
} = body;
|
||||
|
||||
if (!to || !subject || !emailBody || !sessionId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "To, subject, body, and sessionId are required"
|
||||
});
|
||||
}
|
||||
|
||||
// Get encrypted credentials from session
|
||||
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
||||
if (!encryptedCredentials) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Email credentials not found. Please reconnect."
|
||||
});
|
||||
}
|
||||
|
||||
// Decrypt credentials
|
||||
const { email, password } = decryptCredentials(encryptedCredentials);
|
||||
|
||||
// Get user info for signature
|
||||
const defaultName = email.split('@')[0].replace('.', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
// Build email signature with customizable fields
|
||||
const sig = signatureConfig || {};
|
||||
const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('<br>') : '';
|
||||
const signature = includeSignature ? `
|
||||
<div style="margin-top: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="font-weight: bold;">${sig.name || defaultName}</div>
|
||||
<div style="color: #666;">${sig.title || 'Sales & Marketing Director'}</div>
|
||||
<br>
|
||||
<div style="font-weight: bold;">${sig.company || 'Port Nimara'}</div>
|
||||
<br>
|
||||
${contactLines ? contactLines + '<br>' : ''}
|
||||
<a href="mailto:${sig.email || email}" style="color: #0066cc;">${sig.email || email}</a>
|
||||
<br><br>
|
||||
<img src="${process.env.NUXT_EMAIL_LOGO_URL || 'https://portnimara.com/logo.png'}" alt="Port Nimara" style="height: 80px;">
|
||||
<br>
|
||||
<div style="color: #666; font-size: 12px; margin-top: 10px;">
|
||||
The information in this message is confidential and may be privileged.<br>
|
||||
It is intended for the addressee alone.<br>
|
||||
If you are not the intended recipient it is prohibited to disclose, use or copy this information.<br>
|
||||
Please contact the Sender immediately should this message have been transmitted incorrectly.
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
// Convert plain text body to HTML with line breaks
|
||||
const htmlBody = emailBody.replace(/\n/g, '<br>') + signature;
|
||||
|
||||
// Configure SMTP transport
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.NUXT_EMAIL_SMTP_HOST || 'mail.portnimara.com',
|
||||
port: parseInt(process.env.NUXT_EMAIL_SMTP_PORT || '587'),
|
||||
secure: false, // false for STARTTLS
|
||||
auth: {
|
||||
user: email,
|
||||
pass: password
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false // Allow self-signed certificates
|
||||
}
|
||||
});
|
||||
|
||||
// Send email
|
||||
const fromName = sig.name || defaultName;
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${fromName}" <${email}>`,
|
||||
to: to,
|
||||
subject: subject,
|
||||
text: emailBody, // Plain text version
|
||||
html: htmlBody // HTML version with signature
|
||||
});
|
||||
|
||||
// Store email in MinIO for thread history
|
||||
if (interestId) {
|
||||
try {
|
||||
const emailData = {
|
||||
id: info.messageId,
|
||||
from: email,
|
||||
to: to,
|
||||
subject: subject,
|
||||
body: emailBody,
|
||||
html: htmlBody,
|
||||
timestamp: new Date().toISOString(),
|
||||
direction: 'sent',
|
||||
interestId: interestId
|
||||
};
|
||||
|
||||
const objectName = `client-emails/interest-${interestId}/${Date.now()}-sent.json`;
|
||||
const buffer = Buffer.from(JSON.stringify(emailData, null, 2));
|
||||
|
||||
await uploadFile(
|
||||
objectName,
|
||||
buffer,
|
||||
'application/json'
|
||||
);
|
||||
} catch (storageError) {
|
||||
console.error('Failed to store email in MinIO:', storageError);
|
||||
// Continue even if storage fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Email sent successfully",
|
||||
messageId: info.messageId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
if (error instanceof Error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to send email: ${error.message}`
|
||||
});
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
106
server/api/email/test-connection.ts
Normal file
106
server/api/email/test-connection.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import Imap from 'imap';
|
||||
import { encryptCredentials, storeCredentialsInSession } from '~/server/utils/encryption';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { email, password, imapHost, smtpHost, sessionId } = body;
|
||||
|
||||
if (!email || !password || !sessionId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Email, password, and sessionId are required"
|
||||
});
|
||||
}
|
||||
|
||||
// Use provided hosts or defaults from environment
|
||||
const imapHostToUse = imapHost || process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com';
|
||||
const smtpHostToUse = smtpHost || process.env.NUXT_EMAIL_SMTP_HOST || 'mail.portnimara.com';
|
||||
const imapPort = parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993');
|
||||
const smtpPort = parseInt(process.env.NUXT_EMAIL_SMTP_PORT || '587');
|
||||
|
||||
// Test SMTP connection
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHostToUse,
|
||||
port: smtpPort,
|
||||
secure: false, // false for STARTTLS
|
||||
auth: {
|
||||
user: email,
|
||||
pass: password
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false // Allow self-signed certificates
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
// Test IMAP connection
|
||||
const imapConfig = {
|
||||
user: email,
|
||||
password: password,
|
||||
host: imapHostToUse,
|
||||
port: imapPort,
|
||||
tls: true,
|
||||
tlsOptions: {
|
||||
rejectUnauthorized: false // Allow self-signed certificates
|
||||
}
|
||||
};
|
||||
|
||||
const testImapConnection = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = new Imap(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
imap.once('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
};
|
||||
|
||||
await testImapConnection();
|
||||
|
||||
// If both connections successful, encrypt and store credentials
|
||||
const encryptedCredentials = encryptCredentials(email, password);
|
||||
storeCredentialsInSession(sessionId, encryptedCredentials);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Email connection tested successfully",
|
||||
email: email
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Email connection test failed:', error);
|
||||
if (error instanceof Error) {
|
||||
// Check for common authentication errors
|
||||
if (error.message.includes('Authentication') || error.message.includes('AUTHENTICATIONFAILED')) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid email or password"
|
||||
});
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Connection failed: ${error.message}`
|
||||
});
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
117
server/utils/encryption.ts
Normal file
117
server/utils/encryption.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const algorithm = 'aes-256-gcm';
|
||||
const saltLength = 64;
|
||||
const tagLength = 16;
|
||||
const ivLength = 16;
|
||||
const iterations = 100000;
|
||||
const keyLength = 32;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const key = process.env.NUXT_EMAIL_ENCRYPTION_KEY;
|
||||
if (!key || key.length < 32) {
|
||||
throw new Error('NUXT_EMAIL_ENCRYPTION_KEY must be at least 32 characters long');
|
||||
}
|
||||
// Ensure key is exactly 32 bytes
|
||||
return Buffer.from(key.substring(0, 32).padEnd(32, '0'));
|
||||
}
|
||||
|
||||
export function encryptCredentials(email: string, password: string): string {
|
||||
try {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(ivLength);
|
||||
const salt = crypto.randomBytes(saltLength);
|
||||
|
||||
const derivedKey = crypto.pbkdf2Sync(key, salt, iterations, keyLength, 'sha256');
|
||||
const cipher = crypto.createCipheriv(algorithm, derivedKey, iv);
|
||||
|
||||
const data = JSON.stringify({ email, password });
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(data, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Combine salt, iv, tag, and encrypted data
|
||||
const combined = Buffer.concat([salt, iv, tag, encrypted]);
|
||||
|
||||
return combined.toString('base64');
|
||||
} catch (error) {
|
||||
throw new Error('Failed to encrypt credentials');
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptCredentials(encryptedData: string): { email: string; password: string } {
|
||||
try {
|
||||
const key = getKey();
|
||||
const combined = Buffer.from(encryptedData, 'base64');
|
||||
|
||||
// Extract components
|
||||
const salt = combined.slice(0, saltLength);
|
||||
const iv = combined.slice(saltLength, saltLength + ivLength);
|
||||
const tag = combined.slice(saltLength + ivLength, saltLength + ivLength + tagLength);
|
||||
const encrypted = combined.slice(saltLength + ivLength + tagLength);
|
||||
|
||||
const derivedKey = crypto.pbkdf2Sync(key, salt, iterations, keyLength, 'sha256');
|
||||
const decipher = crypto.createDecipheriv(algorithm, derivedKey, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
} catch (error) {
|
||||
throw new Error('Failed to decrypt credentials');
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory session storage for credentials (cleared on server restart)
|
||||
const credentialCache = new Map<string, { credentials: string; timestamp: number }>();
|
||||
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export function storeCredentialsInSession(sessionId: string, encryptedCredentials: string): void {
|
||||
credentialCache.set(sessionId, {
|
||||
credentials: encryptedCredentials,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Clean up expired sessions
|
||||
cleanupExpiredSessions();
|
||||
}
|
||||
|
||||
export function getCredentialsFromSession(sessionId: string): string | null {
|
||||
const session = credentialCache.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if (Date.now() - session.timestamp > CACHE_TTL) {
|
||||
credentialCache.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update timestamp on access
|
||||
session.timestamp = Date.now();
|
||||
return session.credentials;
|
||||
}
|
||||
|
||||
export function clearCredentialsFromSession(sessionId: string): void {
|
||||
credentialCache.delete(sessionId);
|
||||
}
|
||||
|
||||
function cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of credentialCache.entries()) {
|
||||
if (now - session.timestamp > CACHE_TTL) {
|
||||
credentialCache.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup expired sessions every 5 minutes
|
||||
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
|
||||
Reference in New Issue
Block a user