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>
345 lines
16 KiB
HTML
345 lines
16 KiB
HTML
<div x-data="soporteWebhook()" x-init="init()" class="p-6 max-w-2xl">
|
|
<h1 class="text-xl font-bold text-slate-800 mb-1">Webhook de correo entrante</h1>
|
|
<p class="text-sm text-slate-500 mb-6">Configura la integración para recibir correos de soporte@u-s.app y convertirlos automáticamente en tickets.</p>
|
|
|
|
<div class="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1">Proveedor</label>
|
|
<select x-model="cfg.provider" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<option value="sendgrid">SendGrid</option>
|
|
<option value="mailgun">Mailgun</option>
|
|
<option value="generic">Genérico</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1">Email destino</label>
|
|
<input x-model="cfg.email_destino" type="text" placeholder="soporte@u-s.app"
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<p class="text-xs text-slate-400 mt-1">Correo al que llegarán los mensajes. Ej: soporte@u-s.app</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1">API Key / Secreto</label>
|
|
<div class="flex gap-2">
|
|
<input x-model="cfg.api_key" type="text" placeholder="requerido para validar el webhook"
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<button type="button" @click="cfg.api_key = generarClave()"
|
|
class="px-3 py-2 rounded-lg text-xs font-medium border border-slate-200 text-slate-600 hover:bg-slate-50 whitespace-nowrap">
|
|
Generar
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-1">Obligatoria: las peticiones sin esta clave (como <code>?key=...</code>) son rechazadas. Configúrala también en el proveedor si soporta enviarla como parámetro/header.</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<input x-model="cfg.responder_auto" type="checkbox" id="resp-auto"
|
|
class="rounded border-slate-300 text-[#8eb02f] focus:ring-[#8eb02f]">
|
|
<label for="resp-auto" class="text-sm text-slate-700">Responder automáticamente con acuse de recibo</label>
|
|
</div>
|
|
|
|
<div x-show="cfg.responder_auto">
|
|
<label class="block text-sm font-medium text-slate-700 mb-1">Mensaje de auto-respuesta</label>
|
|
<textarea x-model="cfg.mensaje_auto" rows="3" placeholder="Hemos recibido tu solicitud y te responderemos a la brevedad."
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none"></textarea>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 mb-1">Auto-asignar a</label>
|
|
<select x-model="cfg.asignar_a" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<option value="">Sin asignar</option>
|
|
<template x-for="a in admins" :key="a.id">
|
|
<option :value="a.id" x-text="a.name"></option>
|
|
</template>
|
|
</select>
|
|
<p class="text-xs text-slate-400 mt-1">Los tickets creados por email se asignarán automáticamente a este admin.</p>
|
|
</div>
|
|
|
|
<!-- ─── SMTP salida ──────────────────────────────────────────── -->
|
|
<div class="border-t border-slate-100 pt-4 mt-4">
|
|
<p class="text-sm font-semibold text-slate-700 mb-3">SMTP salida (notificaciones y auto-respuesta)</p>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="col-span-1">
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Host</label>
|
|
<input x-model="cfg.smtp_host" type="text" placeholder="smtp.example.com"
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
</div>
|
|
<div class="col-span-1">
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Puerto</label>
|
|
<input x-model="cfg.smtp_port" type="number" placeholder="587"
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid 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.smtp_username" type="text" placeholder="correo@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">Contraseña</label>
|
|
<input x-model="cfg.smtp_password" type="password" placeholder="••••••••"
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-3 mt-2">
|
|
<div>
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Email remitente</label>
|
|
<input x-model="cfg.smtp_from_addr" 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">Nombre remitente</label>
|
|
<input x-model="cfg.smtp_from_name" type="text" placeholder="Soporte U-site"
|
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Encriptación</label>
|
|
<select x-model="cfg.smtp_encryption" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<option value="tls">TLS (puerto 465)</option>
|
|
<option value="starttls">STARTTLS (puerto 587)</option>
|
|
<option value="none">Sin encriptación</option>
|
|
</select>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<!-- ─── 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"
|
|
style="background:#8eb02f"
|
|
onmouseover="this.style.background='#6d8c24'" onmouseout="this.style.background='#8eb02f'">
|
|
Guardar configuración
|
|
</button>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
|
|
<p class="text-xs font-medium text-slate-600 mb-1">URL del webhook</p>
|
|
<p class="text-sm text-slate-800 font-mono break-all" x-text="webhookUrl()"></p>
|
|
<p class="text-xs text-slate-400 mt-1">Configura esta URL exacta (con la clave incluida) en el proveedor de correo para enviar los emails entrantes.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function soporteWebhook() {
|
|
return {
|
|
cfg: {
|
|
id: 0,
|
|
nombre: 'Soporte',
|
|
provider: 'sendgrid',
|
|
email_destino: 'soporte@u-s.app',
|
|
api_key: '',
|
|
responder_auto: true,
|
|
mensaje_auto: '',
|
|
asignar_a: '',
|
|
smtp_host: '',
|
|
smtp_port: 587,
|
|
smtp_username: '',
|
|
smtp_password: '',
|
|
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',
|
|
clasificar_con_ia: false,
|
|
contexto_negocio: '',
|
|
},
|
|
admins: [],
|
|
tieneImapPassword: false,
|
|
ocupado: false,
|
|
mensajeImap: '',
|
|
errorImap: false,
|
|
|
|
async init() {
|
|
try {
|
|
const r = await axios.get('/app/soporte/webhook/data');
|
|
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 {
|
|
const r = await axios.get('/app/tickets/admins');
|
|
this.admins = r.data || [];
|
|
} catch {}
|
|
if (!this.cfg.api_key) {
|
|
this.cfg.api_key = this.generarClave();
|
|
}
|
|
},
|
|
|
|
generarClave() {
|
|
if (window.crypto && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, '');
|
|
return Array.from(crypto.getRandomValues(new Uint8Array(24))).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
},
|
|
|
|
webhookUrl() {
|
|
const base = window.location.origin + '/webhooks/soporte/' + this.cfg.provider;
|
|
return this.cfg.api_key ? `${base}?key=${this.cfg.api_key}` : base;
|
|
},
|
|
|
|
async guardar() {
|
|
const payload = {
|
|
id: this.cfg.id || 0,
|
|
nombre: this.cfg.nombre || 'Soporte',
|
|
provider: this.cfg.provider,
|
|
email_destino: this.cfg.email_destino,
|
|
api_key: this.cfg.api_key,
|
|
responder_auto: this.cfg.responder_auto,
|
|
mensaje_auto: this.cfg.mensaje_auto,
|
|
asignar_a: this.cfg.asignar_a ? parseInt(this.cfg.asignar_a) : null,
|
|
smtp_host: this.cfg.smtp_host || '',
|
|
smtp_port: parseInt(this.cfg.smtp_port) || 587,
|
|
smtp_username: this.cfg.smtp_username || '',
|
|
smtp_password: this.cfg.smtp_password || '',
|
|
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',
|
|
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
|
|
contexto_negocio: this.cfg.contexto_negocio || '',
|
|
};
|
|
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>
|