From a6b63d723a43d2dd16bde648f6830b415e437b54 Mon Sep 17 00:00:00 2001 From: alma Date: Wed, 14 Jan 2026 16:15:23 +0100 Subject: [PATCH] Agenda Sync refactor Mig Graph --- lib/services/email-service.ts | 6 +- lib/services/notifications/email-adapter.ts | 167 ++++++++++++++++---- 2 files changed, 138 insertions(+), 35 deletions(-) diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index f15b384..774f251 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -989,12 +989,12 @@ export async function getEmails( folder, error: error instanceof Error ? error.message : String(error), }); - // Fall back to IMAP if Graph API fails - logger.debug('[EMAIL] Falling back to IMAP after Graph API error'); + // Don't fall back to IMAP for Microsoft accounts - the token doesn't have IMAP permissions + throw error; } } - // Use IMAP for non-Microsoft accounts or as fallback + // Use IMAP for non-Microsoft accounts only // The getImapConnection function already handles 'default' accountId by finding the first available account const client = await getImapConnection(userId, accountId); diff --git a/lib/services/notifications/email-adapter.ts b/lib/services/notifications/email-adapter.ts index e9e8ec2..7ec21c1 100644 --- a/lib/services/notifications/email-adapter.ts +++ b/lib/services/notifications/email-adapter.ts @@ -4,6 +4,8 @@ import { Notification, NotificationCount } from '@/lib/types/notification'; import { getRedisClient } from '@/lib/redis'; import { prisma } from '@/lib/prisma'; import { getImapConnection } from '@/lib/services/email-service'; +import { getGraphUnreadCount, fetchGraphEmails } from '@/lib/services/microsoft-graph-mail'; +import { prisma } from '@/lib/prisma'; export class EmailAdapter implements NotificationAdapter { readonly sourceName = 'email'; @@ -44,53 +46,92 @@ export class EmailAdapter implements NotificationAdapter { for (const account of accounts) { const accountId = account.id; try { - // Get IMAP connection for this account logger.debug('[EMAIL_ADAPTER] Processing account', { userId, accountId, email: account.email, }); - const client = await getImapConnection(userId, accountId); + + // Check if this is a Microsoft account that should use Graph API + const mailCredential = await prisma.mailCredentials.findUnique({ + where: { id: accountId }, + select: { + id: true, + host: true, + use_oauth: true, + refresh_token: true, + }, + }); + + const isMicrosoftAccount = mailCredential && + mailCredential.host === 'outlook.office365.com' && + mailCredential.use_oauth && + mailCredential.refresh_token; + unreadCounts[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; - } - + if (isMicrosoftAccount) { + // Use Graph API for Microsoft accounts try { - // Check folder status without opening it (more efficient) - const status = await client.status(folder, { unseen: true }); + const unreadCount = await getGraphUnreadCount(mailCredential.id, 'Inbox'); + unreadCounts[accountId]['INBOX'] = unreadCount; - if (status && typeof status.unseen === 'number') { - // Store the unread count - unreadCounts[accountId][folder] = status.unseen; + 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 }); - logger.debug('[EMAIL_ADAPTER] Unread count', { + 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, - unread: status.unseen, + error: folderError instanceof Error ? folderError.message : String(folderError), }); + // Continue to next folder even if this one fails } - } 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) { @@ -227,6 +268,68 @@ export class EmailAdapter implements NotificationAdapter { // 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 mailCredential = await prisma.mailCredentials.findUnique({ + where: { id: account.id }, + select: { + id: true, + host: true, + use_oauth: true, + refresh_token: true, + }, + }); + + const isMicrosoftAccount = mailCredential && + mailCredential.host === 'outlook.office365.com' && + mailCredential.use_oauth && + mailCredential.refresh_token; + + if (isMicrosoftAccount) { + // Use Graph API for Microsoft accounts + try { + const graphResult = await fetchGraphEmails( + mailCredential.id, + '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