Checkpoint: OAuth2 IMAP pour Office 365 : ajout du mode d'authentification OAuth2 pour l'import email (Azure AD Client Credentials), sélecteur basic/oauth2 dans l'UI, bouton "Tester la connexion IMAP", procédure testEmailConnection dans le router. Migration DB emailImportAuthMode appliquée.

This commit is contained in:
Manus
2026-06-05 05:54:21 -04:00
parent 22515e5414
commit 4ef130f21d
9 changed files with 2256 additions and 17 deletions

View File

@@ -12,6 +12,7 @@ import {
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { sendImportNotification } from "./notificationService";
import { getOffice365ImapToken, buildXOAuth2String } from "./office365OAuth";
interface EmailImportConfig {
userId: number;
@@ -20,6 +21,11 @@ interface EmailImportConfig {
host: string;
port: number;
sinceDate?: number; // Unix timestamp (s) — ne pas lire les emails antérieurs à cette date
authMode?: "basic" | "oauth2"; // Mode d'authentification IMAP
// Credentials Azure AD pour OAuth2
azureTenantId?: string;
azureClientId?: string;
azureClientSecret?: string;
}
// Store active intervals for each user
@@ -216,25 +222,66 @@ async function processEmailAttachment(
}
/**
* Connect to IMAP and process unread emails with PDF attachments
* Construit la configuration IMAP en fonction du mode d'authentification.
* - basic : login/password classique
* - oauth2 : obtient un token Azure AD et utilise XOAUTH2
*/
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
return new Promise((resolve, reject) => {
const imap = new Imap({
async function buildImapConfig(config: EmailImportConfig): Promise<Imap.Config> {
if (config.authMode === "oauth2") {
if (!config.azureTenantId || !config.azureClientId || !config.azureClientSecret) {
throw new Error(
"OAuth2 IMAP : credentials Azure AD incomplets (Tenant ID, Client ID, Client Secret requis)"
);
}
console.log(`[EmailImport] Obtaining OAuth2 token for ${config.emailAddress}...`);
const accessToken = await getOffice365ImapToken(
config.azureTenantId,
config.azureClientId,
config.azureClientSecret
);
const xoauth2 = buildXOAuth2String(config.emailAddress, accessToken);
console.log(`[EmailImport] OAuth2 token obtained successfully`);
return {
user: config.emailAddress,
password: config.password,
xoauth2,
host: config.host,
port: config.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
});
authTimeout: 30000,
} as any;
}
// Basic auth (par défaut)
return {
user: config.emailAddress,
password: config.password,
host: config.host,
port: config.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
};
}
/**
* Connect to IMAP and process unread emails with PDF attachments
*/
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
// Build IMAP config (may involve async OAuth2 token fetch)
const imapConfig = await buildImapConfig(config);
return new Promise((resolve, reject) => {
const imap = new Imap(imapConfig);
function openInbox(cb: (err: Error | null, box?: any) => void) {
imap.openBox("INBOX", false, cb);
}
imap.once("ready", () => {
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId}`);
console.log(`[EmailImport] Connected to IMAP server for user ${config.userId} (mode: ${config.authMode || "basic"})`);
openInbox((err, box) => {
if (err) {
@@ -294,7 +341,6 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
);
if (pdfAttachments.length === 0) {
console.log(`[EmailImport] Email ${seqno} has no PDF attachments, skipping`);
return;
}
@@ -380,6 +426,65 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
});
}
/**
* Test IMAP connection for a user (without processing emails)
* Returns detailed error message if connection fails
*/
export async function testImapConnection(config: EmailImportConfig): Promise<{ success: boolean; message: string }> {
try {
const imapConfig = await buildImapConfig(config);
return new Promise((resolve) => {
const imap = new Imap(imapConfig);
let resolved = false;
const done = (result: { success: boolean; message: string }) => {
if (!resolved) {
resolved = true;
try { imap.destroy(); } catch {}
resolve(result);
}
};
imap.once("ready", () => {
console.log(`[EmailImport] Test connection successful for ${config.emailAddress}`);
done({ success: true, message: `Connexion IMAP réussie pour ${config.emailAddress}` });
});
imap.once("error", (err: any) => {
console.error(`[EmailImport] Test connection failed:`, err);
let message = `Erreur de connexion IMAP : ${err.message || err}`;
// Messages d'erreur plus clairs
if (err.message?.includes("Invalid credentials") || err.message?.includes("AUTHENTICATE")) {
if (config.authMode === "oauth2") {
message = "Authentification OAuth2 refusée. Vérifiez que l'application Azure AD a bien la permission IMAP.AccessAsApp et que le consentement admin a été accordé.";
} else {
message = "Identifiants invalides. Pour Office 365, l'authentification basique est désactivée. Activez le mode OAuth2 et configurez les credentials Azure AD.";
}
} else if (err.message?.includes("ECONNREFUSED") || err.message?.includes("ENOTFOUND")) {
message = `Impossible de se connecter au serveur ${config.host}:${config.port}. Vérifiez l'adresse et le port IMAP.`;
} else if (err.message?.includes("certificate") || err.message?.includes("SSL")) {
message = `Erreur SSL/TLS lors de la connexion à ${config.host}. Vérifiez le port (993 pour SSL).`;
} else if (err.message?.includes("timeout") || err.message?.includes("Timeout")) {
message = `Timeout de connexion à ${config.host}:${config.port}. Vérifiez l'adresse du serveur IMAP.`;
}
done({ success: false, message });
});
// Timeout de sécurité
setTimeout(() => {
done({ success: false, message: `Timeout : impossible de se connecter à ${config.host}:${config.port} dans les 15 secondes.` });
}, 15000);
imap.connect();
});
} catch (error: any) {
return { success: false, message: `Erreur : ${error.message || error}` };
}
}
/**
* Start email import service for a user
*/
@@ -393,27 +498,43 @@ export async function startEmailImportService(userId: number): Promise<boolean>
return false;
}
if (!settings.emailImportAddress || !settings.emailImportPassword || !settings.emailImportHost) {
if (!settings.emailImportAddress || !settings.emailImportHost) {
console.log(`[EmailImport] Email import configuration incomplete for user ${userId}`);
return false;
}
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
// Vérification selon le mode d'auth
if (authMode === "basic" && !settings.emailImportPassword) {
console.log(`[EmailImport] Email import password missing for user ${userId}`);
return false;
}
if (authMode === "oauth2" && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) {
console.log(`[EmailImport] OAuth2 credentials incomplete for user ${userId}`);
return false;
}
// Stop existing service if running
stopEmailImportService(userId);
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
password: settings.emailImportPassword,
password: settings.emailImportPassword || "",
host: settings.emailImportHost,
port: settings.emailImportPort || 993,
sinceDate: settings.emailImportSinceDate ?? undefined,
authMode,
azureTenantId: settings.azureTenantId || undefined,
azureClientId: settings.azureClientId || undefined,
azureClientSecret: settings.azureClientSecret || undefined,
};
const frequencyMs = (settings.emailImportFrequency || 30) * 60 * 1000; // Convert minutes to milliseconds
console.log(
`[EmailImport] Starting email import service for user ${userId} with frequency ${settings.emailImportFrequency} minutes`
`[EmailImport] Starting email import service for user ${userId} with frequency ${settings.emailImportFrequency} minutes (auth: ${authMode})`
);
// Run immediately on start
@@ -466,26 +587,32 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
const settings = await getImportSettingsByUser(userId);
if (!settings || settings.emailImportEnabled !== 1) {
return { success: false, message: "Import par email non activ\u00e9" };
return { success: false, message: "Import par email non activé" };
}
if (!settings.emailImportAddress || !settings.emailImportPassword || !settings.emailImportHost) {
return { success: false, message: "Configuration IMAP incompl\u00e8te" };
if (!settings.emailImportAddress || !settings.emailImportHost) {
return { success: false, message: "Configuration IMAP incomplète" };
}
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
password: settings.emailImportPassword,
password: settings.emailImportPassword || "",
host: settings.emailImportHost,
port: settings.emailImportPort || 993,
sinceDate: settings.emailImportSinceDate ?? undefined,
authMode,
azureTenantId: settings.azureTenantId || undefined,
azureClientId: settings.azureClientId || undefined,
azureClientSecret: settings.azureClientSecret || undefined,
};
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
await checkEmailsForPDFs(config);
return { success: true, message: "V\u00e9rification termin\u00e9e avec succ\u00e8s" };
return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) {
console.error(`[EmailImport] Error during manual check for user ${userId}:`, error);
return { success: false, message: `Erreur: ${error.message}` };

152
server/office365OAuth.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Office 365 OAuth2 IMAP Authentication Helper
*
* Microsoft a désactivé l'authentification basique (Basic Auth) pour Exchange Online
* depuis octobre 2022. Ce module implémente l'authentification OAuth2 via les
* credentials Azure AD (Client Credentials Flow) pour obtenir un token d'accès
* permettant de s'authentifier en IMAP via le mécanisme XOAUTH2.
*
* Prérequis côté Azure AD :
* - Application enregistrée avec les permissions : IMAP.AccessAsUser.All (ou IMAP.AccessAsApp)
* - Ou utiliser le flow "client_credentials" avec IMAP.AccessAsApp
*
* Référence : https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
*/
interface OAuth2TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope?: string;
error?: string;
error_description?: string;
}
/**
* Obtient un token OAuth2 pour IMAP via le flow Client Credentials
* (utilisé pour les applications de service sans interaction utilisateur)
*
* @param tenantId - Azure AD Tenant ID
* @param clientId - Azure AD Application (Client) ID
* @param clientSecret - Azure AD Client Secret
* @returns Access token string
*/
export async function getOffice365ImapToken(
tenantId: string,
clientId: string,
clientSecret: string
): Promise<string> {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: "https://outlook.office365.com/.default",
grant_type: "client_credentials",
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
const data = (await response.json()) as OAuth2TokenResponse;
if (!response.ok || data.error) {
throw new Error(
`OAuth2 token error: ${data.error || response.status} - ${
data.error_description || response.statusText
}`
);
}
if (!data.access_token) {
throw new Error("OAuth2: no access_token in response");
}
return data.access_token;
}
/**
* Génère la chaîne XOAUTH2 encodée en base64 pour l'authentification IMAP
* Format : base64("user=" + email + "\x01auth=Bearer " + token + "\x01\x01")
*
* @param email - Adresse email de la boîte à surveiller
* @param accessToken - Token OAuth2 obtenu via getOffice365ImapToken
* @returns Chaîne XOAUTH2 encodée en base64
*/
export function buildXOAuth2String(email: string, accessToken: string): string {
const xoauth2 = `user=${email}\x01auth=Bearer ${accessToken}\x01\x01`;
return Buffer.from(xoauth2).toString("base64");
}
/**
* Teste la connexion IMAP OAuth2 en obtenant un token et en vérifiant
* que les credentials Azure AD sont valides.
*
* @param tenantId - Azure AD Tenant ID
* @param clientId - Azure AD Application (Client) ID
* @param clientSecret - Azure AD Client Secret
* @param emailAddress - Adresse email à tester
* @returns { success: boolean; message: string }
*/
export async function testOffice365ImapOAuth2(
tenantId: string,
clientId: string,
clientSecret: string,
emailAddress: string
): Promise<{ success: boolean; message: string; token?: string }> {
try {
if (!tenantId || !clientId || !clientSecret) {
return {
success: false,
message:
"Credentials Azure AD incomplets (Tenant ID, Client ID ou Client Secret manquant)",
};
}
const token = await getOffice365ImapToken(tenantId, clientId, clientSecret);
return {
success: true,
message: `Token OAuth2 obtenu avec succès pour ${emailAddress}. L'authentification IMAP OAuth2 est configurée.`,
token,
};
} catch (error: any) {
const msg = error.message || String(error);
// Messages d'erreur plus clairs pour les cas courants
if (msg.includes("AADSTS700016") || msg.includes("application was not found")) {
return {
success: false,
message: `Client ID invalide ou application non trouvée dans le tenant Azure AD. Vérifiez le Client ID.`,
};
}
if (msg.includes("AADSTS7000215") || msg.includes("Invalid client secret")) {
return {
success: false,
message: `Client Secret invalide ou expiré. Générez un nouveau secret dans Azure AD.`,
};
}
if (msg.includes("AADSTS90002") || msg.includes("Tenant") || msg.includes("tenant")) {
return {
success: false,
message: `Tenant ID invalide. Vérifiez l'ID du tenant Azure AD.`,
};
}
if (msg.includes("AADSTS65001") || msg.includes("consent")) {
return {
success: false,
message: `Permissions non accordées. L'application Azure AD nécessite le consentement admin pour IMAP.AccessAsApp.`,
};
}
return {
success: false,
message: `Erreur OAuth2 : ${msg}`,
};
}
}

