Implementation du mail

This commit is contained in:
2026-06-14 20:34:52 +02:00
parent 96b1741cd1
commit 53b1ad2063
8 changed files with 858 additions and 25 deletions
+19 -25
View File
@@ -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"
+1
View File
@@ -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"
+28
View File
@@ -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');
}
+50
View File
@@ -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');
}
+145
View File
@@ -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: `
<p>Votre configuration SMTP fonctionne correctement.</p>
<p style="color:#6b7280;font-size:14px;">Email de test envoyé le <strong>${date}</strong>.</p>
`,
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) });
}
});
+190
View File
@@ -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
? `<img src="${iconUrl}" alt="${name}" width="48" height="48"
style="display:block;border-radius:10px;margin:0 auto 12px;" />`
: '';
const ctaHtml = ctaLabel && ctaUrl
? `<div style="text-align:center;margin:32px 0 8px;">
<a href="${ctaUrl}"
style="display:inline-block;padding:12px 28px;background:#6366f1;color:#fff;
text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">
${ctaLabel}
</a>
</div>`
: '';
const footerLink = url
? `<a href="${url}" style="color:#6366f1;text-decoration:none;">${url}</a>`
: name;
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>${title}</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:system-ui,-apple-system,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 16px;">
<tr>
<td align="center">
<table width="560" cellpadding="0" cellspacing="0"
style="background:#ffffff;border-radius:12px;overflow:hidden;
box-shadow:0 2px 8px rgba(0,0,0,0.06);max-width:100%;">
<!-- En-tête -->
<tr>
<td style="background:#6366f1;padding:32px 40px;text-align:center;">
${iconHtml}
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:700;
letter-spacing:-0.3px;">${name}</h1>
</td>
</tr>
<!-- Corps -->
<tr>
<td style="padding:36px 40px 8px;">
<h2 style="margin:0 0 16px;color:#111827;font-size:18px;font-weight:600;">
${title}
</h2>
<div style="color:#374151;font-size:15px;line-height:1.65;">
${body}
</div>
${ctaHtml}
</td>
</tr>
<!-- Pied de page -->
<tr>
<td style="padding:24px 40px 32px;border-top:1px solid #f3f4f6;
text-align:center;color:#9ca3af;font-size:12px;margin-top:16px;">
Cet email a été envoyé automatiquement par ${footerLink}.<br/>
Merci de ne pas y répondre directement.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/** 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 };
}