diff --git a/client/src/pages/ImportSettings.tsx b/client/src/pages/ImportSettings.tsx index ae94326..1499df4 100644 --- a/client/src/pages/ImportSettings.tsx +++ b/client/src/pages/ImportSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { trpc } from "@/lib/trpc"; import AzureAdExportSettingsCard from "@/components/AzureAdExportSettingsCard"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useAuth } from "@/_core/hooks/useAuth"; import { toast } from "sonner"; import { Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download, @@ -153,6 +154,8 @@ function BackupSection() { export default function ImportSettings() { + const { user } = useAuth(); + const isAdmin = user?.role === "admin"; // Chargement différé : on ne charge les statuts de services qu'après le montage const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, { staleTime: 30_000, @@ -173,6 +176,18 @@ export default function ImportSettings() { const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation(); const testSharePointUploadMutation = trpc.importSettings.testSharePointUpload.useMutation(); const testEmailConnectionMutation = trpc.importSettings.testEmailConnection.useMutation(); + const { data: emailAccounts, isLoading: emailAccountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, { + enabled: isAdmin, + staleTime: 30_000, + }); + const [selectedEmailAccountId, setSelectedEmailAccountId] = useState(""); + const selectedEmailAccountUserId = Number(selectedEmailAccountId); + const { data: selectedEmailAccountStatus } = trpc.emailImportService.accountStatus.useQuery( + { userId: selectedEmailAccountUserId }, + { enabled: isAdmin && Number.isInteger(selectedEmailAccountUserId) && selectedEmailAccountUserId > 0 }, + ); + const startEmailAccountMutation = trpc.emailImportService.startAccount.useMutation(); + const stopEmailAccountMutation = trpc.emailImportService.stopAccount.useMutation(); // Manual import const [manualImportEnabled, setManualImportEnabled] = useState(true); @@ -257,6 +272,22 @@ export default function ImportSettings() { } }, [settings]); + const configuredEmailAccounts = useMemo( + () => (emailAccounts ?? []).filter((account) => account.isConfigured === 1), + [emailAccounts], + ); + + useEffect(() => { + if (!isAdmin || configuredEmailAccounts.length === 0) { + setSelectedEmailAccountId(""); + return; + } + + if (!configuredEmailAccounts.some((account) => String(account.userId) === selectedEmailAccountId)) { + setSelectedEmailAccountId(String(configuredEmailAccounts[0].userId)); + } + }, [isAdmin, configuredEmailAccounts, selectedEmailAccountId]); + const computedExportMode = (): "browser" | "folder" | "both" => { if (bapExportBrowser && bapExportFolder) return "both"; if (bapExportFolder) return "folder"; @@ -537,8 +568,9 @@ export default function ImportSettings() { /> - {emailImportEnabled && ( -
+
+ {emailImportEnabled && ( + <> {/* Mode d'authentification */}
@@ -568,7 +600,7 @@ export default function ImportSettings() {
{emailImportAuthMode === "oauth2" && (
- Office 365 : Microsoft a désactivé l'auth basique pour Exchange Online. Utilisez ce mode avec les credentials Azure AD déjà configurés dans l'onglet Export. + Office 365 : Microsoft a désactivé l'auth basique pour Exchange Online. Utilisez ce mode avec les paramètres Azure AD configurés dans la carte Microsoft Azure AD au début de cette page.
)}
@@ -600,7 +632,7 @@ export default function ImportSettings() { {emailImportAuthMode === "oauth2" && (
- En mode OAuth2, les credentials Azure AD (Tenant ID, Client ID, Client Secret) configurés dans l'onglet Export > SharePoint seront utilisés automatiquement. + En mode OAuth2, les paramètres Azure AD (Tenant ID, Client ID, Client Secret) configurés dans la carte Microsoft Azure AD seront utilisés automatiquement.
)}
@@ -741,12 +773,12 @@ export default function ImportSettings() { const result = await startEmailServiceMutation.mutateAsync(); if (result.success) { await utils.emailImportService.status.invalidate(); - toast.success("Service démarré"); + toast.success(result.message); } else { - toast.error("Impossible de démarrer le service"); + toast.error(result.message || "Impossible de démarrer le service", { duration: 8000 }); } - } catch { - toast.error("Impossible de démarrer le service"); + } catch (error: any) { + toast.error(error?.message || "Impossible de démarrer le service", { duration: 8000 }); } }} disabled={!emailImportEnabled || startEmailServiceMutation.isPending} @@ -757,8 +789,82 @@ export default function ImportSettings() { )}
+

+ Enregistrez les paramètres avant de démarrer. Le service ne démarre que si le test IMAP est concluant. +

+ + )} + + {isAdmin && ( +
+
+

Compte source administré

+

+ Démarrez la boîte déjà configurée, même si ses paramètres appartiennent à un autre compte applicatif. Aucun identifiant n’est affiché ni recopié. +

+
+
+ + {selectedEmailAccountStatus?.isRunning ? ( + + ) : ( + + )} +
+
+ )}
- )} diff --git a/server/emailImportService.test.ts b/server/emailImportService.test.ts index 2d151b7..6a4dc90 100644 --- a/server/emailImportService.test.ts +++ b/server/emailImportService.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + createEmailImportAccountController, createImapFlowOptions, type EmailImportConfig, + isEmailImportServiceRunning, + startConfiguredEmailImportService, + stopEmailImportService, validateEmailImportConfiguration, } from "./emailImportService"; @@ -76,3 +80,71 @@ describe("validateEmailImportConfiguration", () => { expect(error).toBe("Configuration IMAP incomplète"); }); }); + +describe("startConfiguredEmailImportService", () => { + it("ne crée aucune planification si le test IMAP préalable échoue", async () => { + const userId = 901; + stopEmailImportService(userId); + + const result = await startConfiguredEmailImportService( + { ...baseConfig, userId, authMode: "basic" }, + 5, + async () => ({ + success: false, + message: "Authentification IMAP refusée.", + }), + ); + + expect(result).toEqual({ + success: false, + message: "Authentification IMAP refusée.", + }); + expect(isEmailImportServiceRunning(userId)).toBe(false); + }); + + it("démarre après un test IMAP concluant et exécute le contrôle initial", async () => { + const userId = 902; + const runEmailCheck = vi.fn(async () => undefined); + + try { + const result = await startConfiguredEmailImportService( + { ...baseConfig, userId, authMode: "basic" }, + 5, + async () => ({ success: true, message: "Connexion IMAP réussie." }), + runEmailCheck, + ); + + expect(result).toEqual({ + success: true, + message: "Service de réception e-mail démarré", + }); + expect(isEmailImportServiceRunning(userId)).toBe(true); + expect(runEmailCheck).toHaveBeenCalledWith(expect.objectContaining({ userId })); + } finally { + stopEmailImportService(userId); + } + }); +}); + +describe("createEmailImportAccountController", () => { + it("cible exclusivement le compte sélectionné par un administrateur", async () => { + const start = vi.fn(async () => ({ success: true, message: "Service de réception e-mail démarré" })); + const stop = vi.fn(); + const isRunning = vi.fn(() => true); + const controller = createEmailImportAccountController({ start, stop, isRunning }); + + await expect(controller.startAccount(42)).resolves.toEqual({ + success: true, + message: "Service de réception e-mail démarré", + }); + expect(start).toHaveBeenCalledWith(42); + + expect(controller.stopAccount(42)).toEqual({ + success: true, + message: "Service de réception e-mail arrêté", + }); + expect(stop).toHaveBeenCalledWith(42); + expect(controller.accountStatus(42)).toEqual({ isRunning: true }); + expect(isRunning).toHaveBeenCalledWith(42); + }); +}); diff --git a/server/emailImportService.ts b/server/emailImportService.ts index c6f51da..f218951 100644 --- a/server/emailImportService.ts +++ b/server/emailImportService.ts @@ -34,6 +34,17 @@ export interface EmailImportConfig { export type EmailImportTrigger = "manual" | "automatic"; +export type EmailImportServiceStartResult = { + success: boolean; + message: string; +}; + +type EmailConnectionTester = ( + config: EmailImportConfig, +) => Promise<{ success: boolean; message: string }>; + +type EmailCheckRunner = (config: EmailImportConfig) => Promise; + type EmailImportSettingsSnapshot = { emailImportEnabled: number; emailImportAddress: string | null; @@ -566,21 +577,63 @@ export async function testImapConnection(config: EmailImportConfig): Promise<{ s } /** - * Start email import service for a user + * Vérifie la boîte avant de créer la planification. Une authentification refusée + * ne doit jamais produire une boucle de tentatives automatiques sans fin. */ -export async function startEmailImportService(userId: number): Promise { +export async function startConfiguredEmailImportService( + config: EmailImportConfig, + frequencyMinutes: number, + connectionTester: EmailConnectionTester = testImapConnection, + emailCheckRunner: EmailCheckRunner = runEmailCheckExclusive, +): Promise { + const connection = await connectionTester(config); + if (!connection.success) { + console.error( + `[EmailImport] Service non démarré pour user ${config.userId}: ${connection.message}`, + ); + return connection; + } + + // Stop existing service if running only after the new configuration has passed validation. + stopEmailImportService(config.userId); + + const frequencyMs = Math.max(1, frequencyMinutes || 30) * 60 * 1000; + console.log( + `[EmailImport] Starting email import service for user ${config.userId} with frequency ${frequencyMinutes || 30} minutes (auth: ${config.authMode || "basic"})`, + ); + + // Run immediately on start. The preflight above confirms that the account accepts the credentials. + emailCheckRunner(config).catch((error) => { + console.error(`[EmailImport] Error checking emails for user ${config.userId}:`, error); + }); + + const interval = setInterval(() => { + emailCheckRunner(config).catch((error) => { + console.error(`[EmailImport] Error checking emails for user ${config.userId}:`, error); + }); + }, frequencyMs); + + activeIntervals.set(config.userId, interval); + console.log(`[EmailImport] Email import service started for user ${config.userId}`); + return { success: true, message: "Service de réception e-mail démarré" }; +} + +/** + * Start email import service for a user. + */ +export async function startEmailImportService(userId: number): Promise { try { // Get user's import settings const settings = await getImportSettingsByUser(userId); if (!settings || settings.emailImportEnabled !== 1) { console.log(`[EmailImport] Email import not enabled for user ${userId}`); - return false; + return { success: false, message: "Activez et enregistrez la réception automatique avant de démarrer le service." }; } if (!settings.emailImportAddress || !settings.emailImportHost) { console.log(`[EmailImport] Email import configuration incomplete for user ${userId}`); - return false; + return { success: false, message: "Configuration IMAP incomplète : adresse e-mail ou serveur IMAP manquant." }; } const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic"; @@ -588,16 +641,13 @@ export async function startEmailImportService(userId: number): Promise // Vérification selon le mode d'auth if (authMode === "basic" && !settings.emailImportPassword) { console.log(`[EmailImport] Email import password missing for user ${userId}`); - return false; + return { success: false, message: "Mot de passe IMAP manquant." }; } if (authMode === "oauth2" && (!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)) { console.log(`[EmailImport] OAuth2 credentials incomplete for user ${userId}`); - return false; + return { success: false, message: "Configuration OAuth2 IMAP incomplète : renseignez les paramètres Microsoft Azure AD." }; } - // Stop existing service if running - stopEmailImportService(userId); - const config: EmailImportConfig = { userId, emailAddress: settings.emailImportAddress, @@ -611,31 +661,13 @@ export async function startEmailImportService(userId: number): Promise 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 (auth: ${authMode})` + return startConfiguredEmailImportService( + config, + settings.emailImportFrequency || 30, ); - - // Run immediately on start - runEmailCheckExclusive(config).catch((error) => { - console.error(`[EmailImport] Error checking emails for user ${userId}:`, error); - }); - - // Set up interval for periodic checks - const interval = setInterval(() => { - runEmailCheckExclusive(config).catch((error) => { - console.error(`[EmailImport] Error checking emails for user ${userId}:`, error); - }); - }, frequencyMs); - - activeIntervals.set(userId, interval); - console.log(`[EmailImport] Email import service started for user ${userId}`); - - return true; } catch (error) { console.error(`[EmailImport] Error starting email import service for user ${userId}:`, error); - return false; + return { success: false, message: "Erreur technique lors du démarrage de la réception e-mail." }; } } @@ -658,6 +690,33 @@ export function isEmailImportServiceRunning(userId: number): boolean { return activeIntervals.has(userId); } +type EmailImportAccountControllerDependencies = { + start: (userId: number) => Promise; + stop: (userId: number) => void; + isRunning: (userId: number) => boolean; +}; + +/** + * Contrôles administratifs d'une boîte source. Les paramètres restent rattachés + * à leur compte propriétaire ; seul le service planifié est administré ici. + */ +export function createEmailImportAccountController( + dependencies: EmailImportAccountControllerDependencies = { + start: startEmailImportService, + stop: stopEmailImportService, + isRunning: isEmailImportServiceRunning, + }, +) { + return { + startAccount: (userId: number) => dependencies.start(userId), + stopAccount: (userId: number): EmailImportServiceStartResult => { + dependencies.stop(userId); + return { success: true, message: "Service de réception e-mail arrêté" }; + }, + accountStatus: (userId: number) => ({ isRunning: dependencies.isRunning(userId) }), + }; +} + async function triggerConfiguredEmailCheck( userId: number, requireAutomaticEnabled: boolean, diff --git a/server/routers.ts b/server/routers.ts index 71bc969..ddd7dc0 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -103,7 +103,7 @@ import { localStoragePut, generateStorageKey } from "./localStorage"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { drawBapCartouche } from "./bapCartouche"; import { resolveBapExportDestination } from "./exportDestinationResolver"; -import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService"; +import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService"; import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService"; import { TRPCError } from "@trpc/server"; import { processFreeproExcel } from "./freeproService"; @@ -1704,8 +1704,7 @@ export const appRouter = router({ // ============= EMAIL IMPORT SERVICE ROUTES ============= emailImportService: router({ start: protectedProcedure.mutation(async ({ ctx }) => { - const started = await startEmailImportService(ctx.user.id); - return { success: started }; + return startEmailImportService(ctx.user.id); }), stop: protectedProcedure.mutation(async ({ ctx }) => { @@ -1727,6 +1726,27 @@ export const appRouter = router({ return getEmailImportAccounts(); }), + /** Démarre la réception automatique pour la boîte source sélectionnée par un administrateur. */ + startAccount: adminProcedure + .input(z.object({ userId: z.number().int().positive() })) + .mutation(async ({ input }) => { + return createEmailImportAccountController().startAccount(input.userId); + }), + + /** Arrête la réception automatique pour la boîte source sélectionnée par un administrateur. */ + stopAccount: adminProcedure + .input(z.object({ userId: z.number().int().positive() })) + .mutation(async ({ input }) => { + return createEmailImportAccountController().stopAccount(input.userId); + }), + + /** Retourne le statut en mémoire d’une boîte source administrée. */ + accountStatus: adminProcedure + .input(z.object({ userId: z.number().int().positive() })) + .query(async ({ input }) => { + return createEmailImportAccountController().accountStatus(input.userId); + }), + /** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */ checkAccountNow: adminProcedure .input(z.object({ userId: z.number().int().positive() })) diff --git a/todo.md b/todo.md index bfdb03c..95251be 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,10 @@ # Invoice Analyzer - TODO +## Diagnostic réception e-mail automatique — 24/09/2026 +- [x] Reproduire le démarrage de la réception automatique et identifier le blocage interface → API → planificateur +- [x] Corriger la cause sans lancer d’import ni créer de planification non sollicitée, avec sélection explicite du compte source pour les administrateurs +- [x] Valider les contrôles, les tests et le comportement de démarrage/arrêt + ## Phase 1: Base de données et schéma - [x] Créer table users avec support authentification locale + Azure AD - [x] Créer table sourceFiles pour fichiers PDF uploadés