Telegram: los avisos se mandaban a todas las configuraciones activas sin mirar a dónde apuntan. Con el mismo chat cargado en dos filas, cada aviso llegaba dos veces. Ahora se manda una vez por destino (bot + chat); los chats distintos siguen recibiendo todos. Correo al remitente: el camino STARTTLS abría una conexión, hacía StartTLS, autenticaba… y la descartaba para llamar a smtp.SendMail, que abre otra. La primera quedaba colgada y el envío real salía por una conexión distinta, que podía no estar autenticada. Reescrito: una sola conexión, asegurada según la configuración, y cierre con QUIT. Y como el acuse se manda en segundo plano, su error moría en el log. Ahora hay un botón "Enviar correo de prueba" que usa exactamente el mismo camino y devuelve el error del servidor en pantalla, y el último fallo del acuse real aparece en el resultado de "Revisar buzón ahora". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
421 lines
20 KiB
HTML
421 lines
20 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 class="flex flex-wrap gap-2 mt-3 items-center">
|
|
<input x-model="emailPrueba" type="email" placeholder="tu@correo.com"
|
|
class="border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<button @click="probarEnvio()" :disabled="ocupado"
|
|
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
|
|
Enviar correo de prueba
|
|
</button>
|
|
<span x-show="mensajeEnvio" x-text="mensajeEnvio" class="text-sm"
|
|
:class="errorEnvio ? 'text-red-600' : 'text-green-600'"></span>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-1">
|
|
Sale por el mismo camino que el acuse automático que recibe el cliente cuando se le crea un ticket.
|
|
Si esta prueba falla, ese acuse tampoco está llegando.
|
|
</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="mt-2">
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Antigüedad máxima</label>
|
|
<select x-model.number="cfg.imap_horas_atras" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<option :value="6">Últimas 6 horas</option>
|
|
<option :value="12">Últimas 12 horas</option>
|
|
<option :value="24">Últimas 24 horas</option>
|
|
<option :value="72">Últimos 3 días</option>
|
|
<option :value="0">Todo lo que haya sin leer</option>
|
|
</select>
|
|
<p class="text-xs text-slate-400 mt-1">
|
|
Los correos más viejos que esto no se tocan, aunque estén sin leer. Es lo que evita que la
|
|
primera corrida convierta años de buzón en tickets.
|
|
</p>
|
|
</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).
|
|
<strong>Solo se filtra a los desconocidos</strong>: si el remitente es un cliente o un usuario del
|
|
portal, siempre abre ticket. Ante la duda también abre: es peor ignorar a alguien que tener un
|
|
ticket de más, y si la IA no responde el ticket se abre igual.
|
|
Lo descartado no abre ticket, pero el correo sigue en el buzón y "Revisar buzón ahora" te dice qué
|
|
dejó afuera y por qué.
|
|
</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>
|
|
|
|
<!-- ─── Borradores de respuesta ──────────────────────────────── -->
|
|
<div class="border-t border-slate-200 pt-5">
|
|
<p class="text-sm font-semibold text-slate-700 mb-1">Borradores de respuesta</p>
|
|
<p class="text-xs text-slate-400 mb-3">
|
|
En cada ticket aparece un botón <strong>✨ Borrador</strong> que propone una respuesta usando la base
|
|
de conocimiento del agente que elijas. <strong>Nunca la envía</strong>: cae en el cuadro de respuesta
|
|
para que la revises y la mandes vos. Sin agente elegido el borrador sale igual, pero solo con la
|
|
conversación del ticket.
|
|
</p>
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Agente de uMind</label>
|
|
<select x-model="cfg.agente_borrador_id" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
|
<option value="">Sin base de conocimiento</option>
|
|
<template x-for="a in agentes" :key="a.id">
|
|
<option :value="a.id" x-text="a.nombre"></option>
|
|
</template>
|
|
</select>
|
|
</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',
|
|
imap_horas_atras: 12,
|
|
clasificar_con_ia: false,
|
|
contexto_negocio: '',
|
|
agente_borrador_id: '',
|
|
},
|
|
admins: [],
|
|
agentes: [],
|
|
tieneImapPassword: false,
|
|
ocupado: false,
|
|
mensajeImap: '',
|
|
errorImap: false,
|
|
emailPrueba: '',
|
|
mensajeEnvio: '',
|
|
errorEnvio: 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 {}
|
|
try {
|
|
const r = await axios.get('/app/soporte/agentes');
|
|
this.agentes = 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',
|
|
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
|
|
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
|
|
contexto_negocio: this.cfg.contexto_negocio || '',
|
|
agente_borrador_id: this.cfg.agente_borrador_id ? parseInt(this.cfg.agente_borrador_id) : null,
|
|
};
|
|
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;
|
|
},
|
|
|
|
async probarEnvio() {
|
|
this.ocupado = true;
|
|
this.mensajeEnvio = '';
|
|
try {
|
|
const r = await axios.post('/app/soporte/webhook/probar-envio', { email: this.emailPrueba });
|
|
this.errorEnvio = false;
|
|
this.mensajeEnvio = r.data?.message || 'Enviado';
|
|
} catch (e) {
|
|
this.errorEnvio = true;
|
|
this.mensajeEnvio = 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>
|