View File

@@ -82,7 +82,7 @@ import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtra
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck } from "./emailImportService";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
@@ -1567,6 +1567,7 @@ export const appRouter = router({
emailImportPort: z.number().min(1).max(65535).optional(),
emailImportFrequency: z.number().min(1).optional(),
emailImportSinceDate: z.number().nullable().optional(), // Unix timestamp (s)
emailImportAuthMode: z.enum(["basic", "oauth2"]).optional(),
exportFolder: z.string().nullable().optional(),
exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(),
bapExportMode: z.enum(["browser", "folder", "both"]).optional(),
@@ -1635,6 +1636,37 @@ export const appRouter = router({
}
}),
testEmailConnection: protectedProcedure
.mutation(async ({ ctx }) => {
const settings = await getImportSettingsByUser(ctx.user.id);
if (!settings?.emailImportAddress || !settings?.emailImportHost) {
return { success: false, message: 'Configuration IMAP incomplète (adresse email ou serveur IMAP manquant)' };
}
const authMode = (settings as any).emailImportAuthMode as 'basic' | 'oauth2' || 'basic';
if (authMode === 'basic' && !settings.emailImportPassword) {
return { success: false, message: 'Mot de passe IMAP manquant' };
}
if (authMode === 'oauth2' && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) {
return { success: false, message: 'Credentials Azure AD incomplets pour OAuth2 (Tenant ID, Client ID, Client Secret requis)' };
}
const result = await testImapConnection({
userId: ctx.user.id,
emailAddress: settings.emailImportAddress,
password: settings.emailImportPassword || '',
host: settings.emailImportHost,
port: settings.emailImportPort || 993,
authMode,
azureTenantId: settings.azureTenantId || undefined,
azureClientId: settings.azureClientId || undefined,
azureClientSecret: settings.azureClientSecret || undefined,
});
return result;
}),
testSharePointUpload: protectedProcedure
.mutation(async ({ ctx }) => {
const settings = await getImportSettingsByUser(ctx.user.id);