380 lines
11 KiB
TypeScript
380 lines
11 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';
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export async function fetchGraphEmail(
|
|
mailCredentialId: string,
|
|
messageId: string
|
|
): Promise<GraphMailMessage> {
|
|
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', {
|
|
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;
|
|
}
|
|
}
|