Checkpoint: Réception automatique e-mail sécurisée : le planificateur est créé uniquement après un test IMAP concluant, les erreurs d’authentification sont affichées explicitement et ne déclenchent plus de boucle. Les administrateurs peuvent sélectionner, démarrer, arrêter et suivre le compte source déjà configuré sans afficher ni recopier de secrets. Tests de démarrage réussi/refusé, arrêt et ciblage de compte ajoutés ; 59 tests, TypeScript et build validés.

This commit is contained in:
Manus
2026-09-24 09:34:22 +00:00
parent fcde121438
commit 5aa2b9e285
5 changed files with 307 additions and 45 deletions

View File

@@ -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() {
/>
</div>
{emailImportEnabled && (
<div className="space-y-4 p-4 bg-green-50/50 dark:bg-green-950/10 rounded-lg border-2 border-green-200 dark:border-green-800">
<div className="space-y-4 p-4 bg-green-50/50 dark:bg-green-950/10 rounded-lg border-2 border-green-200 dark:border-green-800">
{emailImportEnabled && (
<>
{/* Mode d'authentification */}
<div className="space-y-2">
<Label className="text-base font-medium">Mode d'authentification IMAP</Label>
@@ -568,7 +600,7 @@ export default function ImportSettings() {
</div>
{emailImportAuthMode === "oauth2" && (
<div className="p-3 bg-purple-50 dark:bg-purple-950/20 rounded-lg border border-purple-200 dark:border-purple-800 text-sm text-purple-700 dark:text-purple-300">
<strong>Office 365 :</strong> 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.
<strong>Office 365 :</strong> 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.
</div>
)}
</div>
@@ -600,7 +632,7 @@ export default function ImportSettings() {
{emailImportAuthMode === "oauth2" && (
<div className="p-3 bg-muted/30 rounded-lg border text-sm text-muted-foreground">
<Wifi className="inline h-4 w-4 mr-1" />
En mode OAuth2, les credentials Azure AD (Tenant ID, Client ID, Client Secret) configurés dans l'onglet <strong>Export &gt; SharePoint</strong> seront utilisés automatiquement.
En mode OAuth2, les paramètres Azure AD (Tenant ID, Client ID, Client Secret) configurés dans la <strong>carte Microsoft Azure AD</strong> seront utilisés automatiquement.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -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() {
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
Enregistrez les paramètres avant de démarrer. Le service ne démarre que si le test IMAP est concluant.
</p>
</>
)}
{isAdmin && (
<div className="space-y-3 border-t border-green-200 pt-4 dark:border-green-800">
<div>
<p className="text-sm font-medium">Compte source administré</p>
<p className="text-xs text-muted-foreground">
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é.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<select
aria-label="Compte source de réception e-mail"
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm"
value={selectedEmailAccountId}
onChange={(event) => setSelectedEmailAccountId(event.target.value)}
disabled={emailAccountsLoading || configuredEmailAccounts.length === 0}
>
{configuredEmailAccounts.length === 0 ? (
<option value="">Aucune boîte e-mail configurée</option>
) : (
configuredEmailAccounts.map((account) => (
<option key={account.userId} value={String(account.userId)}>
{account.userName || account.userEmail || `Compte #${account.userId}`}
{account.automaticEnabled === 1 ? " — réception activée" : " — réception désactivée"}
</option>
))
)}
</select>
{selectedEmailAccountStatus?.isRunning ? (
<Button
variant="destructive"
onClick={async () => {
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 ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Square className="mr-2 h-4 w-4" />}
Arrêter
</Button>
) : (
<Button
onClick={async () => {
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 ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
Démarrer
</Button>
)}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>

View File

@@ -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);
});
});

View File

@@ -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<void>;
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<boolean> {
export async function startConfiguredEmailImportService(
config: EmailImportConfig,
frequencyMinutes: number,
connectionTester: EmailConnectionTester = testImapConnection,
emailCheckRunner: EmailCheckRunner = runEmailCheckExclusive,
): Promise<EmailImportServiceStartResult> {
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<EmailImportServiceStartResult> {
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<boolean>
// 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<boolean>
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<EmailImportServiceStartResult>;
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,

View File

@@ -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() }))

View File

@@ -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