feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets

Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el
correo y decide si es soporte o ruido —newsletters, notificaciones de
plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está
configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que
tener un ticket de más. Hay un campo de contexto del negocio para los casos
raros de cada uno.

Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe,
Precedence, Auto-Submitted) sin gastar una llamada de IA.

Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la
deduplicación era solo un SELECT previo, que no sirve si el flag de leído no
llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un
índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo
se trata como "ya estaba", no como error — sin ticket repetido y sin segunda
auto-respuesta.

Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en
vez de dejarlo solo en el log: es la señal de que van a volver a leerse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 19:36:01 -05:00
co-authored by Claude Opus 5
parent 5875f9e36b
commit d7c266f111
9 changed files with 306 additions and 27 deletions
+26
View File
@@ -180,6 +180,28 @@
<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>
<!-- ─── Filtro con IA ────────────────────────────────────────── -->
<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">Filtrar con IA</p>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" x-model="cfg.clasificar_con_ia" class="rounded"> Detectar si el correo es de soporte
</label>
</div>
<p class="text-xs text-slate-400 mb-3">
Antes de abrir un ticket, la IA lee el correo y decide si es una solicitud de soporte o ruido
(newsletters, notificaciones automáticas, facturas de proveedores, spam). Lo que descarta no abre
ticket, pero el correo sigue en el buzón y queda registrado en el log.
Ante la duda abre el ticket: es peor ignorar a un cliente que tener uno de más.
Si la IA no responde, el ticket se abre igual.
</p>
<label class="block text-xs font-medium text-slate-600 mb-1">Contexto del negocio (opcional)</label>
<textarea x-model="cfg.contexto_negocio" rows="3"
placeholder="Ej: Somos una agencia de software. Nuestros clientes escriben por errores de sus sitios y facturación. Los correos de nuestros proveedores de hosting no son soporte."
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none"></textarea>
<p class="text-xs text-slate-400 mt-1">Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.</p>
</div>
<div class="pt-4">
<button @click="guardar()"
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
@@ -223,6 +245,8 @@ function soporteWebhook() {
imap_password: '',
imap_encryption: 'ssl',
imap_carpeta: 'INBOX',
clasificar_con_ia: false,
contexto_negocio: '',
},
admins: [],
tieneImapPassword: false,
@@ -286,6 +310,8 @@ function soporteWebhook() {
imap_password: this.cfg.imap_password || '',
imap_encryption: this.cfg.imap_encryption || 'ssl',
imap_carpeta: this.cfg.imap_carpeta || 'INBOX',
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
contexto_negocio: this.cfg.contexto_negocio || '',
};
try {
await axios.post('/app/soporte/webhook', payload);