411 lines
14 KiB
TypeScript
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 [];
|
|
}
|
|
}
|
|
|
|
}
|