From 53b1ad2063e86c844d1f9987617ebe34a9a95977 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 14 Jun 2026 20:34:52 +0200 Subject: [PATCH] Implementation du mail --- backend/package-lock.json | 44 ++- backend/package.json | 1 + backend/scripts/seed-app-logo.js | 28 ++ backend/src/db/index.js | 50 +++ backend/src/routes/admin.js | 145 ++++++++ backend/src/utils/mailer.js | 190 +++++++++++ frontend/src/pages/Admin.jsx | 9 + frontend/src/pages/admin/SmtpSection.jsx | 416 +++++++++++++++++++++++ 8 files changed, 858 insertions(+), 25 deletions(-) create mode 100644 backend/scripts/seed-app-logo.js create mode 100644 backend/src/utils/mailer.js create mode 100644 frontend/src/pages/admin/SmtpSection.jsx diff --git a/backend/package-lock.json b/backend/package-lock.json index f370be7..f0b57b1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,11 +18,11 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "nodemailer": "^8.0.11", "sharp": "^0.34.5", "xlsx": "^0.18.5", "zod": "^3.23.8" - }, - "devDependencies": {} + } }, "node_modules/@emnapi/runtime": { "version": "1.10.0", @@ -646,21 +646,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1035,14 +1020,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -1061,7 +1046,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -1607,6 +1592,15 @@ "node": ">=10" } }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1730,9 +1724,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/backend/package.json b/backend/package.json index 053857b..6ee6883 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "nodemailer": "^8.0.11", "sharp": "^0.34.5", "xlsx": "^0.18.5", "zod": "^3.23.8" diff --git a/backend/scripts/seed-app-logo.js b/backend/scripts/seed-app-logo.js new file mode 100644 index 0000000..8e4c4dd --- /dev/null +++ b/backend/scripts/seed-app-logo.js @@ -0,0 +1,28 @@ +/** + * seed-app-logo.js — Enregistre app-logo.svg dans la bibliothèque d'icônes. + * Usage (depuis le dossier backend/) : node scripts/seed-app-logo.js + */ + +import 'dotenv/config'; +import db from '../src/db/index.js'; + +const existing = db.prepare("SELECT id FROM app_icons WHERE name = 'logo-app'").get(); + +if (existing) { + db.prepare(` + INSERT INTO app_icons_history (icon_id, filename, replaced_at) + SELECT id, filename, datetime('now') FROM app_icons WHERE name = 'logo-app' + `).run(); + db.prepare(` + UPDATE app_icons + SET filename = 'app-logo.svg', description = 'Logo principal de la plateforme', updated_at = datetime('now') + WHERE name = 'logo-app' + `).run(); + console.log('✓ Icône logo-app mise à jour → app-logo.svg'); +} else { + db.prepare(` + INSERT INTO app_icons (name, filename, description) + VALUES ('logo-app', 'app-logo.svg', 'Logo principal de la plateforme') + `).run(); + console.log('✓ Icône logo-app créée → app-logo.svg'); +} diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 5772634..5621b55 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -999,11 +999,18 @@ db.exec(` { name: 'remboursement', filename: 'icon_remboursement_seed.svg', description: 'Remboursement' }, { name: 'retrait', filename: 'icon_retrait_seed.svg', description: 'Retrait de fonds' }, { name: 'tax', filename: 'icon_tax_1780230337883.svg', description: 'Fiscalité / Tax' }, + { name: 'logo-app', filename: 'app-logo.svg', description: 'Logo principal de la plateforme' }, ]; const ins = db.prepare( 'INSERT OR IGNORE INTO app_icons (name, filename, description) VALUES (?,?,?)' ); for (const s of seeds) ins.run(s.name, s.filename, s.description); + } else { + // Garantit que logo-app existe même sur une DB déjà peuplée + db.prepare(` + INSERT OR IGNORE INTO app_icons (name, filename, description) + VALUES ('logo-app', 'app-logo.svg', 'Logo principal de la plateforme') + `).run(); } } @@ -1683,3 +1690,46 @@ db.exec(` console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`); } } + +// ── Migration : table smtp_config ───────────────────────────────────────── +{ + db.exec(` + CREATE TABLE IF NOT EXISTS smtp_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + host TEXT, + port INTEGER NOT NULL DEFAULT 587, + secure INTEGER NOT NULL DEFAULT 0, + email TEXT, + username TEXT, + password TEXT, + allow_unauth INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + + // Seed row unique (id=1) si elle n'existe pas encore + const existing = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get(); + if (!existing) { + // Pré-remplir depuis les variables d'environnement si disponibles + db.prepare(` + INSERT INTO smtp_config (id, enabled, host, port, email, username, password) + VALUES (1, 0, ?, ?, ?, ?, ?) + `).run( + process.env.SMTP_HOST || null, + parseInt(process.env.SMTP_PORT || '587', 10), + process.env.SMTP_EMAIL || null, + process.env.SMTP_USERNAME || null, + process.env.SMTP_PASSWORD || null, + ); + } + + // Ajout des colonnes app_name et app_url si absentes + const smtpCols = db.prepare('PRAGMA table_info(smtp_config)').all().map(c => c.name); + if (!smtpCols.includes('app_name')) + db.exec(`ALTER TABLE smtp_config ADD COLUMN app_name TEXT DEFAULT 'Crowdlending'`); + if (!smtpCols.includes('app_url')) + db.exec(`ALTER TABLE smtp_config ADD COLUMN app_url TEXT DEFAULT ''`); + + console.log('[DB] Table smtp_config OK'); +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 56cbfa0..0cb929d 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -371,3 +371,148 @@ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => { res.json({ ok: true, msg: `"${row.nom}" supprimé.` }); } catch (e) { next(e); } }); + +/* ── SMTP ─────────────────────────────────────────────────────────────── */ + +/** Retourne la config SMTP (mot de passe masqué) */ +router.get('/smtp', (req, res) => { + const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get(); + if (!row) return res.json({}); + res.json({ + enabled: !!row.enabled, + host: row.host || '', + port: row.port || 587, + secure: !!row.secure, + email: row.email || '', + username: row.username || '', + hasPassword: !!(row.password), + allowUnauth: !!row.allow_unauth, + appName: row.app_name || 'Crowdlending', + appUrl: row.app_url || '', + }); +}); + +/** Retourne les valeurs .env pour pré-remplissage */ +router.get('/smtp/env', (req, res) => { + res.json({ + host: process.env.SMTP_HOST || '', + port: parseInt(process.env.SMTP_PORT || '587', 10), + email: process.env.SMTP_EMAIL || '', + username: process.env.SMTP_USERNAME || '', + hasPassword: !!(process.env.SMTP_PASSWORD), + }); +}); + +const SmtpSchema = z.object({ + enabled: z.boolean().optional(), + host: z.string().optional(), + port: z.number().int().min(1).max(65535).optional(), + secure: z.boolean().optional(), + email: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + allowUnauth: z.boolean().optional(), + appName: z.string().optional(), + appUrl: z.string().optional(), +}); + +/** Sauvegarde la config SMTP */ +router.put('/smtp', (req, res, next) => { + try { + const body = SmtpSchema.parse(req.body); + + const existing = db.prepare('SELECT password FROM smtp_config WHERE id = 1').get(); + const passwordToStore = body.password !== undefined + ? (body.password || null) + : (existing?.password || null); + + db.prepare(` + INSERT INTO smtp_config (id, enabled, host, port, secure, email, username, password, allow_unauth, app_name, app_url, updated_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + enabled = excluded.enabled, + host = excluded.host, + port = excluded.port, + secure = excluded.secure, + email = excluded.email, + username = excluded.username, + password = excluded.password, + allow_unauth = excluded.allow_unauth, + app_name = excluded.app_name, + app_url = excluded.app_url, + updated_at = excluded.updated_at + `).run( + body.enabled ? 1 : 0, + body.host ?? null, + body.port ?? 587, + body.secure ? 1 : 0, + body.email ?? null, + body.username ?? null, + passwordToStore, + body.allowUnauth ? 1 : 0, + body.appName ?? 'Crowdlending', + body.appUrl ?? '', + ); + + res.json({ ok: true, msg: 'Configuration SMTP sauvegardée.' }); + } catch (e) { next(e); } +}); + +/** Traduit les erreurs nodemailer en messages lisibles */ +function smtpErrorMessage(e) { + const msg = e.message || ''; + const code = e.code || ''; + // Connexion + if (code === 'ETIMEDOUT' || msg.includes('Greeting never received')) + return 'Impossible de joindre le serveur SMTP (timeout). Vérifiez l\'hôte, le port et que le port sortant n\'est pas bloqué par un pare-feu.'; + if (code === 'ECONNREFUSED') + return 'Connexion refusée par le serveur SMTP. Vérifiez l\'hôte et le port.'; + if (code === 'ENOTFOUND') + return `Hôte SMTP introuvable ("${e.hostname || ''}"). Vérifiez le nom du serveur.`; + // TLS / certificat + if (code === 'ESOCKET' || msg.includes('self-signed') || msg.includes('certificate')) + return 'Erreur de certificat TLS. Activez "Faire confiance aux certificats non autorisés" si votre serveur utilise un certificat auto-signé, ou désactivez TLS si vous utilisez le port 587 (STARTTLS).'; + if (msg.includes('wrong version number') || msg.includes('SSL routines')) + return 'Protocole TLS incompatible. Si vous utilisez le port 587, désactivez "Connexion sécurisée (TLS)". Si vous utilisez le port 465, activez-la.'; + // Auth + if (msg.includes('Invalid login') || msg.includes('535') || msg.includes('Authentication')) + return 'Authentification refusée. Vérifiez le nom d\'utilisateur et le mot de passe (certains services exigent une clé API, pas le mot de passe du compte).'; + if (msg.includes('534') || msg.includes('Username and Password not accepted')) + return 'Identifiants rejetés. Vérifiez que vous utilisez bien une clé API ou un mot de passe d\'application, pas le mot de passe principal.'; + // SMTP désactivé / config manquante + if (msg.includes('SMTP désactivé') || msg.includes('incomplète')) + return msg; + // Fallback + return `Erreur SMTP : ${msg}`; +} + +/** Envoie un email de test */ +router.post('/smtp/test', async (req, res, next) => { + try { + const { to } = z.object({ to: z.string().email() }).parse(req.body); + + const { sendMail, buildEmailHtml, getSmtpConfig } = await import('../utils/mailer.js'); + const cfg = getSmtpConfig(); + const date = new Date().toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' }); + + await sendMail({ + to, + subject: `Test SMTP — ${cfg.appName}`, + text: `Configuration SMTP opérationnelle.\n\nCet email de test a été envoyé depuis ${cfg.appName} le ${date}.\n\n${cfg.appUrl || ''}`, + html: buildEmailHtml({ + title: 'Configuration email opérationnelle ✓', + body: ` +

Votre configuration SMTP fonctionne correctement.

+

Email de test envoyé le ${date}.

+ `, + ctaLabel: `Accéder à ${cfg.appName}`, + ctaUrl: cfg.appUrl || undefined, + }), + }); + + res.json({ ok: true, msg: `Email de test envoyé à ${to}.` }); + } catch (e) { + // On retourne un 200 avec ok:false pour que le frontend affiche le message sans crash + res.json({ ok: false, msg: smtpErrorMessage(e) }); + } +}); diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js new file mode 100644 index 0000000..3443bde --- /dev/null +++ b/backend/src/utils/mailer.js @@ -0,0 +1,190 @@ +/** + * mailer.js — Module d'envoi d'email + * + * Charge la config SMTP depuis la DB (source de vérité). + * Fallback : variables d'environnement SMTP_* si la DB est vide. + * + * Usage : + * import { sendMail, buildEmailHtml } from '../utils/mailer.js'; + * await sendMail({ to, subject, html: buildEmailHtml({ title, body, ctaLabel, ctaUrl }) }); + */ + +import nodemailer from 'nodemailer'; +import db from '../db/index.js'; + +/** Charge la config SMTP active (DB en priorité, .env en fallback). */ +export function getSmtpConfig() { + const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get(); + + return { + enabled: !!(row?.enabled), + host: row?.host || process.env.SMTP_HOST || '', + port: row?.port || parseInt(process.env.SMTP_PORT || '587', 10), + secure: !!(row?.secure), + email: row?.email || process.env.SMTP_EMAIL || '', + username: row?.username || process.env.SMTP_USERNAME || '', + password: row?.password || process.env.SMTP_PASSWORD || '', + allowUnauth: !!(row?.allow_unauth), + appName: row?.app_name || process.env.APP_NAME || 'Crowdlending', + appUrl: row?.app_url || process.env.APP_URL || '', + }; +} + +/** Résout l'URL absolue de l'icône principale de l'app (peut être null). */ +function getAppIconUrl(appUrl) { + if (!appUrl) return null; + try { + const icon = db.prepare( + `SELECT filename FROM app_icons ORDER BY updated_at DESC LIMIT 1` + ).get(); + return icon ? `${appUrl.replace(/\/$/, '')}/api/icons-files/${icon.filename}` : null; + } catch { + return null; + } +} + +/** + * Construit un email HTML au format plateforme. + * + * @param {{ + * title: string, // Titre principal (h1) + * body: string, // Corps HTML (paragraphes, etc.) + * ctaLabel?: string, // Texte du bouton CTA + * ctaUrl?: string, // URL du bouton CTA + * appName?: string, // Surcharge nom app + * appUrl?: string, // Surcharge URL app + * }} opts + */ +export function buildEmailHtml({ title, body, ctaLabel, ctaUrl, appName, appUrl } = {}) { + const cfg = getSmtpConfig(); + const name = appName || cfg.appName; + const url = appUrl || cfg.appUrl; + const iconUrl = getAppIconUrl(url); + + const iconHtml = iconUrl + ? `${name}` + : ''; + + const ctaHtml = ctaLabel && ctaUrl + ? `
+ + ${ctaLabel} + +
` + : ''; + + const footerLink = url + ? `${url}` + : name; + + return ` + + + + + ${title} + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ ${iconHtml} +

${name}

+
+

+ ${title} +

+
+ ${body} +
+ ${ctaHtml} +
+ Cet email a été envoyé automatiquement par ${footerLink}.
+ Merci de ne pas y répondre directement. +
+
+ +`; +} + +/** Crée un transporteur nodemailer à partir de la config active. */ +function createTransport(cfg) { + return nodemailer.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure, + auth: { + user: cfg.username, + pass: cfg.password, + }, + tls: cfg.allowUnauth ? { rejectUnauthorized: false } : undefined, + connectionTimeout: 8000, + greetingTimeout: 5000, + socketTimeout: 10000, + }); +} + +/** + * Envoie un email. + * @param {{ to: string, subject: string, text?: string, html?: string }} opts + */ +export async function sendMail({ to, subject, text, html }) { + const cfg = getSmtpConfig(); + + if (!cfg.enabled) { + throw new Error('SMTP désactivé — activez-le dans Administration > SMTP.'); + } + if (!cfg.host || !cfg.email) { + throw new Error('Config SMTP incomplète (hôte ou courriel manquant).'); + } + + const transporter = createTransport(cfg); + const info = await transporter.sendMail({ + from: `"${cfg.appName}" <${cfg.email}>`, + to, + subject, + text, + html, + }); + + return info; +} + +/** + * Vérifie la connexion SMTP sans envoyer d'email. + * Renvoie { ok: true } ou lance une erreur. + */ +export async function verifySmtp() { + const cfg = getSmtpConfig(); + if (!cfg.host) throw new Error('Hôte SMTP manquant.'); + const transporter = createTransport(cfg); + await transporter.verify(); + return { ok: true }; +} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 87ef662..842c708 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -5,6 +5,7 @@ import UsersSection from './admin/UsersSection.jsx'; import CreateUserSection from './admin/CreateUserSection.jsx'; import JobLogsSection from './admin/JobLogsSection.jsx'; import IconsSection from './admin/IconsSection.jsx'; +import SmtpSection from './admin/SmtpSection.jsx'; /* ── Icônes nav ───────────────────────────────────────────────── */ function IconUsers() { return ; } @@ -13,6 +14,7 @@ function IconActivity() { return ; } function IconTax() { return ; } function IconDatabase() { return ; } +function IconMail() { return ; } const NAV = [ { @@ -31,6 +33,12 @@ const NAV = [ { id: 'fiscalite', label: 'Fiscalité', icon: , href: '/admin/fiscalite' }, ], }, + { + group: 'Notifications', + items: [ + { id: 'smtp', label: 'SMTP', icon: }, + ], + }, ]; export default function Admin() { @@ -68,6 +76,7 @@ export default function Admin() { {section === 'create' && setRefreshKey(k => k + 1)} />} {section === 'job-logs' && } {section === 'icons' && } + {section === 'smtp' && } ); diff --git a/frontend/src/pages/admin/SmtpSection.jsx b/frontend/src/pages/admin/SmtpSection.jsx new file mode 100644 index 0000000..b541396 --- /dev/null +++ b/frontend/src/pages/admin/SmtpSection.jsx @@ -0,0 +1,416 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { api } from '../../api.js'; +import { useAuth } from '../../context/AuthContext.jsx'; + +/* ── Icône œil (show/hide password) ───────────────────────────────────── */ +function IconEye({ open }) { + return open + ? + : ; +} + +/* ── Toggle switch ─────────────────────────────────────────────────────── */ +function Toggle({ checked, onChange, id }) { + return ( + + ); +} + +/* ── Ligne de paramètre ────────────────────────────────────────────────── */ +function SettingRow({ label, description, children }) { + return ( +
+
+
{label}
+ {description &&
{description}
} +
+
+ {children} +
+
+ ); +} + +const DEFAULT_STATE = { + enabled: false, + host: '', + port: 587, + secure: false, + email: '', + username: '', + password: '', + allowUnauth: false, + appName: 'Crowdlending', + appUrl: '', +}; + +export default function SmtpSection() { + const { user } = useAuth(); + const [form, setForm] = useState(DEFAULT_STATE); + const [hasPassword, setHasPassword] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const passwordTyped = useRef(false); // true uniquement si l'utilisateur a tapé manuellement + const [testEmail, setTestEmail] = useState(user?.email || ''); + const [result, setResult] = useState(null); // { ok, msg } + const [testResult, setTestResult] = useState(null); + const [loading, setLoading] = useState(false); + const [testing, setTesting] = useState(false); + const [loadingEnv, setLoadingEnv] = useState(false); + const [saveStatus, setSaveStatus] = useState(null); // 'saving' | 'saved' | 'error' + const debounceRef = useRef(null); + const isFirstLoad = useRef(true); + const hasPasswordRef = useRef(false); + + useEffect(() => { + api.get('/admin/smtp').then(data => { + setForm({ + enabled: !!data.enabled, + host: data.host || '', + port: data.port || 587, + secure: !!data.secure, + email: data.email || '', + username: data.username || '', + password: '', + allowUnauth: !!data.allowUnauth, + appName: data.appName || 'Crowdlending', + appUrl: data.appUrl || '', + }); + setHasPassword(!!data.hasPassword); + hasPasswordRef.current = !!data.hasPassword; + }); + }, []); + + const doSave = useCallback(async (currentForm) => { + setSaveStatus('saving'); + try { + const payload = { ...currentForm }; + // N'envoyer le mot de passe que si l'utilisateur l'a tapé manuellement + if (!payload.password || !passwordTyped.current) delete payload.password; + await api.put('/admin/smtp', payload); + if (payload.password) { + setHasPassword(true); + hasPasswordRef.current = true; + } + setSaveStatus('saved'); + setTimeout(() => setSaveStatus(null), 2500); + } catch (e) { + setSaveStatus('error'); + setResult({ ok: false, msg: e.message || 'Erreur lors de la sauvegarde.' }); + } + }, []); + + const set = (key, value) => { + setForm(f => { + const next = { ...f, [key]: value }; + // Ne pas déclencher l'auto-save au chargement initial + if (isFirstLoad.current) { isFirstLoad.current = false; return next; } + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSave(next), 1500); + return next; + }); + }; + + // Nettoyage du timer au démontage + useEffect(() => () => clearTimeout(debounceRef.current), []); + + const handleTest = async () => { + if (!testEmail) return; + setTesting(true); + setTestResult(null); + try { + const data = await api.post('/admin/smtp/test', { to: testEmail }); + setTestResult({ ok: data.ok, msg: data.msg }); + } catch (e) { + setTestResult({ ok: false, msg: e.message || 'Erreur lors du test.' }); + } finally { + setTesting(false); + } + }; + + const handleLoadEnv = async () => { + setLoadingEnv(true); + try { + const data = await api.get('/admin/smtp/env'); + setForm(f => { + const next = { + ...f, + host: data.host || f.host, + port: data.port || f.port, + email: data.email || f.email, + username: data.username || f.username, + password: '', + }; + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSave(next), 1500); + return next; + }); + if (data.hasPassword) { setHasPassword(true); hasPasswordRef.current = true; } + } catch (e) { + setResult({ ok: false, msg: 'Impossible de charger les valeurs .env.' }); + } finally { + setLoadingEnv(false); + } + }; + + return ( +
+
+

SMTP

+
+ {saveStatus === 'saving' && ( + Sauvegarde… + )} + {saveStatus === 'saved' && ( + ✓ Sauvegardé + )} + {saveStatus === 'error' && ( + Erreur + )} + +
+
+

+ Configuration du serveur de messagerie sortant. Utilisée pour les alertes, rapports et réinitialisation de mot de passe. +

+ + {result && ( +
+ {result.msg} + +
+ )} + +
+ + {/* Nom de la plateforme */} + + set('appName', e.target.value)} + placeholder="Crowdlending" + style={{ width: 280 }} + /> + + + {/* URL de la plateforme */} + + set('appUrl', e.target.value)} + placeholder="https://monapp.example.com" + style={{ width: 280 }} + /> + + + {/* Séparateur */} +
+ + {/* Activer */} + + set('enabled', v)} /> + + + {/* Hôte */} + + set('host', e.target.value)} + placeholder="smtp.example.com" + style={{ width: 280 }} + /> + + + {/* Port */} + + set('port', parseInt(e.target.value, 10) || 587)} + min={1} + max={65535} + style={{ width: 120 }} + /> + + + {/* Connexion sécurisée (TLS) */} + + set('secure', v)} /> + + + {/* Courriel */} + + set('email', e.target.value)} + placeholder="no-reply@example.com" + style={{ width: 280 }} + /> + + + {/* Nom d'utilisateur */} + + set('username', e.target.value)} + placeholder="user@example.com" + autoComplete="off" + style={{ width: 280 }} + /> + + + {/* Mot de passe */} + +
+ { passwordTyped.current = true; set('password', e.target.value); }} + placeholder={hasPassword ? '••••••••' : 'Mot de passe'} + autoComplete="new-password" + style={{ width: '100%', paddingRight: 36 }} + /> + +
+
+ + {/* Certificats non autorisés */} + + set('allowUnauth', v)} /> + + +
+ + + {/* Section email de test */} +
+

Envoyer un email de test

+

+ Vérifiez que votre configuration SMTP fonctionne en envoyant un email de test. Sauvegardez d'abord vos paramètres. +

+ {testResult && ( +
+ {testResult.msg} + +
+ )} +
+ setTestEmail(e.target.value)} + placeholder="destinataire@example.com" + style={{ flex: 1, maxWidth: 320 }} + onKeyDown={e => e.key === 'Enter' && handleTest()} + /> + +
+
+
+ ); +}