NeahStable/lib/services/microsoft-graph-mail.ts
2026-01-16 23:13:10 +01:00

388 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';
};
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
*/
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;
}
}