NeahStable/app/api/courrier/unread-counts/route.ts
2026-01-16 22:42:51 +01:00

281 lines
9.7 KiB
TypeScript

import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/app/api/auth/options";
import { getImapConnection } from '@/lib/services/email-service';
import { prisma } from '@/lib/prisma';
import { getRedisClient } from '@/lib/redis';
import { logger } from '@/lib/logger';
// Cache TTL for unread counts (increased to 2 minutes for better performance)
const UNREAD_COUNTS_CACHE_TTL = 120;
// Key for unread counts cache
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
* Optimized with proper caching, connection reuse, and background refresh
*/
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const userId = session.user.id;
const redis = getRedisClient();
// First try to get from cache
const cachedCounts = await redis.get(UNREAD_COUNTS_CACHE_KEY(userId));
if (cachedCounts) {
// Use cached results if available
logger.debug('[UNREAD_API] Using cached unread counts', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
});
// 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) {
logger.debug('[UNREAD_API] Scheduling background refresh', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
});
// Use Promise to run in background
setTimeout(() => {
refreshUnreadCounts(userId, redis)
.catch(err => logger.error('[UNREAD_API] Background refresh error', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
error: err instanceof Error ? err.message : String(err)
}))
.finally(() => {
// Release lock regardless of outcome
redis.del(REFRESH_LOCK_KEY(userId)).catch(() => {});
});
}, 0);
}
}
return NextResponse.json(JSON.parse(cachedCounts));
}
logger.debug('[UNREAD_API] Cache miss, fetching unread counts', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
});
// Try to acquire lock to prevent parallel refreshes
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) {
logger.debug('[UNREAD_API] Another process is refreshing unread counts', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
});
// 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) {
logger.error('[UNREAD_API] Error fetching unread counts', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: "Failed to fetch unread counts", message: error.message },
{ status: 500 }
);
}
}
/**
* Background refresh function to update cache without blocking the API response
*/
async function refreshUnreadCounts(userId: string, redis: any): Promise<void> {
try {
logger.debug('[UNREAD_API] Background refresh started', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
});
const unreadCounts = await fetchUnreadCounts(userId);
// Save to cache
await redis.set(
UNREAD_COUNTS_CACHE_KEY(userId),
JSON.stringify(unreadCounts),
'EX',
UNREAD_COUNTS_CACHE_TTL
);
logger.debug('[UNREAD_API] Background refresh completed', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
});
} catch (error) {
logger.error('[UNREAD_API] Background refresh failed', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
error: error instanceof Error ? error.message : String(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
}
});
logger.debug('[UNREAD_API] Found accounts', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
count: accounts.length,
});
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
logger.debug('[UNREAD_API] Processing account', {
userIdHash: Buffer.from(userId).toString('base64').slice(0, 12),
accountIdHash: Buffer.from(accountId).toString('base64').slice(0, 12),
});
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;
logger.debug('[UNREAD_API] Account folder unread count', {
accountIdHash: Buffer.from(accountId).toString('base64').slice(0, 12),
unseen: status.unseen
});
}
} catch (folderError) {
logger.error('[UNREAD_API] Error getting unread count for folder', {
accountIdHash: Buffer.from(accountId).toString('base64').slice(0, 12),
folder,
error: folderError instanceof Error ? folderError.message : String(folderError)
});
// Continue to next folder even if this one fails
}
}
// Don't close the connection - let the connection pool handle it
} catch (accountError) {
logger.error('[UNREAD_API] Error processing account', {
accountIdHash: Buffer.from(accountId).toString('base64').slice(0, 12),
error: accountError instanceof Error ? accountError.message : String(accountError)
});
}
}
return unreadCounts;
}
/**
* Helper to get all account IDs for a user
*/
async function getUserAccountIds(userId: string): Promise<string[]> {
try {
// Get credentials for all accounts from the email service
// This is a simplified version - you should replace this with your actual logic
// to retrieve the user's accounts
// First try the default account
const defaultClient = await getImapConnection(userId, 'default');
const accounts = ['default'];
try {
// Try to get other accounts if they exist
// This is just a placeholder - implement your actual account retrieval logic
// Close the default connection
await defaultClient.logout();
} catch (error) {
logger.error('[UNREAD_API] Error getting additional accounts', {
error: error instanceof Error ? error.message : String(error)
});
}
return accounts;
} catch (error) {
logger.error('[UNREAD_API] Error getting account IDs', {
error: error instanceof Error ? error.message : String(error)
});
return ['default']; // Return at least the default account
}
}