courrier multi account restore compose
This commit is contained in:
parent
363e999dcd
commit
a7b023e359
@ -5,14 +5,18 @@ import { getImapConnection } from '@/lib/services/email-service';
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getRedisClient } from '@/lib/redis';
|
import { getRedisClient } from '@/lib/redis';
|
||||||
|
|
||||||
// Cache TTL for unread counts (30 seconds)
|
// Cache TTL for unread counts (increased to 2 minutes for better performance)
|
||||||
const UNREAD_COUNTS_CACHE_TTL = 30;
|
const UNREAD_COUNTS_CACHE_TTL = 120;
|
||||||
// Key for unread counts cache
|
// Key for unread counts cache
|
||||||
const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`;
|
const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`;
|
||||||
|
// Refresh lock key to prevent parallel refreshes
|
||||||
|
const REFRESH_LOCK_KEY = (userId: string) => `email:unread-refresh:${userId}`;
|
||||||
|
// Lock TTL to prevent stuck locks (30 seconds)
|
||||||
|
const REFRESH_LOCK_TTL = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route for fetching unread counts for email folders
|
* API route for fetching unread counts for email folders
|
||||||
* Optimized with proper caching and connection reuse
|
* Optimized with proper caching, connection reuse, and background refresh
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -33,88 +37,72 @@ export async function GET(request: Request) {
|
|||||||
if (cachedCounts) {
|
if (cachedCounts) {
|
||||||
// Use cached results if available
|
// Use cached results if available
|
||||||
console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`);
|
console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`);
|
||||||
|
|
||||||
|
// If the cache is about to expire, schedule a background refresh
|
||||||
|
const ttl = await redis.ttl(UNREAD_COUNTS_CACHE_KEY(userId));
|
||||||
|
if (ttl < UNREAD_COUNTS_CACHE_TTL / 2) {
|
||||||
|
// Only refresh if not already refreshing (use a lock)
|
||||||
|
const lockAcquired = await redis.set(
|
||||||
|
REFRESH_LOCK_KEY(userId),
|
||||||
|
Date.now().toString(),
|
||||||
|
'EX',
|
||||||
|
REFRESH_LOCK_TTL,
|
||||||
|
'NX' // Set only if key doesn't exist
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lockAcquired) {
|
||||||
|
console.log(`[UNREAD_API] Scheduling background refresh for user ${userId}`);
|
||||||
|
// Use Promise to run in background
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshUnreadCounts(userId, redis)
|
||||||
|
.catch(err => console.error(`[UNREAD_API] Background refresh error: ${err}`))
|
||||||
|
.finally(() => {
|
||||||
|
// Release lock regardless of outcome
|
||||||
|
redis.del(REFRESH_LOCK_KEY(userId)).catch(() => {});
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(JSON.parse(cachedCounts));
|
return NextResponse.json(JSON.parse(cachedCounts));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`);
|
console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`);
|
||||||
|
|
||||||
// Get all accounts from the database directly
|
// Try to acquire lock to prevent parallel refreshes
|
||||||
const accounts = await prisma.mailCredentials.findMany({
|
const lockAcquired = await redis.set(
|
||||||
where: { userId },
|
REFRESH_LOCK_KEY(userId),
|
||||||
select: {
|
Date.now().toString(),
|
||||||
id: true,
|
|
||||||
email: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
|
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
return NextResponse.json({ default: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapping to hold the unread counts
|
|
||||||
const unreadCounts: Record<string, Record<string, number>> = {};
|
|
||||||
|
|
||||||
// For each account, get the unread counts for standard folders
|
|
||||||
for (const account of accounts) {
|
|
||||||
const accountId = account.id;
|
|
||||||
try {
|
|
||||||
// Get IMAP connection for this account
|
|
||||||
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
|
|
||||||
const client = await getImapConnection(userId, accountId);
|
|
||||||
unreadCounts[accountId] = {};
|
|
||||||
|
|
||||||
// Standard folders to check
|
|
||||||
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
|
|
||||||
|
|
||||||
// Get mailboxes for this account to check if folders exist
|
|
||||||
const mailboxes = await client.list();
|
|
||||||
const availableFolders = mailboxes.map(mb => mb.path);
|
|
||||||
|
|
||||||
// Check each standard folder if it exists
|
|
||||||
for (const folder of standardFolders) {
|
|
||||||
// Skip if folder doesn't exist in this account
|
|
||||||
if (!availableFolders.includes(folder) &&
|
|
||||||
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check folder status without opening it (more efficient)
|
|
||||||
const status = await client.status(folder, { unseen: true });
|
|
||||||
|
|
||||||
if (status && typeof status.unseen === 'number') {
|
|
||||||
// Store the unread count
|
|
||||||
unreadCounts[accountId][folder] = status.unseen;
|
|
||||||
|
|
||||||
// Also store with prefixed version for consistency
|
|
||||||
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
|
|
||||||
|
|
||||||
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
|
|
||||||
}
|
|
||||||
} catch (folderError) {
|
|
||||||
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
|
|
||||||
// Continue to next folder even if this one fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't close the connection - let the connection pool handle it
|
|
||||||
// This avoids opening and closing connections repeatedly
|
|
||||||
} catch (accountError) {
|
|
||||||
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to cache for 30 seconds to avoid hammering the IMAP server
|
|
||||||
await redis.set(
|
|
||||||
UNREAD_COUNTS_CACHE_KEY(userId),
|
|
||||||
JSON.stringify(unreadCounts),
|
|
||||||
'EX',
|
'EX',
|
||||||
UNREAD_COUNTS_CACHE_TTL
|
REFRESH_LOCK_TTL,
|
||||||
|
'NX' // Set only if key doesn't exist
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json(unreadCounts);
|
if (!lockAcquired) {
|
||||||
|
console.log(`[UNREAD_API] Another process is refreshing unread counts for ${userId}`);
|
||||||
|
|
||||||
|
// Return empty counts with short cache time if we can't acquire lock
|
||||||
|
// The next request will likely get cached data
|
||||||
|
return NextResponse.json({ _status: 'pending_refresh' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch new counts
|
||||||
|
const unreadCounts = await fetchUnreadCounts(userId);
|
||||||
|
|
||||||
|
// Save to cache with longer TTL (2 minutes)
|
||||||
|
await redis.set(
|
||||||
|
UNREAD_COUNTS_CACHE_KEY(userId),
|
||||||
|
JSON.stringify(unreadCounts),
|
||||||
|
'EX',
|
||||||
|
UNREAD_COUNTS_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(unreadCounts);
|
||||||
|
} finally {
|
||||||
|
// Always release lock
|
||||||
|
await redis.del(REFRESH_LOCK_KEY(userId));
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[UNREAD_API] Error fetching unread counts:", error);
|
console.error("[UNREAD_API] Error fetching unread counts:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -124,6 +112,103 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background refresh function to update cache without blocking the API response
|
||||||
|
*/
|
||||||
|
async function refreshUnreadCounts(userId: string, redis: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`[UNREAD_API] Background refresh started for user ${userId}`);
|
||||||
|
const unreadCounts = await fetchUnreadCounts(userId);
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
await redis.set(
|
||||||
|
UNREAD_COUNTS_CACHE_KEY(userId),
|
||||||
|
JSON.stringify(unreadCounts),
|
||||||
|
'EX',
|
||||||
|
UNREAD_COUNTS_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Background refresh completed for user ${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[UNREAD_API] Background refresh failed for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core function to fetch unread counts from IMAP
|
||||||
|
*/
|
||||||
|
async function fetchUnreadCounts(userId: string): Promise<Record<string, Record<string, number>>> {
|
||||||
|
// Get all accounts from the database directly
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return { default: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping to hold the unread counts
|
||||||
|
const unreadCounts: Record<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
// For each account, get the unread counts for standard folders
|
||||||
|
for (const account of accounts) {
|
||||||
|
const accountId = account.id;
|
||||||
|
try {
|
||||||
|
// Get IMAP connection for this account
|
||||||
|
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
|
||||||
|
const client = await getImapConnection(userId, accountId);
|
||||||
|
unreadCounts[accountId] = {};
|
||||||
|
|
||||||
|
// Standard folders to check
|
||||||
|
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
|
||||||
|
|
||||||
|
// Get mailboxes for this account to check if folders exist
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
const availableFolders = mailboxes.map(mb => mb.path);
|
||||||
|
|
||||||
|
// Check each standard folder if it exists
|
||||||
|
for (const folder of standardFolders) {
|
||||||
|
// Skip if folder doesn't exist in this account
|
||||||
|
if (!availableFolders.includes(folder) &&
|
||||||
|
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check folder status without opening it (more efficient)
|
||||||
|
const status = await client.status(folder, { unseen: true });
|
||||||
|
|
||||||
|
if (status && typeof status.unseen === 'number') {
|
||||||
|
// Store the unread count
|
||||||
|
unreadCounts[accountId][folder] = status.unseen;
|
||||||
|
|
||||||
|
// Also store with prefixed version for consistency
|
||||||
|
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
|
||||||
|
}
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
|
||||||
|
// Continue to next folder even if this one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't close the connection - let the connection pool handle it
|
||||||
|
} catch (accountError) {
|
||||||
|
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unreadCounts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get all account IDs for a user
|
* Helper to get all account IDs for a user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -625,19 +625,44 @@ export const useEmailState = () => {
|
|||||||
// Skip fetching if an email was viewed recently (within last 5 seconds)
|
// Skip fetching if an email was viewed recently (within last 5 seconds)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastViewedTimestamp = (window as any).__lastViewedEmailTimestamp || 0;
|
const lastViewedTimestamp = (window as any).__lastViewedEmailTimestamp || 0;
|
||||||
if (lastViewedTimestamp && now - lastViewedTimestamp < 5000) { // Increased from 2000ms for better performance
|
if (lastViewedTimestamp && now - lastViewedTimestamp < 5000) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset failure tracking if it's been more than 1 minute since last failure
|
// Try to get from sessionStorage first for faster response
|
||||||
if ((window as any).__unreadCountFailures && now - (window as any).__unreadCountFailures > 60000) {
|
try {
|
||||||
(window as any).__unreadCountFailures = 0;
|
const storageKey = `unread_counts_${session.user.id}`;
|
||||||
|
const storedData = sessionStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (storedData) {
|
||||||
|
const { data, timestamp } = JSON.parse(storedData);
|
||||||
|
// Use stored data if it's less than 30 seconds old
|
||||||
|
if (now - timestamp < 30000) {
|
||||||
|
logEmailOp('FETCH_UNREAD', 'Using sessionStorage data', { age: Math.round((now - timestamp)/1000) + 's' });
|
||||||
|
dispatch({ type: 'SET_UNREAD_COUNTS', payload: data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore storage errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exponential backoff for failures
|
// Reset failure tracking if it's been more than 1 minute since last failure
|
||||||
if ((window as any).__unreadCountFailures > 0) {
|
if ((window as any).__unreadCountFailures?.lastFailureTime &&
|
||||||
const backoffMs = Math.min(30000, 1000 * Math.pow(2, (window as any).__unreadCountFailures - 1));
|
now - (window as any).__unreadCountFailures.lastFailureTime > 60000) {
|
||||||
if ((window as any).__unreadCountFailures && now - (window as any).__unreadCountFailures < backoffMs) {
|
(window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff for failures with proper tracking object
|
||||||
|
if (!(window as any).__unreadCountFailures) {
|
||||||
|
(window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((window as any).__unreadCountFailures.count > 0) {
|
||||||
|
const failures = (window as any).__unreadCountFailures.count;
|
||||||
|
const backoffMs = Math.min(30000, 1000 * Math.pow(2, failures - 1));
|
||||||
|
if (now - (window as any).__unreadCountFailures.lastFailureTime < backoffMs) {
|
||||||
|
logEmailOp('BACKOFF', `Skipping unread fetch, in backoff period (${backoffMs}ms)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -650,13 +675,19 @@ export const useEmailState = () => {
|
|||||||
|
|
||||||
const response = await fetch('/api/courrier/unread-counts', {
|
const response = await fetch('/api/courrier/unread-counts', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Add cache control headers
|
||||||
|
cache: 'no-cache',
|
||||||
|
next: { revalidate: 0 }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// If request failed, increment failure count but cap it
|
// If request failed, track failures properly
|
||||||
(window as any).__unreadCountFailures = Math.min((window as any).__unreadCountFailures || 0 + 1, 10);
|
(window as any).__unreadCountFailures.count =
|
||||||
const failures = (window as any).__unreadCountFailures;
|
Math.min((window as any).__unreadCountFailures.count + 1, 10);
|
||||||
|
(window as any).__unreadCountFailures.lastFailureTime = now;
|
||||||
|
|
||||||
|
const failures = (window as any).__unreadCountFailures.count;
|
||||||
|
|
||||||
if (failures > 3) {
|
if (failures > 3) {
|
||||||
// After 3 failures, slow down requests with exponential backoff
|
// After 3 failures, slow down requests with exponential backoff
|
||||||
@ -676,14 +707,40 @@ export const useEmailState = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset failure counter on success
|
// Reset failure counter on success
|
||||||
(window as any).__unreadCountFailures = 0;
|
(window as any).__unreadCountFailures = { count: 0, lastFailureTime: 0 };
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const timeAfterCall = performance.now();
|
const timeAfterCall = performance.now();
|
||||||
logEmailOp('FETCH_UNREAD', `Received unread counts in ${(timeAfterCall - timeBeforeCall).toFixed(2)}ms`, data);
|
|
||||||
|
// Skip if we got the "pending_refresh" status
|
||||||
|
if (data._status === 'pending_refresh') {
|
||||||
|
logEmailOp('FETCH_UNREAD', 'Server is refreshing counts, will try again soon');
|
||||||
|
|
||||||
|
// Retry after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchUnreadCounts();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logEmailOp('FETCH_UNREAD', `Received unread counts in ${(timeAfterCall - timeBeforeCall).toFixed(2)}ms`);
|
||||||
|
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
dispatch({ type: 'SET_UNREAD_COUNTS', payload: data });
|
dispatch({ type: 'SET_UNREAD_COUNTS', payload: data });
|
||||||
|
|
||||||
|
// Store in sessionStorage for faster future access
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
`unread_counts_${session.user.id}`,
|
||||||
|
JSON.stringify({
|
||||||
|
data,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -705,14 +762,13 @@ export const useEmailState = () => {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastUpdate = (window as any).__lastUnreadUpdate;
|
const lastUpdate = (window as any).__lastUnreadUpdate;
|
||||||
const MIN_UPDATE_INTERVAL = 2000; // 2 seconds minimum between updates
|
const MIN_UPDATE_INTERVAL = 10000; // 10 seconds minimum between updates (increased from 2s)
|
||||||
|
|
||||||
if (now - lastUpdate.timestamp < MIN_UPDATE_INTERVAL) {
|
if (now - lastUpdate.timestamp < MIN_UPDATE_INTERVAL) {
|
||||||
return; // Skip if updated too recently
|
return; // Skip if updated too recently
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rather than calculating locally, let's fetch from the API
|
// Rather than calculating locally, fetch from the API
|
||||||
// This ensures we get accurate server-side counts
|
|
||||||
fetchUnreadCounts();
|
fetchUnreadCounts();
|
||||||
|
|
||||||
// Update timestamp of last update
|
// Update timestamp of last update
|
||||||
@ -726,7 +782,7 @@ export const useEmailState = () => {
|
|||||||
// Debounce unread count updates to prevent rapid multiple updates
|
// Debounce unread count updates to prevent rapid multiple updates
|
||||||
let updateTimeoutId: ReturnType<typeof setTimeout>;
|
let updateTimeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const debounceMs = 2000; // Increase debounce to 2 seconds
|
const debounceMs = 5000; // Increase debounce to 5 seconds (from 2s)
|
||||||
|
|
||||||
// Function to call after debounce period
|
// Function to call after debounce period
|
||||||
const debouncedUpdate = () => {
|
const debouncedUpdate = () => {
|
||||||
@ -738,9 +794,17 @@ export const useEmailState = () => {
|
|||||||
// Clear any existing timeout and start a new one
|
// Clear any existing timeout and start a new one
|
||||||
debouncedUpdate();
|
debouncedUpdate();
|
||||||
|
|
||||||
|
// Also set up a periodic refresh every minute if the tab is active
|
||||||
|
const periodicRefreshId = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
updateUnreadCounts();
|
||||||
|
}
|
||||||
|
}, 60000); // 1 minute
|
||||||
|
|
||||||
// Cleanup timeout on unmount or state change
|
// Cleanup timeout on unmount or state change
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(updateTimeoutId);
|
clearTimeout(updateTimeoutId);
|
||||||
|
clearInterval(periodicRefreshId);
|
||||||
};
|
};
|
||||||
// Deliberately exclude unreadCountMap to prevent infinite loops
|
// Deliberately exclude unreadCountMap to prevent infinite loops
|
||||||
}, [state.emails, updateUnreadCounts]);
|
}, [state.emails, updateUnreadCounts]);
|
||||||
@ -801,11 +865,3 @@ export const useEmailState = () => {
|
|||||||
viewEmail
|
viewEmail
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
async function cacheEmails(userId, folder, accountId, page, perPage, emails) {
|
|
||||||
const client = await getRedisClient();
|
|
||||||
const key = `${userId}:${accountId}:${folder}:${page}:${perPage}`;
|
|
||||||
|
|
||||||
// Cache with TTL of 15 minutes (900 seconds)
|
|
||||||
await client.setEx(key, 900, JSON.stringify(emails));
|
|
||||||
}
|
|
||||||
53
lib/redis.ts
53
lib/redis.ts
@ -3,23 +3,52 @@ import CryptoJS from 'crypto-js';
|
|||||||
|
|
||||||
// Initialize Redis client
|
// Initialize Redis client
|
||||||
let redisClient: Redis | null = null;
|
let redisClient: Redis | null = null;
|
||||||
|
let isConnecting = false;
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a Redis client instance (singleton pattern)
|
* Get a Redis client instance (singleton pattern) with improved connection management
|
||||||
*/
|
*/
|
||||||
export function getRedisClient(): Redis {
|
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) {
|
if (!redisClient) {
|
||||||
|
isConnecting = true;
|
||||||
|
connectionAttempts = 0;
|
||||||
|
|
||||||
// Set Redis connection parameters from environment variables only
|
// Set Redis connection parameters from environment variables only
|
||||||
const redisOptions = {
|
const redisOptions = {
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined,
|
port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined,
|
||||||
password: process.env.REDIS_PASSWORD,
|
password: process.env.REDIS_PASSWORD,
|
||||||
retryStrategy: (times: number) => {
|
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);
|
const delay = Math.min(times * 100, 5000);
|
||||||
|
console.log(`Redis reconnect attempt ${times}, retrying in ${delay}ms`);
|
||||||
return delay;
|
return delay;
|
||||||
},
|
},
|
||||||
maxRetriesPerRequest: 5,
|
maxRetriesPerRequest: 5,
|
||||||
enableOfflineQueue: true
|
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');
|
console.log('Connecting to Redis using environment variables');
|
||||||
@ -27,14 +56,34 @@ export function getRedisClient(): Redis {
|
|||||||
|
|
||||||
redisClient.on('error', (err) => {
|
redisClient.on('error', (err) => {
|
||||||
console.error('Redis connection 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', () => {
|
redisClient.on('connect', () => {
|
||||||
console.log('Successfully connected to Redis');
|
console.log('Successfully connected to Redis');
|
||||||
|
isConnecting = false;
|
||||||
|
connectionAttempts = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
redisClient.on('reconnecting', () => {
|
redisClient.on('reconnecting', () => {
|
||||||
console.log('Reconnecting to Redis...');
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,33 +31,74 @@ export interface EmailListResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connection pool to reuse IMAP clients
|
// Connection pool to reuse IMAP clients
|
||||||
const connectionPool: Record<string, { client: ImapFlow; lastUsed: number }> = {};
|
const connectionPool: Record<string, {
|
||||||
const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
client: ImapFlow;
|
||||||
|
lastUsed: number;
|
||||||
|
isConnecting: boolean;
|
||||||
|
connectionPromise?: Promise<ImapFlow>;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
const CONNECTION_TIMEOUT = 15 * 60 * 1000; // Increased to 15 minutes for long-lived connections
|
||||||
|
const MAX_POOL_SIZE = 20; // Maximum number of connections to keep in the pool
|
||||||
|
const CONNECTION_CHECK_INTERVAL = 60 * 1000; // Check every minute
|
||||||
|
|
||||||
// Clean up idle connections periodically
|
// Clean up idle connections periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const connectionKeys = Object.keys(connectionPool);
|
||||||
|
|
||||||
Object.entries(connectionPool).forEach(([key, { client, lastUsed }]) => {
|
// If we're over the pool size limit, sort by last used and remove oldest
|
||||||
if (now - lastUsed > CONNECTION_TIMEOUT) {
|
if (connectionKeys.length > MAX_POOL_SIZE) {
|
||||||
console.log(`Closing idle IMAP connection for ${key}`);
|
const sortedConnections = connectionKeys
|
||||||
|
.map(key => ({ key, lastUsed: connectionPool[key].lastUsed }))
|
||||||
|
.sort((a, b) => a.lastUsed - b.lastUsed);
|
||||||
|
|
||||||
|
// Keep the most recently used connections up to the max pool size
|
||||||
|
const connectionsToRemove = sortedConnections.slice(0, sortedConnections.length - MAX_POOL_SIZE);
|
||||||
|
|
||||||
|
connectionsToRemove.forEach(({ key }) => {
|
||||||
|
const connection = connectionPool[key];
|
||||||
try {
|
try {
|
||||||
if (client.usable) {
|
if (connection.client.usable) {
|
||||||
client.logout().catch(err => {
|
connection.client.logout().catch(err => {
|
||||||
console.error(`Error closing connection for ${key}:`, err);
|
console.error(`Error closing excess connection for ${key}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking excess connection status for ${key}:`, error);
|
||||||
|
} finally {
|
||||||
|
delete connectionPool[key];
|
||||||
|
console.log(`Removed excess connection for ${key} from pool (pool size: ${Object.keys(connectionPool).length})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close idle connections
|
||||||
|
Object.entries(connectionPool).forEach(([key, connection]) => {
|
||||||
|
// Skip connections that are currently being established
|
||||||
|
if (connection.isConnecting) return;
|
||||||
|
|
||||||
|
if (now - connection.lastUsed > CONNECTION_TIMEOUT) {
|
||||||
|
console.log(`Closing idle IMAP connection for ${key} (idle for ${Math.round((now - connection.lastUsed)/1000)}s)`);
|
||||||
|
try {
|
||||||
|
if (connection.client.usable) {
|
||||||
|
connection.client.logout().catch(err => {
|
||||||
|
console.error(`Error closing idle connection for ${key}:`, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error checking connection status for ${key}:`, error);
|
console.error(`Error checking connection status for ${key}:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
delete connectionPool[key];
|
delete connectionPool[key];
|
||||||
|
console.log(`Removed idle connection for ${key} from pool (pool size: ${Object.keys(connectionPool).length})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 60 * 1000); // Check every minute
|
}, CONNECTION_CHECK_INTERVAL);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get IMAP connection for a user, reusing existing connections when possible
|
* Get IMAP connection for a user, reusing existing connections when possible
|
||||||
|
* with improved connection handling and error recovery
|
||||||
*/
|
*/
|
||||||
export async function getImapConnection(
|
export async function getImapConnection(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -69,22 +110,87 @@ export async function getImapConnection(
|
|||||||
if (!accountId || accountId === 'default') {
|
if (!accountId || accountId === 'default') {
|
||||||
console.log(`No specific account provided or 'default' requested, trying to find first account for user ${userId}`);
|
console.log(`No specific account provided or 'default' requested, trying to find first account for user ${userId}`);
|
||||||
|
|
||||||
// Query to find all accounts for this user
|
// Try getting the account ID from cache to avoid database query
|
||||||
const accounts = await prisma.mailCredentials.findMany({
|
const sessionData = await getCachedImapSession(userId);
|
||||||
where: { userId },
|
if (sessionData && sessionData.defaultAccountId) {
|
||||||
orderBy: { createdAt: 'asc' },
|
accountId = sessionData.defaultAccountId;
|
||||||
take: 1
|
console.log(`Using cached default account ID: ${accountId}`);
|
||||||
});
|
|
||||||
|
|
||||||
if (accounts && accounts.length > 0) {
|
|
||||||
const firstAccount = accounts[0];
|
|
||||||
console.log(`Using first available account: ${firstAccount.id} (${firstAccount.email})`);
|
|
||||||
accountId = firstAccount.id;
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No email accounts configured for this user');
|
// Query to find all accounts for this user
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accounts && accounts.length > 0) {
|
||||||
|
const firstAccount = accounts[0];
|
||||||
|
console.log(`Using first available account: ${firstAccount.id} (${firstAccount.email})`);
|
||||||
|
accountId = firstAccount.id;
|
||||||
|
|
||||||
|
// Cache default account ID for future use
|
||||||
|
if (sessionData) {
|
||||||
|
await cacheImapSession(userId, {
|
||||||
|
...sessionData,
|
||||||
|
defaultAccountId: accountId,
|
||||||
|
lastActive: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await cacheImapSession(userId, {
|
||||||
|
lastActive: Date.now(),
|
||||||
|
defaultAccountId: accountId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No email accounts configured for this user');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use accountId in connection key to ensure different accounts get different connections
|
||||||
|
const connectionKey = `${userId}:${accountId}`;
|
||||||
|
|
||||||
|
// If we already have a connection for this key
|
||||||
|
if (connectionPool[connectionKey]) {
|
||||||
|
const connection = connectionPool[connectionKey];
|
||||||
|
|
||||||
|
// If a connection is being established, wait for it
|
||||||
|
if (connection.isConnecting && connection.connectionPromise) {
|
||||||
|
console.log(`Connection in progress for ${connectionKey}, waiting for existing connection`);
|
||||||
|
try {
|
||||||
|
const client = await connection.connectionPromise;
|
||||||
|
connection.lastUsed = Date.now();
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error waiting for connection for ${connectionKey}:`, error);
|
||||||
|
// Fall through to create new connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to use existing connection if it's usable
|
||||||
|
try {
|
||||||
|
if (connection.client.usable) {
|
||||||
|
// Touch the connection to mark it as recently used
|
||||||
|
connection.lastUsed = Date.now();
|
||||||
|
console.log(`Reusing existing IMAP connection for ${connectionKey}`);
|
||||||
|
|
||||||
|
// Update session data in Redis
|
||||||
|
await updateSessionData(userId, accountId);
|
||||||
|
|
||||||
|
return connection.client;
|
||||||
|
} else {
|
||||||
|
console.log(`Existing connection for ${connectionKey} not usable, recreating`);
|
||||||
|
// Will create a new connection below
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error checking existing connection for ${connectionKey}:`, error);
|
||||||
|
// Will create a new connection below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, we need a new connection
|
||||||
|
console.log(`Creating new IMAP connection for ${connectionKey}`);
|
||||||
|
|
||||||
// First try to get credentials from Redis cache
|
// First try to get credentials from Redis cache
|
||||||
let credentials = await getCachedEmailCredentials(userId, accountId);
|
let credentials = await getCachedEmailCredentials(userId, accountId);
|
||||||
|
|
||||||
@ -111,43 +217,51 @@ export async function getImapConnection(
|
|||||||
throw new Error('Invalid email credentials configuration');
|
throw new Error('Invalid email credentials configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use accountId in connection key to ensure different accounts get different connections
|
// Create connection record with connecting state
|
||||||
const connectionKey = `${userId}:${accountId}`;
|
connectionPool[connectionKey] = {
|
||||||
const existingConnection = connectionPool[connectionKey];
|
client: null as any, // Will be set once connected
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
isConnecting: true
|
||||||
|
};
|
||||||
|
|
||||||
// Try to get session data from Redis
|
// Create the connection promise
|
||||||
const sessionData = await getCachedImapSession(userId);
|
const connectionPromise = createImapConnection(credentials, connectionKey);
|
||||||
|
connectionPool[connectionKey].connectionPromise = connectionPromise;
|
||||||
|
|
||||||
// Return existing connection if available and connected
|
try {
|
||||||
if (existingConnection) {
|
const client = await connectionPromise;
|
||||||
try {
|
console.log(`Successfully connected to IMAP server for ${connectionKey}`);
|
||||||
if (existingConnection.client.usable) {
|
|
||||||
existingConnection.lastUsed = Date.now();
|
|
||||||
console.log(`Reusing existing IMAP connection for ${connectionKey}`);
|
|
||||||
|
|
||||||
// Update session data in Redis
|
// Update connection record
|
||||||
if (sessionData) {
|
connectionPool[connectionKey] = {
|
||||||
await cacheImapSession(userId, {
|
client,
|
||||||
...sessionData,
|
lastUsed: Date.now(),
|
||||||
lastActive: Date.now()
|
isConnecting: false
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return existingConnection.client;
|
// Update session data in Redis
|
||||||
}
|
await updateSessionData(userId, accountId);
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Existing connection for ${connectionKey} is not usable, creating new connection`);
|
return client;
|
||||||
// Will create a new connection below
|
} catch (error) {
|
||||||
}
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error(`IMAP connection error for ${connectionKey}:`, errorMessage);
|
||||||
|
|
||||||
|
// Clean up failed connection
|
||||||
|
delete connectionPool[connectionKey];
|
||||||
|
|
||||||
|
throw new Error(`Failed to connect to IMAP server: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Creating new IMAP connection for ${connectionKey}`);
|
/**
|
||||||
|
* Helper function to create a new IMAP connection
|
||||||
// Create new connection
|
*/
|
||||||
|
async function createImapConnection(credentials: EmailCredentials, connectionKey: string): Promise<ImapFlow> {
|
||||||
const client = new ImapFlow({
|
const client = new ImapFlow({
|
||||||
host: credentials.host,
|
host: credentials.host,
|
||||||
port: credentials.port,
|
port: credentials.port,
|
||||||
secure: true,
|
secure: credentials.secure ?? true,
|
||||||
auth: {
|
auth: {
|
||||||
user: credentials.email,
|
user: credentials.email,
|
||||||
pass: credentials.password,
|
pass: credentials.password,
|
||||||
@ -156,24 +270,45 @@ export async function getImapConnection(
|
|||||||
emitLogs: false,
|
emitLogs: false,
|
||||||
tls: {
|
tls: {
|
||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
|
},
|
||||||
|
// Connection timeout settings
|
||||||
|
disableAutoIdle: false, // Keep idle to auto-refresh connection
|
||||||
|
idleTimeout: 60000, // 1 minute
|
||||||
|
idleRefreshTimeout: 30000, // 30 seconds
|
||||||
|
idleRefreshIntervalMs: 30 * 1000, // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Add error handler
|
||||||
|
client.on('error', (err) => {
|
||||||
|
console.error(`IMAP connection error for ${connectionKey}:`, err);
|
||||||
|
// Remove from pool on error
|
||||||
|
if (connectionPool[connectionKey]) {
|
||||||
|
delete connectionPool[connectionKey];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
return client;
|
||||||
await client.connect();
|
}
|
||||||
console.log(`Successfully connected to IMAP server for ${connectionKey}`);
|
|
||||||
|
|
||||||
// Store in connection pool
|
/**
|
||||||
connectionPool[connectionKey] = {
|
* Update session data in Redis
|
||||||
client,
|
*/
|
||||||
lastUsed: Date.now()
|
async function updateSessionData(userId: string, accountId?: string): Promise<void> {
|
||||||
};
|
const sessionData = await getCachedImapSession(userId);
|
||||||
|
|
||||||
return client;
|
if (sessionData) {
|
||||||
} catch (error: unknown) {
|
await cacheImapSession(userId, {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
...sessionData,
|
||||||
console.error(`IMAP connection error for ${connectionKey}:`, errorMessage);
|
lastActive: Date.now(),
|
||||||
throw new Error(`Failed to connect to IMAP server: ${errorMessage}`);
|
...(accountId && { lastAccountId: accountId })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await cacheImapSession(userId, {
|
||||||
|
lastActive: Date.now(),
|
||||||
|
...(accountId && { lastAccountId: accountId })
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user