Agenda Sync refactor Mig Graph
This commit is contained in:
parent
5ecc7c9500
commit
901b8cd7af
@ -27,7 +27,8 @@ export async function getInfomaniakCalDAVClient(
|
|||||||
password: string
|
password: string
|
||||||
): Promise<WebDAVClient> {
|
): Promise<WebDAVClient> {
|
||||||
// Infomaniak CalDAV base URL (from Infomaniak sync assistant)
|
// 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, {
|
const client = createClient(baseUrl, {
|
||||||
username: email,
|
username: email,
|
||||||
|
|||||||
@ -21,6 +21,12 @@ import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@
|
|||||||
import { ensureFreshToken } from './token-refresh';
|
import { ensureFreshToken } from './token-refresh';
|
||||||
import { createXOAuth2Token, refreshAccessToken as refreshMicrosoftAccessToken } from './microsoft-oauth';
|
import { createXOAuth2Token, refreshAccessToken as refreshMicrosoftAccessToken } from './microsoft-oauth';
|
||||||
import { MailCredentials } from '@prisma/client';
|
import { MailCredentials } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
fetchGraphEmails as fetchGraphEmailsAPI,
|
||||||
|
fetchGraphMailFolders,
|
||||||
|
sendGraphEmail as sendGraphEmailAPI,
|
||||||
|
GraphMailMessage,
|
||||||
|
} from './microsoft-graph-mail';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { getRedisClient } from '../redis';
|
import { getRedisClient } from '../redis';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@ -779,8 +785,123 @@ interface FetchOptions {
|
|||||||
bodyParts: { part: string; query: any; limit?: number }[];
|
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<string, string> = {
|
||||||
|
'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
|
* Get list of emails for a user
|
||||||
|
* Uses Graph API for Microsoft accounts, IMAP for others
|
||||||
*/
|
*/
|
||||||
export async function getEmails(
|
export async function getEmails(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -801,6 +922,79 @@ export async function getEmails(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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
|
// The getImapConnection function already handles 'default' accountId by finding the first available account
|
||||||
const client = await getImapConnection(userId, accountId);
|
const client = await getImapConnection(userId, accountId);
|
||||||
|
|
||||||
@ -1387,9 +1581,52 @@ export async function sendEmail(
|
|||||||
content: string;
|
content: string;
|
||||||
type: string;
|
type: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
},
|
||||||
|
accountId?: string
|
||||||
): Promise<{ success: boolean; messageId?: string; error?: 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) {
|
if (!credentials) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
343
lib/services/microsoft-graph-mail.ts
Normal file
343
lib/services/microsoft-graph-mail.ts
Normal file
@ -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<AxiosInstance> {
|
||||||
|
// 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<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', {
|
||||||
|
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<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', {
|
||||||
|
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<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', {
|
||||||
|
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<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', {
|
||||||
|
mailCredentialId,
|
||||||
|
folderId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
// 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 = [
|
const REQUIRED_SCOPES = [
|
||||||
'offline_access',
|
'offline_access',
|
||||||
'https://outlook.office.com/IMAP.AccessAsUser.All',
|
'https://graph.microsoft.com/Mail.Read', // Read mail via Graph API
|
||||||
'https://outlook.office.com/SMTP.Send',
|
'https://graph.microsoft.com/Mail.Send', // Send mail via Graph API
|
||||||
'https://graph.microsoft.com/Calendars.Read', // Microsoft Graph API scope for calendar read access
|
'https://graph.microsoft.com/Calendars.Read', // Read calendars via Graph API
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user