NeahStable/lib/services/notifications/email-adapter.ts

411 lines
14 KiB
TypeScript

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<boolean> {
// 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<Record<string, Record<string, number>>> {
// 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<string, Record<string, number>> = {};
// 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<NotificationCount> {
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<string, Record<string, number>> | 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<Notification[]> {
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 [];
}
}
}