diff --git a/API.md b/API.md index bfd0469..4452705 100644 --- a/API.md +++ b/API.md @@ -85,6 +85,9 @@ ### Configuración SMTP (`/api/settings`) — solo Superadministrador - `GET /api/settings/smtp` — `{ host, port, user, from, configured }` (nunca la clave) - `PUT /api/settings/smtp` — `{ host, port, user, from, pass? }` (`pass` vacío = no cambiarla) +- `POST /api/settings/smtp/test` — `{ host, port, user, from, pass?, to }` (`pass` vacío = usa la + guardada). Envía un correo real con los valores del formulario (aunque no se hayan guardado) y + responde `{ sent: true }` o `400` con el error real del servidor SMTP. Rate-limited (10/15min). ## Errores esperados diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1c626..0cbb7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog - project.linkedcorp.net +## [1.3.0] - 2026-09-06 +### Added +- Configuración → Correo (SMTP): botón "Enviar prueba" con campo de destinatario. Ejecuta un envío + real (`POST /api/settings/smtp/test`, solo superadmin, rate-limited) usando los valores actuales + del formulario (aunque no se hayan guardado) y muestra el resultado real — éxito o el error tal + cual lo devuelve el servidor SMTP — a diferencia de `sendMail()` (correo de negocio), que nunca + lanza para no romper flujos como `forgot-password`. +### Fixed +- La pantalla de SMTP no tenía forma de verificar que la configuración guardada funcionara de + verdad; un error de host/certificado/credenciales solo se detectaba cuando fallaba un correo real + de negocio, sin ningún diagnóstico. Regla agregada a `SEGURIDAD-Y-CALIDAD.md` del orquestador: + toda config de servicio externo debe incluir una acción de prueba real. + ## [1.2.0] - 2026-09-06 ### Added - Cambiar clave desde la app: `PUT /api/auth/change-password` (exige la clave actual) + opción diff --git a/VERSION b/VERSION index 26aaba0..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/backend/src/modules/settings/controller.js b/backend/src/modules/settings/controller.js index 6d12853..de0372c 100644 --- a/backend/src/modules/settings/controller.js +++ b/backend/src/modules/settings/controller.js @@ -6,3 +6,7 @@ export const getSmtp = asyncHandler(async (req, res) => ok(res, await service.ge export const putSmtp = asyncHandler(async (req, res) => { ok(res, await service.updateSmtpConfig(req.body, req.currentUser.id)); }); + +export const postTestSmtp = asyncHandler(async (req, res) => { + ok(res, await service.testSmtpConfig(req.body, req.currentUser.id)); +}); diff --git a/backend/src/modules/settings/routes.js b/backend/src/modules/settings/routes.js index c119060..312d5e7 100644 --- a/backend/src/modules/settings/routes.js +++ b/backend/src/modules/settings/routes.js @@ -1,11 +1,16 @@ import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { requireAuth, requireRole, CAN_MANAGE_SMTP } from '../../middleware/auth.js'; -import { getSmtp, putSmtp } from './controller.js'; +import { getSmtp, putSmtp, postTestSmtp } from './controller.js'; const router = Router(); +// Evita abusar del relay SMTP configurado para mandar correo masivo por esta via de diagnostico. +const testLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); + router.use(requireAuth, requireRole(...CAN_MANAGE_SMTP)); router.get('/smtp', getSmtp); router.put('/smtp', putSmtp); +router.post('/smtp/test', testLimiter, postTestSmtp); export default router; diff --git a/backend/src/modules/settings/service.js b/backend/src/modules/settings/service.js index 87d1656..f909f8a 100644 --- a/backend/src/modules/settings/service.js +++ b/backend/src/modules/settings/service.js @@ -1,3 +1,4 @@ +import nodemailer from 'nodemailer'; import * as repo from './repository.js'; import { encrypt, decrypt } from '../../utils/crypto.js'; import { logAudit } from '../../utils/audit.js'; @@ -42,3 +43,43 @@ export async function updateSmtpConfig(payload, actorId) { await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null }); return getSmtpConfig(); } + +// Envia un correo de prueba con los valores ACTUALES del formulario (guardados o no), para poder +// verificar la configuracion antes de confiar en ella. A diferencia de utils/mailer.js (que nunca +// lanza, para no romper registro/recuperacion), esta funcion SI propaga el error real del SMTP +// (credenciales invalidas, host inalcanzable, certificado vencido, etc.) — es una accion de +// diagnostico explicita del superadmin, no un envio "de negocio". +export async function testSmtpConfig(payload, actorId) { + const { to } = payload; + if (!to) throw new HttpError(400, 'Indica un correo destino para la prueba'); + if (!payload.host || !payload.user) throw new HttpError(400, 'Host y usuario son obligatorios para probar'); + + let pass = payload.pass; + if (!pass) { + const stored = await repo.getAll(['smtp_pass_enc']); + if (!stored.smtp_pass_enc) throw new HttpError(400, 'No hay una clave guardada ni escrita para probar'); + pass = decrypt(stored.smtp_pass_enc); + } + + const transporter = nodemailer.createTransport({ + host: payload.host, + port: Number(payload.port || 587), + secure: false, + requireTLS: true, + auth: { user: payload.user, pass }, + }); + + try { + await transporter.sendMail({ + from: payload.from || payload.user, + to, + subject: 'Correo de prueba — project.linkedcorp.net', + html: '
Si estas leyendo esto, la configuracion SMTP de project.linkedcorp.net funciona correctamente.
', + }); + } catch (err) { + throw new HttpError(400, `No se pudo enviar el correo de prueba: ${err.message}`); + } + + await logAudit({ userId: actorId, action: 'test', entityType: 'settings:smtp', entityId: null, detail: { to } }); + return { sent: true }; +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 19aa996..8f48c02 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -53,6 +53,12 @@ export const messages = { smtp_pass: 'Clave', smtp_pass_configured_hint: 'Configurada — dejar en blanco para no cambiarla', smtp_not_configured: 'Sin configurar: los correos se registran en el log del servidor (modo dev), no se envian de verdad.', + smtp_test_title: 'Probar configuracion', + smtp_test_hint: 'Envia un correo de verdad con los datos de arriba (guardados o no) para confirmar que funcionan antes de confiar en ellos.', + smtp_test_to: 'Enviar prueba a', + smtp_test_send: 'Enviar prueba', + smtp_test_sending: 'Enviando...', + smtp_test_success: 'Correo de prueba enviado correctamente a', common_new: 'Nuevo', common_edit: 'Editar', common_save: 'Guardar', @@ -230,6 +236,12 @@ export const messages = { smtp_pass: 'Password', smtp_pass_configured_hint: 'Configured — leave blank to keep it unchanged', smtp_not_configured: 'Not configured: emails are logged on the server (dev mode), not actually sent.', + smtp_test_title: 'Test configuration', + smtp_test_hint: 'Sends a real email with the settings above (saved or not) so you can confirm they work before trusting them.', + smtp_test_to: 'Send test to', + smtp_test_send: 'Send test', + smtp_test_sending: 'Sending...', + smtp_test_success: 'Test email sent successfully to', common_new: 'New', common_edit: 'Edit', common_save: 'Save', diff --git a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx index b86b524..2f6d2f1 100644 --- a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx +++ b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; -import { Save } from 'lucide-react'; +import { Save, Send } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; -import { getSmtpConfig, updateSmtpConfig } from '../../services/settings.js'; +import { getSmtpConfig, updateSmtpConfig, testSmtpConfig } from '../../services/settings.js'; import { Field, FieldRow } from '../../components/Field.jsx'; import { Button } from '../../components/Button.jsx'; import { InfoBar } from '../../components/InfoBar.jsx'; @@ -12,6 +12,9 @@ export default function SmtpSettingsPanel() { const runAction = useAction(); const { data: config, reload } = useAsync(getSmtpConfig, []); const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' }); + const [testTo, setTestTo] = useState(''); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); // { ok: bool, message: string } useEffect(() => { if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' }); @@ -26,8 +29,23 @@ export default function SmtpSettingsPanel() { }); } + async function sendTest() { + setTestResult(null); + setTesting(true); + try { + await testSmtpConfig({ ...form, to: testTo }); + setTestResult({ ok: true, message: `${t('smtp_test_success')} ${testTo}` }); + } catch (err) { + setTestResult({ ok: false, message: err.message }); + } finally { + setTesting(false); + } + } + if (!config) return