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 + ? `| + + | +
+ Configuration du serveur de messagerie sortant. Utilisée pour les alertes, rapports et réinitialisation de mot de passe. +
+ + {result && ( ++ Vérifiez que votre configuration SMTP fonctionne en envoyant un email de test. Sauvegardez d'abord vos paramètres. +
+ {testResult && ( +