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 */}
Mode d'authentification IMAP
@@ -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é.
+
+
+
+
setSelectedEmailAccountId(event.target.value)}
+ disabled={emailAccountsLoading || configuredEmailAccounts.length === 0}
+ >
+ {configuredEmailAccounts.length === 0 ? (
+ Aucune boîte e-mail configurée
+ ) : (
+ configuredEmailAccounts.map((account) => (
+
+ {account.userName || account.userEmail || `Compte #${account.userId}`}
+ {account.automaticEnabled === 1 ? " — réception activée" : " — réception désactivée"}
+
+ ))
+ )}
+
+ {selectedEmailAccountStatus?.isRunning ? (
+
{
+ try {
+ const result = await stopEmailAccountMutation.mutateAsync({ userId: selectedEmailAccountUserId });
+ await utils.emailImportService.accountStatus.invalidate();
+ toast.success(result.message);
+ } catch (error: any) {
+ toast.error(error?.message || "Impossible d’arrêter le service.");
+ }
+ }}
+ disabled={stopEmailAccountMutation.isPending}
+ >
+ {stopEmailAccountMutation.isPending ? : }
+ Arrêter
+
+ ) : (
+
{
+ try {
+ const result = await startEmailAccountMutation.mutateAsync({ userId: selectedEmailAccountUserId });
+ if (result.success) {
+ await utils.emailImportService.accountStatus.invalidate();
+ toast.success(result.message);
+ } else {
+ toast.error(result.message, { duration: 8000 });
+ }
+ } catch (error: any) {
+ toast.error(error?.message || "Impossible de démarrer le service.", { duration: 8000 });
+ }
+ }}
+ disabled={!selectedEmailAccountUserId || startEmailAccountMutation.isPending}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ {startEmailAccountMutation.isPending ? : }
+ Démarrer
+
+ )}
+
+
+ )}
- )}
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