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:
@@ -35,6 +35,7 @@ export default function ImportSettings() {
|
|||||||
const stopFolderServiceMutation = trpc.folderImportService.stop.useMutation();
|
const stopFolderServiceMutation = trpc.folderImportService.stop.useMutation();
|
||||||
const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
|
const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
|
||||||
const testSharePointUploadMutation = trpc.importSettings.testSharePointUpload.useMutation();
|
const testSharePointUploadMutation = trpc.importSettings.testSharePointUpload.useMutation();
|
||||||
|
const testEmailConnectionMutation = trpc.importSettings.testEmailConnection.useMutation();
|
||||||
|
|
||||||
// Manual import
|
// Manual import
|
||||||
const [manualImportEnabled, setManualImportEnabled] = useState(true);
|
const [manualImportEnabled, setManualImportEnabled] = useState(true);
|
||||||
@@ -48,6 +49,7 @@ export default function ImportSettings() {
|
|||||||
const [emailImportEnabled, setEmailImportEnabled] = useState(false);
|
const [emailImportEnabled, setEmailImportEnabled] = useState(false);
|
||||||
const [emailImportAddress, setEmailImportAddress] = useState("");
|
const [emailImportAddress, setEmailImportAddress] = useState("");
|
||||||
const [emailImportPassword, setEmailImportPassword] = useState("");
|
const [emailImportPassword, setEmailImportPassword] = useState("");
|
||||||
|
const [emailImportAuthMode, setEmailImportAuthMode] = useState<"basic" | "oauth2">("basic");
|
||||||
const [emailImportHost, setEmailImportHost] = useState("");
|
const [emailImportHost, setEmailImportHost] = useState("");
|
||||||
const [emailImportPort, setEmailImportPort] = useState(993);
|
const [emailImportPort, setEmailImportPort] = useState(993);
|
||||||
const [emailImportFrequency, setEmailImportFrequency] = useState(30);
|
const [emailImportFrequency, setEmailImportFrequency] = useState(30);
|
||||||
@@ -76,6 +78,7 @@ export default function ImportSettings() {
|
|||||||
setEmailImportEnabled(settings.emailImportEnabled === 1);
|
setEmailImportEnabled(settings.emailImportEnabled === 1);
|
||||||
setEmailImportAddress(settings.emailImportAddress || "");
|
setEmailImportAddress(settings.emailImportAddress || "");
|
||||||
setEmailImportPassword(settings.emailImportPassword || "");
|
setEmailImportPassword(settings.emailImportPassword || "");
|
||||||
|
setEmailImportAuthMode(((settings as any).emailImportAuthMode as "basic" | "oauth2") || "basic");
|
||||||
setEmailImportHost(settings.emailImportHost || "");
|
setEmailImportHost(settings.emailImportHost || "");
|
||||||
setEmailImportPort(settings.emailImportPort || 993);
|
setEmailImportPort(settings.emailImportPort || 993);
|
||||||
setEmailImportFrequency(settings.emailImportFrequency || 30);
|
setEmailImportFrequency(settings.emailImportFrequency || 30);
|
||||||
@@ -148,6 +151,7 @@ export default function ImportSettings() {
|
|||||||
emailImportSinceDate: emailImportSinceDate
|
emailImportSinceDate: emailImportSinceDate
|
||||||
? Math.floor(new Date(emailImportSinceDate + 'T00:00:00Z').getTime() / 1000)
|
? Math.floor(new Date(emailImportSinceDate + 'T00:00:00Z').getTime() / 1000)
|
||||||
: null,
|
: null,
|
||||||
|
emailImportAuthMode: emailImportAuthMode,
|
||||||
exportFolder: exportFolder || null,
|
exportFolder: exportFolder || null,
|
||||||
exportFolderType: exportFolderType,
|
exportFolderType: exportFolderType,
|
||||||
bapExportMode: computedExportMode() as "browser" | "folder" | "both",
|
bapExportMode: computedExportMode() as "browser" | "folder" | "both",
|
||||||
@@ -396,6 +400,40 @@ export default function ImportSettings() {
|
|||||||
|
|
||||||
{emailImportEnabled && (
|
{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">
|
||||||
|
{/* Mode d'authentification */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base font-medium">Mode d'authentification IMAP</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEmailImportAuthMode("basic")}
|
||||||
|
className={`flex-1 py-2 px-4 rounded-lg border-2 text-sm font-medium transition-colors ${
|
||||||
|
emailImportAuthMode === "basic"
|
||||||
|
? "border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
|
||||||
|
: "border-muted bg-muted/30 text-muted-foreground hover:border-blue-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Basique (login/mot de passe)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEmailImportAuthMode("oauth2")}
|
||||||
|
className={`flex-1 py-2 px-4 rounded-lg border-2 text-sm font-medium transition-colors ${
|
||||||
|
emailImportAuthMode === "oauth2"
|
||||||
|
? "border-purple-500 bg-purple-50 text-purple-700 dark:bg-purple-950/30 dark:text-purple-300"
|
||||||
|
: "border-muted bg-muted/30 text-muted-foreground hover:border-purple-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OAuth2 (Office 365 / Azure AD)
|
||||||
|
</button>
|
||||||
|
</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.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email-address" className="text-base font-medium">Adresse email</Label>
|
<Label htmlFor="email-address" className="text-base font-medium">Adresse email</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -407,6 +445,7 @@ export default function ImportSettings() {
|
|||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{emailImportAuthMode === "basic" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email-password" className="text-base font-medium">Mot de passe</Label>
|
<Label htmlFor="email-password" className="text-base font-medium">Mot de passe</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -418,6 +457,13 @@ export default function ImportSettings() {
|
|||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{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 > SharePoint</strong> seront utilisés automatiquement.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email-host" className="text-base font-medium">Serveur IMAP</Label>
|
<Label htmlFor="email-host" className="text-base font-medium">Serveur IMAP</Label>
|
||||||
@@ -475,6 +521,34 @@ export default function ImportSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Bouton test connexion IMAP */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
toast.info("Test de connexion IMAP en cours...");
|
||||||
|
const result = await testEmailConnectionMutation.mutateAsync();
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { duration: 8000 });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("Erreur : " + (e?.message || "inconnue"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={testEmailConnectionMutation.isPending}
|
||||||
|
className="border-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/20"
|
||||||
|
>
|
||||||
|
{testEmailConnectionMutation.isPending
|
||||||
|
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Test en cours...</>
|
||||||
|
: <><Wifi className="mr-2 h-4 w-4" />Tester la connexion IMAP</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Vérifie que la connexion au serveur IMAP est fonctionnelle (sans importer d'emails)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 pt-4 border-t border-green-200 dark:border-green-800">
|
<div className="flex flex-wrap gap-3 pt-4 border-t border-green-200 dark:border-green-800">
|
||||||
{emailServiceStatus?.isRunning ? (
|
{emailServiceStatus?.isRunning ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
1
drizzle/0027_complex_ultimo.sql
Normal file
1
drizzle/0027_complex_ultimo.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `importSettings` ADD `emailImportAuthMode` enum('basic','oauth2') DEFAULT 'basic' NOT NULL;
|
||||||
1832
drizzle/meta/0027_snapshot.json
Normal file
1832
drizzle/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,13 @@
|
|||||||
"when": 1780411851758,
|
"when": 1780411851758,
|
||||||
"tag": "0026_perfect_richard_fisk",
|
"tag": "0026_perfect_richard_fisk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1780653000245,
|
||||||
|
"tag": "0027_complex_ultimo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -183,6 +183,7 @@ export const importSettings = mysqlTable("importSettings", {
|
|||||||
emailImportPort: int("emailImportPort").default(993), // IMAP port (default: 993 for SSL)
|
emailImportPort: int("emailImportPort").default(993), // IMAP port (default: 993 for SSL)
|
||||||
emailImportFrequency: int("emailImportFrequency").default(30).notNull(), // Frequency in minutes (default: 30)
|
emailImportFrequency: int("emailImportFrequency").default(30).notNull(), // Frequency in minutes (default: 30)
|
||||||
emailImportSinceDate: int("emailImportSinceDate"), // Timestamp Unix (s) — ne pas lire les emails antérieurs à cette date
|
emailImportSinceDate: int("emailImportSinceDate"), // Timestamp Unix (s) — ne pas lire les emails antérieurs à cette date
|
||||||
|
emailImportAuthMode: mysqlEnum("emailImportAuthMode", ["basic", "oauth2"]).default("basic").notNull(), // Auth mode: basic (login/password) or oauth2 (Azure AD token)
|
||||||
|
|
||||||
// Export folder settings
|
// Export folder settings
|
||||||
exportFolder: text("exportFolder"), // Path to folder for exporting invoices
|
exportFolder: text("exportFolder"), // Path to folder for exporting invoices
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
import { sendImportNotification } from "./notificationService";
|
import { sendImportNotification } from "./notificationService";
|
||||||
|
import { getOffice365ImapToken, buildXOAuth2String } from "./office365OAuth";
|
||||||
|
|
||||||
interface EmailImportConfig {
|
interface EmailImportConfig {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -20,6 +21,11 @@ interface EmailImportConfig {
|
|||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
sinceDate?: number; // Unix timestamp (s) — ne pas lire les emails antérieurs à cette date
|
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
|
// 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> {
|
async function buildImapConfig(config: EmailImportConfig): Promise<Imap.Config> {
|
||||||
return new Promise((resolve, reject) => {
|
if (config.authMode === "oauth2") {
|
||||||
const imap = new Imap({
|
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,
|
||||||
|
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,
|
user: config.emailAddress,
|
||||||
password: config.password,
|
password: config.password,
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
tls: true,
|
tls: true,
|
||||||
tlsOptions: { rejectUnauthorized: false },
|
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) {
|
function openInbox(cb: (err: Error | null, box?: any) => void) {
|
||||||
imap.openBox("INBOX", false, cb);
|
imap.openBox("INBOX", false, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
imap.once("ready", () => {
|
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) => {
|
openInbox((err, box) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -294,7 +341,6 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (pdfAttachments.length === 0) {
|
if (pdfAttachments.length === 0) {
|
||||||
console.log(`[EmailImport] Email ${seqno} has no PDF attachments, skipping`);
|
|
||||||
return;
|
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
|
* Start email import service for a user
|
||||||
*/
|
*/
|
||||||
@@ -393,27 +498,43 @@ export async function startEmailImportService(userId: number): Promise<boolean>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.emailImportAddress || !settings.emailImportPassword || !settings.emailImportHost) {
|
if (!settings.emailImportAddress || !settings.emailImportHost) {
|
||||||
console.log(`[EmailImport] Email import configuration incomplete for user ${userId}`);
|
console.log(`[EmailImport] Email import configuration incomplete for user ${userId}`);
|
||||||
return false;
|
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
|
// Stop existing service if running
|
||||||
stopEmailImportService(userId);
|
stopEmailImportService(userId);
|
||||||
|
|
||||||
const config: EmailImportConfig = {
|
const config: EmailImportConfig = {
|
||||||
userId,
|
userId,
|
||||||
emailAddress: settings.emailImportAddress,
|
emailAddress: settings.emailImportAddress,
|
||||||
password: settings.emailImportPassword,
|
password: settings.emailImportPassword || "",
|
||||||
host: settings.emailImportHost,
|
host: settings.emailImportHost,
|
||||||
port: settings.emailImportPort || 993,
|
port: settings.emailImportPort || 993,
|
||||||
sinceDate: settings.emailImportSinceDate ?? undefined,
|
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
|
const frequencyMs = (settings.emailImportFrequency || 30) * 60 * 1000; // Convert minutes to milliseconds
|
||||||
|
|
||||||
console.log(
|
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
|
// Run immediately on start
|
||||||
@@ -466,26 +587,32 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
|
|||||||
const settings = await getImportSettingsByUser(userId);
|
const settings = await getImportSettingsByUser(userId);
|
||||||
|
|
||||||
if (!settings || settings.emailImportEnabled !== 1) {
|
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) {
|
if (!settings.emailImportAddress || !settings.emailImportHost) {
|
||||||
return { success: false, message: "Configuration IMAP incompl\u00e8te" };
|
return { success: false, message: "Configuration IMAP incomplète" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
|
||||||
|
|
||||||
const config: EmailImportConfig = {
|
const config: EmailImportConfig = {
|
||||||
userId,
|
userId,
|
||||||
emailAddress: settings.emailImportAddress,
|
emailAddress: settings.emailImportAddress,
|
||||||
password: settings.emailImportPassword,
|
password: settings.emailImportPassword || "",
|
||||||
host: settings.emailImportHost,
|
host: settings.emailImportHost,
|
||||||
port: settings.emailImportPort || 993,
|
port: settings.emailImportPort || 993,
|
||||||
sinceDate: settings.emailImportSinceDate ?? undefined,
|
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}`);
|
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
|
||||||
await checkEmailsForPDFs(config);
|
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) {
|
} catch (error: any) {
|
||||||
console.error(`[EmailImport] Error during manual check for user ${userId}:`, error);
|
console.error(`[EmailImport] Error during manual check for user ${userId}:`, error);
|
||||||
return { success: false, message: `Erreur: ${error.message}` };
|
return { success: false, message: `Erreur: ${error.message}` };
|
||||||
|
|||||||
152
server/office365OAuth.ts
Normal file
152
server/office365OAuth.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtra
|
|||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
import { drawBapCartouche } from "./bapCartouche";
|
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 { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
@@ -1567,6 +1567,7 @@ export const appRouter = router({
|
|||||||
emailImportPort: z.number().min(1).max(65535).optional(),
|
emailImportPort: z.number().min(1).max(65535).optional(),
|
||||||
emailImportFrequency: z.number().min(1).optional(),
|
emailImportFrequency: z.number().min(1).optional(),
|
||||||
emailImportSinceDate: z.number().nullable().optional(), // Unix timestamp (s)
|
emailImportSinceDate: z.number().nullable().optional(), // Unix timestamp (s)
|
||||||
|
emailImportAuthMode: z.enum(["basic", "oauth2"]).optional(),
|
||||||
exportFolder: z.string().nullable().optional(),
|
exportFolder: z.string().nullable().optional(),
|
||||||
exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(),
|
exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(),
|
||||||
bapExportMode: z.enum(["browser", "folder", "both"]).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
|
testSharePointUpload: protectedProcedure
|
||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
const settings = await getImportSettingsByUser(ctx.user.id);
|
const settings = await getImportSettingsByUser(ctx.user.id);
|
||||||
|
|||||||
13
todo.md
13
todo.md
@@ -631,3 +631,16 @@
|
|||||||
- [ ] Log SharePoint dans l'historique BAP (colonnes sharepointUploadStatus + sharepointUploadPath)
|
- [ ] Log SharePoint dans l'historique BAP (colonnes sharepointUploadStatus + sharepointUploadPath)
|
||||||
- [ ] Bouton "Tester la connexion" Azure AD dans les paramètres
|
- [ ] Bouton "Tester la connexion" Azure AD dans les paramètres
|
||||||
- [ ] Champ date d'expiration du secret Azure AD + alerte dans les paramètres
|
- [ ] Champ date d'expiration du secret Azure AD + alerte dans les paramètres
|
||||||
|
|
||||||
|
## OAuth2 IMAP pour Office 365
|
||||||
|
- [x] Créer server/office365OAuth.ts avec getOffice365ImapToken et buildXOAuth2String
|
||||||
|
- [x] Ajouter champ emailImportAuthMode (basic/oauth2) dans le schéma DB importSettings
|
||||||
|
- [x] Appliquer migration DB (pnpm db:push)
|
||||||
|
- [x] Modifier emailImportService.ts pour supporter OAuth2 XOAUTH2 via Azure AD
|
||||||
|
- [x] Ajouter fonction testImapConnection dans emailImportService.ts
|
||||||
|
- [x] Ajouter procédure testEmailConnection dans routers.ts
|
||||||
|
- [x] Ajouter emailImportAuthMode dans le schéma Zod de importSettings.update
|
||||||
|
- [x] Ajouter sélecteur mode auth (basic/oauth2) dans ImportSettings.tsx
|
||||||
|
- [x] Ajouter bouton "Tester la connexion IMAP" dans ImportSettings.tsx
|
||||||
|
- [ ] Déployer sur recette et production
|
||||||
|
- [ ] Appliquer migration emailImportAuthMode sur recette et production
|
||||||
|
|||||||
Reference in New Issue
Block a user