/** * 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 { // Look up the mail credential to get userId and email for token refresh const account = await prisma.mailCredentials.findUnique({ where: { id: mailCredentialId }, select: { userId: true, email: true, }, }); if (!account) { throw new Error('Mail credential not found for Microsoft Graph client'); } // Get fresh access token using userId + email const tokenResult = await ensureFreshToken(account.userId, account.email); 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'; }; attachments?: Array<{ id: string; name: string; contentType: string; size: number; contentBytes?: string; // Base64 encoded content isInline?: boolean; }>; } /** * 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 * Note: Microsoft Graph API doesn't support $skip for pagination, only $top and $skipToken */ export async function fetchGraphEmails( mailCredentialId: string, folderId: string = 'Inbox', top: number = 50, skip: number = 0, filter?: string, skipToken?: 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(), '$orderby': 'receivedDateTime desc', '$select': 'id,subject,from,toRecipients,ccRecipients,body,bodyPreview,receivedDateTime,sentDateTime,isRead,hasAttachments,importance,flag', }); // Microsoft Graph API supports $skip for messages endpoint, but it's more reliable to use $skipToken // For the first page (skip=0), don't use $skip // For subsequent pages, we can use $skip but $skipToken is preferred if (skip > 0 && !skipToken) { params.append('$skip', skip.toString()); } // Use skipToken if provided (for server-driven pagination from @odata.nextLink) // This is the preferred method for pagination in Graph API if (skipToken) { params.append('$skiptoken', skipToken); } if (filter) { params.append('$filter', filter); } url += `?${params.toString()}`; logger.debug('Fetching emails from Microsoft Graph API', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), top, skip, skipToken: skipToken ? 'present' : 'none', }); const response = await client.get(url); logger.debug('Microsoft Graph API response', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), emailCount: response.data?.value?.length || 0, hasNextLink: !!response.data?.['@odata.nextLink'], }); return response.data; } catch (error: any) { logger.error('Error fetching emails from Microsoft Graph', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), 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 * Automatically fetches attachment content if not included in initial response */ 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', }, }); const message = response.data; // If email has attachments but they don't have contentBytes, fetch them individually if (message.hasAttachments && message.attachments && Array.isArray(message.attachments)) { const attachmentsWithContent = await Promise.all( message.attachments.map(async (attachment: any) => { // If contentBytes is missing, fetch the attachment content if (!attachment.contentBytes && attachment.id) { try { logger.debug('Fetching attachment content from Graph API', { messageId, attachmentId: attachment.id, attachmentName: attachment.name, }); const attachmentData = await fetchGraphAttachment(mailCredentialId, messageId, attachment.id); return { ...attachment, contentBytes: attachmentData.contentBytes, }; } catch (error) { logger.error('Error fetching attachment content', { messageId, attachmentId: attachment.id, error: error instanceof Error ? error.message : String(error), }); // Return attachment without content if fetch fails return attachment; } } // Already has contentBytes, return as-is return attachment; }) ); message.attachments = attachmentsWithContent; } return message; } catch (error: any) { logger.error('Error fetching email from Microsoft Graph', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), 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', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), 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', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), 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', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), 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', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), error: error instanceof Error ? error.message : String(error), }); throw error; } } /** * Fetch a single attachment by ID from a message using Microsoft Graph API */ export async function fetchGraphAttachment( mailCredentialId: string, messageId: string, attachmentId: string ): Promise<{ id: string; name: string; contentType: string; size: number; contentBytes: string; // Base64 encoded content }> { try { const client = await getMicrosoftGraphClient(mailCredentialId); const response = await client.get(`/me/messages/${messageId}/attachments/${attachmentId}`); return response.data; } catch (error: any) { logger.error('Error fetching attachment from Microsoft Graph', { mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12), messageId, attachmentId, 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; } }