From 8b1f3f581e72fa315e57ee52c00f89b9e27dd36c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 15:02:04 +0000 Subject: [PATCH] fix: migrer envoi email de SMTP/Nodemailer vers Microsoft Graph sendMail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email.ts : remplace Nodemailer par fetch Graph (POST /users/{email}/sendMail) * getGraphEmailConfig() lit les credentials depuis app_settings * getAccessToken() : client_credentials flow * sendInvoiceEmail(senderEmail, senderName, toEmail, pdfPath, pdfFilename) * sendTestEmail(senderEmail, senderName) : envoie à soi-même - invoices.ts : retire la vérification SMTP, passe user.email + user.name - settings.ts : test email utilise désormais Graph (même endpoint /smtp/test) --- backend/src/routes/invoices.ts | 11 +- backend/src/routes/settings.ts | 15 +-- backend/src/services/email.ts | 204 +++++++++++++++++++++++++-------- 3 files changed, 164 insertions(+), 66 deletions(-) diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts index ee20b81..0e06e0b 100644 --- a/backend/src/routes/invoices.ts +++ b/backend/src/routes/invoices.ts @@ -306,14 +306,9 @@ router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise => { - const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]); + const userResult = await db.query('SELECT name, email FROM users WHERE id=$1', [req.user!.id]); const user = userResult.rows[0]; - if (!user.smtp_host || !user.smtp_pass_enc) { - res.status(400).json({ error: 'SMTP non configuré' }); - return; - } - try { const { sendTestEmail } = await import('../services/email'); - await sendTestEmail(user); - res.json({ success: true, message: `Email de test envoyé à ${user.smtp_from_email}` }); + await sendTestEmail(user.email, user.name); + res.json({ success: true, message: `Email de test envoyé à ${user.email}` }); } catch (err: any) { - res.status(500).json({ error: `Échec SMTP : ${err.message}` }); + res.status(500).json({ error: `Échec envoi Graph : ${err.message}` }); } })); diff --git a/backend/src/services/email.ts b/backend/src/services/email.ts index 863e8f8..67bca56 100644 --- a/backend/src/services/email.ts +++ b/backend/src/services/email.ts @@ -1,66 +1,174 @@ -import nodemailer from 'nodemailer'; +import { db } from '../db'; import { decrypt } from '../crypto'; +import fs from 'fs/promises'; -export interface UserSmtpConfig { - smtp_host: string; - smtp_port: number; - smtp_secure: boolean; - smtp_user: string; - smtp_pass_enc: string; - smtp_from_name: string; - smtp_from_email: string; +// ─── Types ─────────────────────────────────────────────────── + +interface GraphEmailConfig { + tenantId: string; + clientId: string; + clientSecret: string; } -function createTransporter(user: UserSmtpConfig) { - return nodemailer.createTransport({ - host: user.smtp_host, - port: user.smtp_port, - secure: user.smtp_secure, - auth: { - user: user.smtp_user, - pass: decrypt(user.smtp_pass_enc), - }, - }); +// ─── Helpers internes ──────────────────────────────────────── + +async function getGraphEmailConfig(): Promise { + const result = await db.query( + `SELECT key, value FROM app_settings + WHERE key IN ('graph_tenant_id', 'graph_client_id', 'graph_client_secret_enc')` + ); + const cfg: Record = {}; + for (const row of result.rows) cfg[row.key] = row.value; + + if (!cfg.graph_tenant_id || !cfg.graph_client_id || !cfg.graph_client_secret_enc) { + throw new Error( + 'Microsoft Graph non configuré. Allez dans Paramètres → Microsoft 365 et renseignez le Tenant ID, Client ID et Client Secret.' + ); + } + + return { + tenantId: cfg.graph_tenant_id, + clientId: cfg.graph_client_id, + clientSecret: decrypt(cfg.graph_client_secret_enc), + }; } +async function getAccessToken(cfg: GraphEmailConfig): Promise { + const response = await fetch( + `https://login.microsoftonline.com/${cfg.tenantId}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + scope: 'https://graph.microsoft.com/.default', + }), + } + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Obtention token Graph échouée (${response.status}) : ${body.slice(0, 300)}`); + } + + const data = (await response.json()) as { access_token: string }; + return data.access_token; +} + +// ─── API publique ──────────────────────────────────────────── + /** - * Envoie la facture par email. - * Expéditeur = compte SMTP de l'utilisateur connecté. - * Destinataire = email de la société à rembourser. + * Envoie la facture par email via Microsoft Graph (sendMail). + * + * Prérequis Azure : + * - Permission d'application : Mail.Send + * - L'expéditeur (senderEmail) doit être une boîte Exchange/M365 du tenant. + * + * @param senderEmail Email de l'expéditeur (user.email en base) + * @param senderName Nom affiché de l'expéditeur (user.name) + * @param toEmail Destinataire (company.email) + * @param pdfAbsPath Chemin absolu vers le PDF généré + * @param pdfFilename Nom du fichier à afficher dans la pièce jointe */ export async function sendInvoiceEmail( - user: UserSmtpConfig, - toEmail: string, - pdfAbsPath: string, - pdfFilename: string + senderEmail: string, + senderName: string, + toEmail: string, + pdfAbsPath: string, + pdfFilename: string ): Promise { - const transporter = createTransporter(user); + const cfg = await getGraphEmailConfig(); + const token = await getAccessToken(cfg); - await transporter.sendMail({ - from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`, - to: toEmail, - subject: 'Facture à rembourser', - text: '', - attachments: [ - { - filename: pdfFilename, - path: pdfAbsPath, - contentType: 'application/pdf', + // Lecture du PDF et encodage Base64 + const pdfBuffer = await fs.readFile(pdfAbsPath); + const pdfBase64 = pdfBuffer.toString('base64'); + + const payload = { + message: { + subject: 'Facture à rembourser', + body: { + contentType: 'Text', + content: '', }, - ], - }); + from: { + emailAddress: { name: senderName, address: senderEmail }, + }, + toRecipients: [ + { emailAddress: { address: toEmail } }, + ], + attachments: [ + { + '@odata.type': '#microsoft.graph.fileAttachment', + name: pdfFilename, + contentType: 'application/pdf', + contentBytes: pdfBase64, + }, + ], + }, + saveToSentItems: true, + }; + + const resp = await fetch( + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(senderEmail)}/sendMail`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Graph sendMail échoué (${resp.status}) : ${text.slice(0, 400)}`); + } + // 202 Accepted — Graph ne retourne pas de corps } /** - * Email de test pour vérifier la config SMTP de l'utilisateur. + * Email de test (s'envoie à soi-même) via Microsoft Graph. + * Utilisé par POST /api/settings/graph/test-email */ -export async function sendTestEmail(user: UserSmtpConfig): Promise { - const transporter = createTransporter(user); +export async function sendTestEmail( + senderEmail: string, + senderName: string +): Promise { + const cfg = await getGraphEmailConfig(); + const token = await getAccessToken(cfg); - await transporter.sendMail({ - from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`, - to: user.smtp_from_email, - subject: '[NotesFrais] Test de configuration SMTP', - text: 'Votre configuration SMTP fonctionne correctement ✓', - }); + const payload = { + message: { + subject: '[NotesFrais] Test de configuration email', + body: { + contentType: 'Text', + content: `Bonjour ${senderName},\n\nVotre configuration Microsoft Graph fonctionne correctement ✓\n\n— NotesFrais`, + }, + toRecipients: [ + { emailAddress: { address: senderEmail } }, + ], + }, + saveToSentItems: false, + }; + + const resp = await fetch( + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(senderEmail)}/sendMail`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Graph sendMail test échoué (${resp.status}) : ${text.slice(0, 400)}`); + } }