feat(soporte): leer el buzón por IMAP, no solo esperar el webhook

Los clientes escriben a soporte@ desde su correo de siempre. Hasta ahora eso
solo llegaba si un proveedor (SendGrid/Mailgun) nos hacía POST; si nadie lo
configuraba, los correos quedaban sin leer en el buzón.

Ahora el cron entra al buzón cada 2 minutos, baja los no leídos, abre ticket
(o los engancha al hilo si son respuesta) y los marca como leídos. La lógica
de ingesta se movió a services para que webhook e IMAP se comporten igual.

La contraseña del buzón se guarda cifrada (AES-GCM con APP_KEY) y no vuelve
al navegador.

De paso, dos bugs que impedían guardar la configuración: el formulario mandaba
id=0 (gorm.Model serializa "ID"), así que cada guardado creaba una fila nueva
en vez de editar la que se usa; y Updates con struct ignoraba los booleanos en
false, así que desactivar algo no tenía efecto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 18:36:08 -05:00
co-authored by Claude Opus 5
parent 1ebae791f6
commit 8c3ab79e88
11 changed files with 732 additions and 155 deletions
+112
View File
@@ -110,6 +110,76 @@
<p class="text-xs text-slate-400 mt-2">Si se deja vacío, se usará la configuración SMTP general del sistema.</p>
</div>
<!-- ─── IMAP entrante ────────────────────────────────────────── -->
<div class="border-t border-slate-200 pt-5">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-semibold text-slate-700">Buzón entrante (IMAP)</p>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" x-model="cfg.imap_activo" class="rounded"> Leer el buzón cada 2 minutos
</label>
</div>
<p class="text-xs text-slate-400 mb-3">
Alternativa al webhook: en vez de esperar que el proveedor nos avise, entramos al buzón y bajamos
los correos sin leer. Cada uno abre un ticket (o se agrega al hilo si es respuesta) y queda marcado como leído.
</p>
<div class="grid md:grid-cols-3 gap-3">
<div class="md:col-span-2">
<label class="block text-xs font-medium text-slate-600 mb-1">Servidor IMAP</label>
<input x-model="cfg.imap_host" type="text" placeholder="imap.example.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Puerto</label>
<input x-model="cfg.imap_port" type="number" placeholder="993"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="grid md:grid-cols-2 gap-3 mt-2">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Usuario</label>
<input x-model="cfg.imap_username" type="text" placeholder="soporte@u-s.app"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Contraseña</label>
<input x-model="cfg.imap_password" type="password"
:placeholder="tieneImapPassword ? 'Guardada — escribí una nueva para cambiarla' : '••••••••'"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="grid md:grid-cols-2 gap-3 mt-2">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Encriptación</label>
<select x-model="cfg.imap_encryption" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<option value="ssl">SSL/TLS (puerto 993)</option>
<option value="starttls">STARTTLS (puerto 143)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Carpeta</label>
<input x-model="cfg.imap_carpeta" type="text" placeholder="INBOX"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<button @click="probarImap()" :disabled="ocupado"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
Probar conexión
</button>
<button @click="revisarAhora()" :disabled="ocupado"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
Revisar buzón ahora
</button>
<span x-show="mensajeImap" x-text="mensajeImap" class="text-sm self-center"
:class="errorImap ? 'text-red-600' : 'text-green-600'"></span>
</div>
<p class="text-xs text-slate-400 mt-2">Probá la conexión después de guardar: la contraseña se cifra al guardarse.</p>
</div>
<div class="pt-4">
<button @click="guardar()"
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
@@ -146,8 +216,19 @@ function soporteWebhook() {
smtp_encryption: 'starttls',
smtp_from_addr: '',
smtp_from_name: '',
imap_activo: false,
imap_host: '',
imap_port: 993,
imap_username: '',
imap_password: '',
imap_encryption: 'ssl',
imap_carpeta: 'INBOX',
},
admins: [],
tieneImapPassword: false,
ocupado: false,
mensajeImap: '',
errorImap: false,
async init() {
try {
@@ -155,6 +236,11 @@ function soporteWebhook() {
if (r.data && r.data.data) {
this.cfg = { ...this.cfg, ...r.data.data };
this.cfg.asignar_a = r.data.data.asignar_a || '';
// gorm.Model serializa la clave primaria como "ID"; sin esto el
// guardado creaba una fila nueva cada vez y los cambios no se veían.
this.cfg.id = r.data.data.ID || r.data.data.id || 0;
this.cfg.imap_password = '';
this.tieneImapPassword = !!r.data.data.tiene_imap_password;
}
} catch {}
try {
@@ -193,14 +279,40 @@ function soporteWebhook() {
smtp_encryption: this.cfg.smtp_encryption || 'starttls',
smtp_from_addr: this.cfg.smtp_from_addr || '',
smtp_from_name: this.cfg.smtp_from_name || '',
imap_activo: !!this.cfg.imap_activo,
imap_host: this.cfg.imap_host || '',
imap_port: parseInt(this.cfg.imap_port) || 993,
imap_username: this.cfg.imap_username || '',
imap_password: this.cfg.imap_password || '',
imap_encryption: this.cfg.imap_encryption || 'ssl',
imap_carpeta: this.cfg.imap_carpeta || 'INBOX',
};
try {
await axios.post('/app/soporte/webhook', payload);
if (payload.imap_password) { this.tieneImapPassword = true; this.cfg.imap_password = ''; }
await this.init();
alert('Configuración guardada');
} catch (e) {
alert('Error al guardar: ' + (e.response?.data?.error || e.message));
}
},
async llamarImap(url, okPorDefecto) {
this.ocupado = true;
this.mensajeImap = '';
try {
const r = await axios.post(url);
this.errorImap = false;
this.mensajeImap = r.data?.message || okPorDefecto;
} catch (e) {
this.errorImap = true;
this.mensajeImap = e.response?.data?.error || e.message;
}
this.ocupado = false;
},
probarImap() { return this.llamarImap('/app/soporte/webhook/probar-imap', 'Conexión correcta'); },
revisarAhora() { return this.llamarImap('/app/soporte/webhook/revisar-buzon', 'Buzón revisado'); },
};
}
</script>