Agenda Sync refactor Mig Graph

This commit is contained in:
alma 2026-01-14 16:09:44 +01:00
parent 5ecc7c9500
commit 901b8cd7af
4 changed files with 589 additions and 7 deletions

View File

@ -27,7 +27,8 @@ export async function getInfomaniakCalDAVClient(
password: string
): Promise<WebDAVClient> {
// 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, {
username: email,

View File

@ -21,6 +21,12 @@ import { EmailCredentials, EmailMessage, EmailAddress, EmailAttachment } from '@
import { ensureFreshToken } from './token-refresh';
import { createXOAuth2Token, refreshAccessToken as refreshMicrosoftAccessToken } from './microsoft-oauth';
import { MailCredentials } from '@prisma/client';
import {
fetchGraphEmails as fetchGraphEmailsAPI,
fetchGraphMailFolders,
sendGraphEmail as sendGraphEmailAPI,
GraphMailMessage,
} from './microsoft-graph-mail';
import Redis from 'ioredis';
import { getRedisClient } from '../redis';
import { logger } from '@/lib/logger';
@ -779,8 +785,123 @@ interface FetchOptions {
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
* Uses Graph API for Microsoft accounts, IMAP for others
*/
export async function getEmails(
userId: string,
@ -801,6 +922,79 @@ export async function getEmails(
});
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
const client = await getImapConnection(userId, accountId);
@ -1387,9 +1581,52 @@ export async function sendEmail(
content: string;
type: string;
}>;
}
},
accountId?: 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) {
return {

View 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;
}
}

View File

@ -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.
// 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 = [
'offline_access',
'https://outlook.office.com/IMAP.AccessAsUser.All',
'https://outlook.office.com/SMTP.Send',
'https://graph.microsoft.com/Calendars.Read', // Microsoft Graph API scope for calendar read access
'https://graph.microsoft.com/Mail.Read', // Read mail via Graph API
'https://graph.microsoft.com/Mail.Send', // Send mail via Graph API
'https://graph.microsoft.com/Calendars.Read', // Read calendars via Graph API
].join(' ');
/**