diff --git a/lib/services/caldav-sync.ts b/lib/services/caldav-sync.ts index 01ad4db..cc1c86e 100644 --- a/lib/services/caldav-sync.ts +++ b/lib/services/caldav-sync.ts @@ -27,7 +27,8 @@ export async function getInfomaniakCalDAVClient( password: string ): Promise { // Infomaniak CalDAV base URL (from Infomaniak sync assistant) - const baseUrl = 'https://sync.infomaniak.com'; + // The actual CalDAV endpoint is at /caldav path + const baseUrl = 'https://sync.infomaniak.com/caldav'; const client = createClient(baseUrl, { username: email, diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts index f2c2108..f15b384 100644 --- a/lib/services/email-service.ts +++ b/lib/services/email-service.ts @@ -21,6 +21,12 @@ import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@ import { ensureFreshToken } from './token-refresh'; import { createXOAuth2Token, refreshAccessToken as refreshMicrosoftAccessToken } from './microsoft-oauth'; import { MailCredentials } from '@prisma/client'; +import { + fetchGraphEmails as fetchGraphEmailsAPI, + fetchGraphMailFolders, + sendGraphEmail as sendGraphEmailAPI, + GraphMailMessage, +} from './microsoft-graph-mail'; import Redis from 'ioredis'; import { getRedisClient } from '../redis'; import { logger } from '@/lib/logger'; @@ -779,8 +785,123 @@ interface FetchOptions { bodyParts: { part: string; query: any; limit?: number }[]; } +/** + * Check if an account should use Microsoft Graph API instead of IMAP + */ +async function shouldUseGraphAPI(userId: string, accountId?: string): Promise<{ useGraph: boolean; mailCredentialId?: string }> { + try { + // Resolve accountId if it's 'default' + let resolvedAccountId = accountId; + if (!resolvedAccountId || resolvedAccountId === 'default') { + const accounts = await prisma.mailCredentials.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + take: 1 + }); + if (accounts && accounts.length > 0) { + resolvedAccountId = accounts[0].id; + } + } + + if (!resolvedAccountId) { + return { useGraph: false }; + } + + // Check if this is a Microsoft account with OAuth + const mailCredential = await prisma.mailCredentials.findUnique({ + where: { id: resolvedAccountId }, + select: { + id: true, + host: true, + use_oauth: true, + refresh_token: true, + }, + }); + + if (mailCredential && + mailCredential.host === 'outlook.office365.com' && + mailCredential.use_oauth && + mailCredential.refresh_token) { + return { useGraph: true, mailCredentialId: mailCredential.id }; + } + + return { useGraph: false }; + } catch (error) { + logger.error('[EMAIL] Error checking if should use Graph API', { + userId, + accountId, + error: error instanceof Error ? error.message : String(error), + }); + return { useGraph: false }; + } +} + +/** + * Convert Graph API folder name to Graph API folder ID + * Graph API uses folder IDs, but we can map common names + */ +function mapFolderNameToGraphId(folderName: string): string { + const folderMap: Record = { + 'INBOX': 'Inbox', + 'Sent': 'SentItems', + 'Drafts': 'Drafts', + 'Trash': 'DeletedItems', + 'Junk': 'JunkEmail', + 'Archive': 'Archive', + }; + + return folderMap[folderName] || folderName; +} + +/** + * Convert Graph API mail message to EmailMessage format + */ +function convertGraphMessageToEmailMessage( + graphMessage: GraphMailMessage, + folder: string, + accountId: string +): EmailMessage { + return { + id: graphMessage.id, + from: graphMessage.from ? [{ + name: graphMessage.from.emailAddress.name || '', + address: graphMessage.from.emailAddress.address, + }] : [], + to: graphMessage.toRecipients?.map(recipient => ({ + name: recipient.emailAddress.name || '', + address: recipient.emailAddress.address, + })) || [], + cc: graphMessage.ccRecipients?.map(recipient => ({ + name: recipient.emailAddress.name || '', + address: recipient.emailAddress.address, + })), + subject: graphMessage.subject || '', + date: new Date(graphMessage.receivedDateTime), + flags: { + seen: graphMessage.isRead, + flagged: graphMessage.flag?.flagStatus === 'flagged' || graphMessage.flag?.flagStatus === 'complete', + answered: false, // Graph API doesn't have this flag directly + draft: false, // Would need to check message type + deleted: false, + }, + size: 0, // Graph API doesn't provide size directly in this format + hasAttachments: graphMessage.hasAttachments || false, + folder, + contentFetched: false, + accountId, + content: { + text: graphMessage.body?.contentType === 'text' ? (graphMessage.body.content || '') : '', + html: graphMessage.body?.contentType === 'html' ? (graphMessage.body.content || '') : (graphMessage.bodyPreview || ''), + isHtml: graphMessage.body?.contentType === 'html', + direction: 'ltr', + }, + preview: graphMessage.bodyPreview, + }; +} + /** * Get list of emails for a user + * Uses Graph API for Microsoft accounts, IMAP for others */ export async function getEmails( userId: string, @@ -801,6 +922,79 @@ export async function getEmails( }); try { + // Check if we should use Graph API for Microsoft accounts + const graphCheck = await shouldUseGraphAPI(userId, accountId); + + if (graphCheck.useGraph && graphCheck.mailCredentialId) { + // Use Microsoft Graph API + logger.debug('[EMAIL] Using Microsoft Graph API', { + userId, + folder, + mailCredentialId: graphCheck.mailCredentialId, + }); + + try { + const graphFolderId = mapFolderNameToGraphId(folder); + const skip = (page - 1) * perPage; + + // Fetch emails from Graph API + const graphResult = await fetchGraphEmailsAPI( + graphCheck.mailCredentialId, + graphFolderId, + perPage, + skip + ); + + // Get mailboxes (folders) + const graphFolders = await fetchGraphMailFolders(graphCheck.mailCredentialId); + const mailboxes = graphFolders.map(f => f.displayName); + + // Convert Graph messages to EmailMessage format + const emails = graphResult.value.map(msg => + convertGraphMessageToEmailMessage(msg, folder, accountId || 'default') + ); + + // Calculate total (Graph API doesn't provide total count directly, so we estimate) + const totalEmails = graphResult['@odata.nextLink'] + ? (page * perPage) + 1 // Has more pages + : emails.length; + + const result: EmailListResult = { + emails, + totalEmails, + page, + perPage, + totalPages: Math.ceil(totalEmails / perPage), + folder, + mailboxes, + newestEmailId: emails.length > 0 ? parseInt(emails[0].id) || 0 : 0, + }; + + // Cache the result + if (!checkOnly) { + await cacheEmailList( + userId, + accountId || 'default', + folder, + page, + perPage, + result + ); + } + + return result; + } catch (error) { + logger.error('[EMAIL] Error fetching emails from Graph API', { + userId, + 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'); + } + } + + // Use IMAP for non-Microsoft accounts or as fallback // The getImapConnection function already handles 'default' accountId by finding the first available account const client = await getImapConnection(userId, accountId); @@ -1387,9 +1581,52 @@ export async function sendEmail( content: string; type: string; }>; - } + }, + accountId?: string ): Promise<{ success: boolean; messageId?: string; error?: string }> { - const credentials = await getUserEmailCredentials(userId); + // Check if we should use Graph API for Microsoft accounts + const graphCheck = await shouldUseGraphAPI(userId, accountId); + + if (graphCheck.useGraph && graphCheck.mailCredentialId) { + // Use Microsoft Graph API to send email + logger.debug('[EMAIL] Sending email via Microsoft Graph API', { + userId, + mailCredentialId: graphCheck.mailCredentialId, + }); + + try { + const result = await sendGraphEmailAPI(graphCheck.mailCredentialId, { + to: emailData.to, + cc: emailData.cc, + bcc: emailData.bcc, + subject: emailData.subject, + body: emailData.body, + bodyType: 'html', + attachments: emailData.attachments?.map(att => ({ + name: att.name, + content: att.content, // Should be base64 encoded + contentType: att.type, + })), + }); + + return { + success: true, + messageId: result.id, + }; + } catch (error) { + logger.error('[EMAIL] Error sending email via Graph API', { + userId, + error: error instanceof Error ? error.message : String(error), + }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + // Use SMTP for non-Microsoft accounts + const credentials = await getUserEmailCredentials(userId, accountId); if (!credentials) { return { diff --git a/lib/services/microsoft-graph-mail.ts b/lib/services/microsoft-graph-mail.ts new file mode 100644 index 0000000..9987a94 --- /dev/null +++ b/lib/services/microsoft-graph-mail.ts @@ -0,0 +1,343 @@ +/** + * Microsoft Graph API Mail Service + * + * This service replaces IMAP/SMTP for Microsoft accounts by using Graph API. + * It provides email reading, sending, and folder management capabilities. + */ + +import axios, { AxiosInstance } from 'axios'; +import { ensureFreshToken } from './token-refresh'; +import { logger } from '@/lib/logger'; +import { prisma } from '@/lib/prisma'; + +// Graph API base URL +const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0'; + +/** + * Get a configured Axios instance for Microsoft Graph API + */ +async function getMicrosoftGraphClient(mailCredentialId: string): Promise { + // Get fresh access token + const tokenResult = await ensureFreshToken(mailCredentialId); + + if (!tokenResult || !tokenResult.accessToken) { + throw new Error('Failed to obtain valid access token for Microsoft Graph API. The account may need to be re-authenticated with Mail.Read and Mail.Send permissions.'); + } + + return axios.create({ + baseURL: GRAPH_API_BASE, + headers: { + 'Authorization': `Bearer ${tokenResult.accessToken}`, + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Microsoft Graph Mail Message interface + */ +export interface GraphMailMessage { + id: string; + subject?: string; + from?: { + emailAddress: { + name?: string; + address: string; + }; + }; + toRecipients?: Array<{ + emailAddress: { + name?: string; + address: string; + }; + }>; + ccRecipients?: Array<{ + emailAddress: { + name?: string; + address: string; + }; + }>; + body?: { + contentType: 'text' | 'html'; + content: string; + }; + bodyPreview?: string; + receivedDateTime: string; + sentDateTime?: string; + isRead: boolean; + hasAttachments: boolean; + importance?: 'low' | 'normal' | 'high'; + flag?: { + flagStatus: 'notFlagged' | 'flagged' | 'complete'; + }; +} + +/** + * Microsoft Graph Mail Folder interface + */ +export interface GraphMailFolder { + id: string; + displayName: string; + parentFolderId?: string; + childFolderCount: number; + unreadItemCount: number; + totalItemCount: number; +} + +/** + * Fetch emails from a Microsoft mailbox folder using Graph API + */ +export async function fetchGraphEmails( + mailCredentialId: string, + folderId: string = 'Inbox', + top: number = 50, + skip: number = 0, + filter?: string +): Promise<{ + value: GraphMailMessage[]; + '@odata.nextLink'?: string; +}> { + try { + const client = await getMicrosoftGraphClient(mailCredentialId); + + // Build the query URL + let url = `/me/mailFolders/${folderId}/messages`; + const params = new URLSearchParams({ + '$top': top.toString(), + '$skip': skip.toString(), + '$orderby': 'receivedDateTime desc', + '$select': 'id,subject,from,toRecipients,ccRecipients,body,bodyPreview,receivedDateTime,sentDateTime,isRead,hasAttachments,importance,flag', + }); + + if (filter) { + params.append('$filter', filter); + } + + url += `?${params.toString()}`; + + const response = await client.get(url); + + return response.data; + } catch (error: any) { + logger.error('Error fetching emails from Microsoft Graph', { + mailCredentialId, + folderId, + error: error instanceof Error ? error.message : String(error), + status: error.response?.status, + statusText: error.response?.statusText, + }); + + // Check for authentication errors + if (error.response?.status === 401 || error.response?.status === 403) { + throw new Error('Microsoft Graph API access denied - may need to re-authenticate with Mail.Read and Mail.Send permissions'); + } + + throw error; + } +} + +/** + * Fetch a single email by ID from Microsoft Graph + */ +export async function fetchGraphEmail( + mailCredentialId: string, + messageId: string +): Promise { + try { + const client = await getMicrosoftGraphClient(mailCredentialId); + + const response = await client.get(`/me/messages/${messageId}`, { + params: { + '$select': 'id,subject,from,toRecipients,ccRecipients,bccRecipients,body,bodyPreview,receivedDateTime,sentDateTime,isRead,hasAttachments,importance,flag,attachments', + }, + }); + + return response.data; + } catch (error: any) { + logger.error('Error fetching email from Microsoft Graph', { + mailCredentialId, + messageId, + error: error instanceof Error ? error.message : String(error), + }); + + if (error.response?.status === 401 || error.response?.status === 403) { + throw new Error('Microsoft Graph API access denied - may need to re-authenticate with Mail.Read permissions'); + } + + throw error; + } +} + +/** + * Get list of mail folders from Microsoft Graph + */ +export async function fetchGraphMailFolders( + mailCredentialId: string +): Promise { + try { + const client = await getMicrosoftGraphClient(mailCredentialId); + + const response = await client.get('/me/mailFolders', { + params: { + '$select': 'id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount', + '$top': 100, + }, + }); + + return response.data.value || []; + } catch (error: any) { + logger.error('Error fetching mail folders from Microsoft Graph', { + mailCredentialId, + error: error instanceof Error ? error.message : String(error), + }); + + if (error.response?.status === 401 || error.response?.status === 403) { + throw new Error('Microsoft Graph API access denied - may need to re-authenticate with Mail.Read permissions'); + } + + throw error; + } +} + +/** + * Send an email using Microsoft Graph API + */ +export async function sendGraphEmail( + mailCredentialId: string, + emailData: { + to: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; + subject: string; + body: string; + bodyType?: 'text' | 'html'; + attachments?: Array<{ + name: string; + content: string; + contentType: string; + }>; + } +): Promise<{ id: string }> { + try { + const client = await getMicrosoftGraphClient(mailCredentialId); + + // Get the mail credential to get the sender email + const mailCredential = await prisma.mailCredentials.findUnique({ + where: { id: mailCredentialId }, + select: { email: true }, + }); + + if (!mailCredential) { + throw new Error('Mail credential not found'); + } + + // Build the message payload + const message: any = { + subject: emailData.subject, + body: { + contentType: emailData.bodyType || 'html', + content: emailData.body, + }, + toRecipients: Array.isArray(emailData.to) + ? emailData.to.map(email => ({ emailAddress: { address: email } })) + : [{ emailAddress: { address: emailData.to } }], + }; + + if (emailData.cc) { + message.ccRecipients = Array.isArray(emailData.cc) + ? emailData.cc.map(email => ({ emailAddress: { address: email } })) + : [{ emailAddress: { address: emailData.cc } }]; + } + + if (emailData.bcc) { + message.bccRecipients = Array.isArray(emailData.bcc) + ? emailData.bcc.map(email => ({ emailAddress: { address: email } })) + : [{ emailAddress: { address: emailData.bcc } }]; + } + + if (emailData.attachments && emailData.attachments.length > 0) { + message.attachments = emailData.attachments.map(att => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: att.name, + contentType: att.contentType, + contentBytes: att.content, // Should be base64 encoded + })); + } + + // Create a draft message first to get the message ID + const draftResponse = await client.post('/me/messages', message); + const draftId = draftResponse.data.id; + + // Send the draft + await client.post(`/me/messages/${draftId}/send`); + + return { id: draftId }; + } catch (error: any) { + logger.error('Error sending email via Microsoft Graph', { + mailCredentialId, + error: error instanceof Error ? error.message : String(error), + status: error.response?.status, + statusText: error.response?.statusText, + }); + + if (error.response?.status === 401 || error.response?.status === 403) { + throw new Error('Microsoft Graph API access denied - may need to re-authenticate with Mail.Send permissions'); + } + + throw error; + } +} + +/** + * Mark an email as read/unread using Graph API + */ +export async function markGraphEmailAsRead( + mailCredentialId: string, + messageId: string, + isRead: boolean = true +): Promise { + try { + const client = await getMicrosoftGraphClient(mailCredentialId); + + await client.patch(`/me/messages/${messageId}`, { + isRead, + }); + } catch (error: any) { + logger.error('Error marking email as read via Microsoft Graph', { + mailCredentialId, + messageId, + isRead, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } +} + +/** + * Get unread email count for a folder + */ +export async function getGraphUnreadCount( + mailCredentialId: string, + folderId: string = 'Inbox' +): Promise { + try { + const client = await getMicrosoftGraphClient(mailCredentialId); + + const response = await client.get(`/me/mailFolders/${folderId}`, { + params: { + '$select': 'unreadItemCount', + }, + }); + + return response.data.unreadItemCount || 0; + } catch (error: any) { + logger.error('Error getting unread count from Microsoft Graph', { + mailCredentialId, + folderId, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } +} diff --git a/lib/services/microsoft-oauth.ts b/lib/services/microsoft-oauth.ts index ad148f9..172e400 100644 --- a/lib/services/microsoft-oauth.ts +++ b/lib/services/microsoft-oauth.ts @@ -14,12 +14,13 @@ const redirectUri = process.env.MICROSOFT_REDIRECT_URI; // NOTE: In production we do not log Microsoft OAuth configuration to avoid noise and potential leakage. -// Required scopes for IMAP, SMTP, and Calendar access +// Required scopes for Microsoft Graph API (Mail and Calendar) +// All scopes use the same resource (graph.microsoft.com) to avoid multi-resource errors const REQUIRED_SCOPES = [ 'offline_access', - 'https://outlook.office.com/IMAP.AccessAsUser.All', - 'https://outlook.office.com/SMTP.Send', - 'https://graph.microsoft.com/Calendars.Read', // Microsoft Graph API scope for calendar read access + 'https://graph.microsoft.com/Mail.Read', // Read mail via Graph API + 'https://graph.microsoft.com/Mail.Send', // Send mail via Graph API + 'https://graph.microsoft.com/Calendars.Read', // Read calendars via Graph API ].join(' '); /**