import { NotificationAdapter } from './notification-adapter.interface'; import { logger } from '@/lib/logger'; import { Notification, NotificationCount } from '@/lib/types/notification'; import { getRedisClient } from '@/lib/redis'; import { getImapConnection, shouldUseGraphAPI, getUserEmailAccounts } from '@/lib/services/email-service'; import { getGraphUnreadCount, fetchGraphEmails } from '@/lib/services/microsoft-graph-mail'; export class EmailAdapter implements NotificationAdapter { readonly sourceName = 'email'; private static readonly UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`; private static readonly CACHE_TTL = 120; // 2 minutes (aligned with unread-counts API) async isConfigured(): Promise { // Email service is always configured if user has email accounts return true; } /** * Fetch unread counts from IMAP (same logic as unread-counts API) */ private async fetchUnreadCounts(userId: string): Promise>> { // Get all accounts from the database const accounts = await getUserEmailAccounts(userId); logger.debug('[EMAIL_ADAPTER] Found accounts', { userId, accountCount: accounts.length, }); if (accounts.length === 0) { return { default: {} }; } // Mapping to hold the unread counts const unreadCounts: Record> = {}; // For each account, get the unread counts for standard folders for (const account of accounts) { const accountId = account.id; try { logger.debug('[EMAIL_ADAPTER] Processing account', { userId, accountId, email: account.email, }); // Check if this is a Microsoft account that should use Graph API const graphCheck = await shouldUseGraphAPI(userId, accountId); unreadCounts[accountId] = {}; if (graphCheck.useGraph && graphCheck.mailCredentialId) { // Use Graph API for Microsoft accounts try { const unreadCount = await getGraphUnreadCount(graphCheck.mailCredentialId, 'Inbox'); unreadCounts[accountId]['INBOX'] = unreadCount; logger.debug('[EMAIL_ADAPTER] Unread count (Graph API)', { userId, accountId, folder: 'INBOX', unread: unreadCount, }); } catch (graphError) { logger.error('[EMAIL_ADAPTER] Error getting unread count via Graph API', { userId, accountId, error: graphError instanceof Error ? graphError.message : String(graphError), }); } } else { // Use IMAP for non-Microsoft accounts const client = await getImapConnection(userId, accountId); // Standard folders to check (focus on INBOX for notifications) const standardFolders = ['INBOX']; // 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; logger.debug('[EMAIL_ADAPTER] Unread count (IMAP)', { userId, accountId, folder, unread: status.unseen, }); } } catch (folderError) { logger.error('[EMAIL_ADAPTER] Error getting unread count for folder', { userId, accountId, folder, error: folderError instanceof Error ? folderError.message : String(folderError), }); // Continue to next folder even if this one fails } } } } catch (accountError) { logger.error('[EMAIL_ADAPTER] Error processing account', { userId, accountId, error: accountError instanceof Error ? accountError.message : String(accountError), }); } } return unreadCounts; } /** * Get user's email accounts and calculate total unread count */ async getNotificationCount(userId: string): Promise { logger.debug('[EMAIL_ADAPTER] getNotificationCount called', { userId }); try { // Try to get from cache first (same cache as unread-counts API) const redis = getRedisClient(); const cacheKey = EmailAdapter.UNREAD_COUNTS_CACHE_KEY(userId); let unreadCounts: Record> | null = null; try { const cachedData = await redis.get(cacheKey); if (cachedData) { unreadCounts = JSON.parse(cachedData); logger.debug('[EMAIL_ADAPTER] Using cached unread counts', { userId }); } } catch (error) { logger.debug('[EMAIL_ADAPTER] Cache miss or error', { userId, error: error instanceof Error ? error.message : String(error), }); } // If no cache, fetch directly (but don't cache here - let the API route handle caching) if (!unreadCounts) { try { // Fetch unread counts directly unreadCounts = await this.fetchUnreadCounts(userId); logger.debug('[EMAIL_ADAPTER] Fetched unread counts directly', { userId }); } catch (error) { logger.error('[EMAIL_ADAPTER] Error fetching unread counts', { userId, error: error instanceof Error ? error.message : String(error), }); return { total: 0, unread: 0, sources: { email: { total: 0, unread: 0 } } }; } } // Calculate total unread count across all accounts and folders // Focus on INBOX for notifications let totalUnread = 0; let foldersWithUnread = 0; for (const accountId in unreadCounts) { const accountFolders = unreadCounts[accountId]; // Focus on INBOX folder for notifications const inboxCount = accountFolders['INBOX'] || accountFolders[`${accountId}:INBOX`] || 0; if (inboxCount > 0) { totalUnread += inboxCount; foldersWithUnread++; } } logger.debug('[EMAIL_ADAPTER] Notification counts', { userId, total: foldersWithUnread, unread: totalUnread, }); return { total: foldersWithUnread, unread: totalUnread, sources: { email: { total: foldersWithUnread, unread: totalUnread } } }; } catch (error) { logger.error('[EMAIL_ADAPTER] Error getting notification count', { userId, error: error instanceof Error ? error.message : String(error), }); return { total: 0, unread: 0, sources: { email: { total: 0, unread: 0 } } }; } } async getNotifications(userId: string, page = 1, limit = 20): Promise { logger.debug('[EMAIL_ADAPTER] getNotifications called', { userId, page, limit }); try { // Get all accounts from the database const accounts = await getUserEmailAccounts(userId); if (accounts.length === 0) { return []; } const notifications: Notification[] = []; // For each account, get unread emails from INBOX // Use the same flow as getEmails() but filter for unread only for (const account of accounts) { try { // Check if this is a Microsoft account that should use Graph API const graphCheck = await shouldUseGraphAPI(userId, account.id); if (graphCheck.useGraph && graphCheck.mailCredentialId) { // Use Graph API for Microsoft accounts try { const graphResult = await fetchGraphEmails( graphCheck.mailCredentialId, 'Inbox', limit * 3, // Get more than limit to have enough after filtering 0, 'isRead eq false' // Filter for unread only ); // Convert Graph messages to notifications for (const graphMessage of graphResult.value) { if (graphMessage.isRead) continue; // Double-check unread status notifications.push({ id: `email-${graphMessage.id}`, source: 'email', title: graphMessage.subject || '(No subject)', message: graphMessage.bodyPreview || '', timestamp: new Date(graphMessage.receivedDateTime), read: false, link: `/courrier/${account.id}?email=${graphMessage.id}`, metadata: { accountId: account.id, accountEmail: account.email, emailId: graphMessage.id, folder: 'INBOX', }, }); if (notifications.length >= limit) { break; } } } catch (graphError) { logger.error('[EMAIL_ADAPTER] Error fetching notifications via Graph API', { userId, accountId: account.id, error: graphError instanceof Error ? graphError.message : String(graphError), }); } continue; // Skip IMAP processing for Microsoft accounts } // Use IMAP for non-Microsoft accounts const client = await getImapConnection(userId, account.id); // Use the same approach as getEmails() - open mailbox first const mailboxInfo = await client.mailboxOpen('INBOX'); if (!mailboxInfo || typeof mailboxInfo === 'boolean') { logger.debug('[EMAIL_ADAPTER] Could not open INBOX', { userId, accountId: account.id, }); continue; } const totalEmails = mailboxInfo.exists || 0; if (totalEmails === 0) { continue; } // Search for unread emails (same as getNotificationCount logic) const searchResult = await client.search({ seen: false }); if (!searchResult || searchResult.length === 0) { continue; } // Limit the number of results for performance (get more than limit to have enough after filtering) const limitedResults = searchResult.slice(0, limit * 3); // Fetch email metadata using the same structure as getEmails() const messages = await client.fetch(limitedResults, { envelope: true, flags: true, uid: true }); // Convert to notifications (same format as getEmails() processes emails) for await (const message of messages) { // Filter: only process if truly unread (double-check flags) if (message.flags && message.flags.has('\\Seen')) { continue; } const envelope = message.envelope; const from = envelope.from?.[0]; const subject = envelope.subject || '(Sans objet)'; const fromName = from?.name || from?.address || 'Expéditeur inconnu'; const fromAddress = from?.address || ''; const notification: Notification = { id: `email-${account.id}-${message.uid}`, source: 'email', sourceId: message.uid.toString(), type: 'email', title: 'Email', message: `${fromName}${fromAddress ? ` <${fromAddress}>` : ''}: ${subject}`, link: `/courrier?accountId=${account.id}&folder=INBOX&emailId=${message.uid}`, isRead: false, timestamp: envelope.date || new Date(), priority: 'normal', user: { id: fromAddress, name: fromName, }, metadata: { accountId: account.id, accountEmail: account.email, folder: 'INBOX', emailId: message.uid.toString(), } }; notifications.push(notification); } // Close mailbox (same as getEmails() does implicitly via connection pool) try { await client.mailboxClose(); } catch (closeError) { // Non-fatal, connection pool will handle it logger.debug('[EMAIL_ADAPTER] Error closing mailbox (non-fatal)', { error: closeError instanceof Error ? closeError.message : String(closeError), }); } } catch (accountError) { logger.error('[EMAIL_ADAPTER] Error processing account for notifications', { userId, accountId: account.id, error: accountError instanceof Error ? accountError.message : String(accountError), }); continue; } } // Sort by timestamp (newest first) and apply pagination notifications.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const paginatedNotifications = notifications.slice(startIndex, endIndex); logger.debug('[EMAIL_ADAPTER] getNotifications result', { total: notifications.length, returned: paginatedNotifications.length, page, limit, }); return paginatedNotifications; } catch (error) { logger.error('[EMAIL_ADAPTER] Error getting notifications', { error: error instanceof Error ? error.message : String(error), }); return []; } } }