feat: boton de prueba real en Configuracion > Correo (SMTP)
Agrega POST /api/settings/smtp/test (superadmin, rate-limited) que envia un correo real usando los valores actuales del formulario y propaga el error real del servidor SMTP, a diferencia de sendMail() que nunca lanza. El panel de SMTP ahora tiene un campo de destinatario y boton "Enviar prueba" con el resultado real (exito o error), en vez de solo "Guardar" sin forma de verificar que la configuracion funcione. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '<p>Si estas leyendo esto, la configuracion SMTP de project.linkedcorp.net funciona correctamente.</p>',
|
||||
});
|
||||
} 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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
const canTest = form.host && form.user && testTo && !testing;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
|
||||
@@ -51,6 +69,22 @@ export default function SmtpSettingsPanel() {
|
||||
<input className="input" type="password" value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
|
||||
</Field>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
|
||||
<div className="panel-card" style={{ marginTop: 16 }}>
|
||||
<h4 style={{ marginTop: 0 }}>{t('smtp_test_title')}</h4>
|
||||
<div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('smtp_test_hint')}</div>
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_test_to')}>
|
||||
<input className="input" type="email" value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="tucorreo@linkedcorp.net" />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
{testResult && (
|
||||
<InfoBar tone={testResult.ok ? 'success' : 'error'}>{testResult.message}</InfoBar>
|
||||
)}
|
||||
<Button variant="standard" icon={Send} onClick={sendTest} disabled={!canTest}>
|
||||
{testing ? t('smtp_test_sending') : t('smtp_test_send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ import { api } from './api.js';
|
||||
|
||||
export const getSmtpConfig = () => api.get('/settings/smtp');
|
||||
export const updateSmtpConfig = (data) => api.put('/settings/smtp', data);
|
||||
export const testSmtpConfig = (data) => api.post('/settings/smtp/test', data);
|
||||
|
||||
Reference in New Issue
Block a user