571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
/**
|
|
* 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<AxiosInstance> {
|
|
// 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<GraphMailMessage> {
|
|
try {
|
|
const client = await getMicrosoftGraphClient(mailCredentialId);
|
|
|
|
// First, try to get the message with attachments included
|
|
// Microsoft Graph API may include attachments in the initial response if we request them
|
|
const response = await client.get(`/me/messages/${messageId}`, {
|
|
params: {
|
|
'$expand': 'attachments',
|
|
'$select': 'id,subject,from,toRecipients,ccRecipients,bccRecipients,body,bodyPreview,receivedDateTime,sentDateTime,isRead,hasAttachments,importance,flag,attachments',
|
|
},
|
|
});
|
|
|
|
const message = response.data;
|
|
|
|
logger.debug('Fetched email from Graph API', {
|
|
messageId,
|
|
hasAttachments: message.hasAttachments,
|
|
attachmentsInResponse: !!message.attachments,
|
|
attachmentsCount: message.attachments?.length || 0,
|
|
attachmentsType: typeof message.attachments,
|
|
attachmentsIsArray: Array.isArray(message.attachments),
|
|
attachmentsKeys: message.attachments ? Object.keys(message.attachments) : [],
|
|
mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12),
|
|
});
|
|
|
|
// Handle case where $expand returns attachments in a different structure
|
|
// Sometimes attachments might be in message.attachments.value instead of message.attachments
|
|
if (message.attachments && !Array.isArray(message.attachments) && message.attachments.value) {
|
|
logger.debug('Attachments found in .value property', {
|
|
messageId,
|
|
count: message.attachments.value?.length || 0,
|
|
});
|
|
message.attachments = message.attachments.value;
|
|
}
|
|
|
|
// Process attachments - either from initial response or fetch separately
|
|
if (message.hasAttachments) {
|
|
// If attachments are already in the response, process them
|
|
if (message.attachments && Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
logger.debug('Processing attachments from initial response', {
|
|
messageId,
|
|
attachmentsCount: message.attachments.length,
|
|
});
|
|
|
|
// Process attachments - fetch content if not included
|
|
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;
|
|
} else {
|
|
// Attachments not in initial response, fetch them separately
|
|
try {
|
|
logger.debug('Fetching attachments list from Graph API', {
|
|
messageId,
|
|
mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12),
|
|
});
|
|
|
|
// Fetch attachments separately - don't use $select as it may cause issues
|
|
// Microsoft Graph API will return all attachment properties by default
|
|
const attachmentsResponse = await client.get(`/me/messages/${messageId}/attachments`);
|
|
|
|
const attachments = attachmentsResponse.data.value || [];
|
|
|
|
logger.debug('Fetched attachments from Graph API', {
|
|
messageId,
|
|
attachmentsCount: attachments.length,
|
|
mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12),
|
|
});
|
|
|
|
// Process attachments - fetch content if not included
|
|
if (attachments.length > 0) {
|
|
const attachmentsWithContent = await Promise.all(
|
|
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;
|
|
} else {
|
|
logger.warn('Email has hasAttachments=true but no attachments returned', {
|
|
messageId,
|
|
mailCredentialIdHash: Buffer.from(mailCredentialId).toString('base64').slice(0, 12),
|
|
});
|
|
message.attachments = [];
|
|
}
|
|
} catch (attachmentsError: any) {
|
|
logger.error('Error fetching attachments from Graph API', {
|
|
messageId,
|
|
error: attachmentsError instanceof Error ? attachmentsError.message : String(attachmentsError),
|
|
status: attachmentsError.response?.status,
|
|
statusText: attachmentsError.response?.statusText,
|
|
responseData: attachmentsError.response?.data,
|
|
url: `/me/messages/${messageId}/attachments`,
|
|
});
|
|
// Continue without attachments if fetch fails
|
|
message.attachments = [];
|
|
}
|
|
}
|
|
} else {
|
|
message.attachments = [];
|
|
}
|
|
|
|
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<GraphMailFolder[]> {
|
|
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<void> {
|
|
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<number> {
|
|
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;
|
|
}
|
|
}
|