feat(umind): el correo entra al agente por dos puertas distintas — y son distintas a propósito
En "Dónde atiende", el canal correo: el agente lee una casilla y responde solo los correos que llegan, con su base de conocimiento, igual que atiende WhatsApp. Como el correo no tiene webhook, se revisa por intervalo — y el intervalo lo elige el cliente por canal (2 a 60 minutos): una inmobiliaria quiere 2, a un estudio contable con 30 le sobra. El cron corre cada minuto pero cada casilla se revisa solo cuando le toca, con un pool de 8 para que 100 casillas no salgan a la red en el mismo instante. Las guardas que separan "asistente" de "incidente", cada una con su test: nunca responde correo automático ni se responde a sí mismo (el bucle con otro autoresponder); la revisión se marca ANTES de conectar, así una contraseña cambiada no martilla el login cada minuto hasta que el host del cliente nos bloquea; y el correo se marca leído recién cuando la respuesta salió — si el envío falla, queda sin leer y se reintenta. En "Lo que sabe", las cuentas de correo: casillas que el agente consulta a pedido — "revisame los correos de hoy y haceme un resumen" — sin nada de fondo. Solo lectura en serio: Peek, INBOX en read-only, y un test que falla si alguien le agrega un marcado. Ahora se pueden conectar varias por agente; con más de una, el modelo pregunta cuál en vez de adivinar — resumirle a alguien la casilla que no pidió no es un error menor. El alta pide correo y contraseña: el host se deduce (mail.<dominio>) y el campo técnico aparece recién si eso falla. Se prueba la conexión antes de guardar, con la persona mirando. Enviar por una cuenta conectada está bloqueado a propósito: para responder correos está el canal, con sus guardas. Una tool de envío sin límites es una máquina de spam con el dominio del cliente. La navegación acompaña: "Lo que sabe" agrupa Información, Cuentas de correo y Herramientas — tres formas de saber, no tres pantallas sueltas — y Avanzado queda solo con Problemas. El SMTP se unificó en una sola implementación que comparten soporte y el canal: el bug de STARTTLS que abría dos conexiones ya se pagó una vez. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ffa24a068f
commit
eddec45857
@@ -271,12 +271,19 @@ const ZONAS = [
|
||||
['canales', 'Dónde atiende'],
|
||||
]
|
||||
|
||||
const AVANZADO = [
|
||||
// "Lo que sabe" agrupa todo lo que alimenta las respuestas: la información
|
||||
// cargada, las casillas que puede consultar y las herramientas que puede
|
||||
// llamar. Son tres formas de saber, no tres pantallas sueltas.
|
||||
const SUBSECCIONES_SABE = [
|
||||
['conocimiento', 'Información'],
|
||||
['conexiones', 'Cuentas de correo'],
|
||||
['herramientas', 'Herramientas'],
|
||||
['conexiones', 'Cuentas conectadas'],
|
||||
['auditoria', 'Problemas'],
|
||||
]
|
||||
|
||||
const enLoQueSabe = computed(() => SUBSECCIONES_SABE.some(([k]) => k === tab.value))
|
||||
|
||||
const AVANZADO = [['auditoria', 'Problemas']]
|
||||
|
||||
// La guía se muestra hasta que sabe algo Y atiende en algún lado. Probar es
|
||||
// deseable pero no bloquea: nadie queda preso de la guía por no haber chateado.
|
||||
const configurando = computed(
|
||||
@@ -368,7 +375,7 @@ watch(
|
||||
v-for="[key, label] in ZONAS"
|
||||
:key="key"
|
||||
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
|
||||
:class="tab === key
|
||||
:class="(key === 'conocimiento' ? enLoQueSabe : tab === key)
|
||||
? 'bg-brand text-white font-medium'
|
||||
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-borde hover:border-brand/50'"
|
||||
@click="tab = key"
|
||||
@@ -387,6 +394,20 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="enLoQueSabe" class="flex gap-1.5 mb-6 flex-wrap -mt-3">
|
||||
<button
|
||||
v-for="[key, label] in SUBSECCIONES_SABE"
|
||||
:key="key"
|
||||
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
|
||||
:class="tab === key
|
||||
? 'bg-brand/15 text-brand font-medium border border-brand/40'
|
||||
: 'text-tenue border border-transparent hover:text-texto'"
|
||||
@click="tab = key"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mostrarAvanzado" class="flex gap-1.5 mb-6 flex-wrap">
|
||||
<button
|
||||
v-for="[key, label] in AVANZADO"
|
||||
|
||||
@@ -20,7 +20,7 @@ const canalesActivos = computed(() => props.canales.filter((c) => c.activo))
|
||||
|
||||
// Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
|
||||
// como un error de tipeo.
|
||||
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web' }
|
||||
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web', correo: 'tu correo' }
|
||||
const nombreCanal = (t) => NOMBRES_CANAL[t] || t
|
||||
|
||||
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
|
||||
|
||||
@@ -37,6 +37,8 @@ async function copiarWidget() {
|
||||
function canalVacio() {
|
||||
return {
|
||||
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
|
||||
usuario: '', password: '', imap_host: '', smtp_host: '', imap_encriptado: 'ssl', smtp_encriptado: 'starttls',
|
||||
intervalo_minutos: 5,
|
||||
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||
}
|
||||
}
|
||||
@@ -47,18 +49,33 @@ function nuevoCanal() {
|
||||
}
|
||||
|
||||
async function guardarCanal() {
|
||||
const credenciales =
|
||||
canalForm.value.tipo === 'telegram'
|
||||
? { bot_token: canalForm.value.bot_token }
|
||||
: {
|
||||
phone_number_id: canalForm.value.phone_number_id,
|
||||
access_token: canalForm.value.access_token,
|
||||
app_secret: canalForm.value.app_secret,
|
||||
verify_token: canalForm.value.verify_token,
|
||||
}
|
||||
let credenciales
|
||||
if (canalForm.value.tipo === 'telegram') {
|
||||
credenciales = { bot_token: canalForm.value.bot_token }
|
||||
} else if (canalForm.value.tipo === 'correo') {
|
||||
// El host se deduce del dominio si el cliente no lo escribió: en hosting
|
||||
// común mail.<dominio> acierta casi siempre.
|
||||
const dominio = String(canalForm.value.usuario).split('@')[1] || ''
|
||||
credenciales = {
|
||||
usuario: canalForm.value.usuario,
|
||||
password: canalForm.value.password,
|
||||
imap_host: canalForm.value.imap_host || `mail.${dominio}`,
|
||||
smtp_host: canalForm.value.smtp_host || `mail.${dominio}`,
|
||||
imap_encriptado: canalForm.value.imap_encriptado,
|
||||
smtp_encriptado: canalForm.value.smtp_encriptado,
|
||||
}
|
||||
} else {
|
||||
credenciales = {
|
||||
phone_number_id: canalForm.value.phone_number_id,
|
||||
access_token: canalForm.value.access_token,
|
||||
app_secret: canalForm.value.app_secret,
|
||||
verify_token: canalForm.value.verify_token,
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.post(apiUmind('/umind/canales'), {
|
||||
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
|
||||
intervalo_minutos: Number(canalForm.value.intervalo_minutos) || 5,
|
||||
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
|
||||
usar_archivos_docs: canalForm.value.usar_archivos_docs,
|
||||
})
|
||||
@@ -160,7 +177,11 @@ async function eliminarCanal(c) {
|
||||
Leer archivos adjuntos (PDF, Word, texto)
|
||||
</label>
|
||||
</div>
|
||||
<p class="label mt-1 break-all">
|
||||
<!-- Un canal de correo no tiene webhook: se revisa por intervalo. -->
|
||||
<p v-if="c.tipo === 'correo'" class="label mt-1">
|
||||
Revisa la casilla cada {{ c.intervalo_minutos >= 60 ? '1 hora' : `${c.intervalo_minutos} minutos` }}
|
||||
</p>
|
||||
<p v-else class="label mt-1 break-all">
|
||||
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
|
||||
</p>
|
||||
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue mt-1">
|
||||
@@ -179,6 +200,7 @@ async function eliminarCanal(c) {
|
||||
<select v-model="canalForm.tipo" class="input">
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp Business</option>
|
||||
<option value="correo">Correo (tu casilla)</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="canalForm.tipo === 'telegram'">
|
||||
@@ -187,6 +209,46 @@ async function eliminarCanal(c) {
|
||||
<input v-model="canalForm.bot_token" type="password" required class="input" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="canalForm.tipo === 'correo'">
|
||||
<p class="text-xs text-tenue">
|
||||
El agente va a leer esta casilla y <strong>responder solo</strong> los correos que lleguen,
|
||||
con su base de conocimiento. Ignora newsletters y correos automáticos.
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Correo</label>
|
||||
<input v-model="canalForm.usuario" type="email" required placeholder="ventas@tunegocio.com" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contraseña</label>
|
||||
<input v-model="canalForm.password" type="password" autocomplete="new-password" required class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Revisar la casilla cada</label>
|
||||
<select v-model="canalForm.intervalo_minutos" class="input">
|
||||
<option :value="2">2 minutos — respuesta casi inmediata</option>
|
||||
<option :value="5">5 minutos</option>
|
||||
<option :value="15">15 minutos</option>
|
||||
<option :value="30">30 minutos</option>
|
||||
<option :value="60">1 hora</option>
|
||||
</select>
|
||||
<p class="text-xs text-tenue mt-1">Un correo no es un chat: para la mayoría, 5 o 15 minutos sobra.</p>
|
||||
</div>
|
||||
<details class="text-xs text-tenue">
|
||||
<summary class="cursor-pointer">Servidores (solo si la detección automática falla)</summary>
|
||||
<div class="grid sm:grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label class="label">Servidor IMAP</label>
|
||||
<input v-model="canalForm.imap_host" placeholder="mail.tunegocio.com" class="input font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Servidor SMTP</label>
|
||||
<input v-model="canalForm.smtp_host" placeholder="mail.tunegocio.com" class="input font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="label">Phone Number ID</label>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../../lib/api.js'
|
||||
import { apiUmind } from '../../lib/contexto.js'
|
||||
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||
@@ -8,16 +9,67 @@ const props = defineProps({
|
||||
conexiones: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
// El padre es el dueño de los datos del agente: acá se avisa que cambiaron y
|
||||
// él recarga. Si cada pestaña mantuviera su propia copia, volver de una a otra
|
||||
// mostraría estados distintos de lo mismo.
|
||||
const emit = defineEmits(['recargar'])
|
||||
|
||||
// Alta de una casilla IMAP. Solo correo y contraseña: el host se deduce
|
||||
// (mail.<dominio>) y el campo técnico aparece recién si eso falla.
|
||||
const showForm = ref(false)
|
||||
const guardando = ref(false)
|
||||
const error = ref('')
|
||||
const modoManual = ref(false)
|
||||
const form = ref({ email: '', password: '', host: '', puerto: 993, encriptado: 'ssl' })
|
||||
|
||||
const probando = ref(null)
|
||||
const resultado = ref({})
|
||||
|
||||
function nombreProveedor(p) {
|
||||
return { google: 'Google', microsoft: 'Outlook', imap: 'Casilla propia' }[p] || p
|
||||
}
|
||||
|
||||
function conectar(proveedor) {
|
||||
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
|
||||
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`)
|
||||
}
|
||||
|
||||
async function guardarImap() {
|
||||
guardando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind('/umind/conexiones/imap'), {
|
||||
agente_id: props.agenteId,
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
host: modoManual.value ? form.value.host : '',
|
||||
puerto: modoManual.value ? Number(form.value.puerto) : 0,
|
||||
encriptado: form.value.encriptado,
|
||||
})
|
||||
showForm.value = false
|
||||
form.value = { email: '', password: '', host: '', puerto: 993, encriptado: 'ssl' }
|
||||
modoManual.value = false
|
||||
emit('recargar')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
// Si la autodetección no encontró el servidor, se abre el modo manual:
|
||||
// es el único caso donde el campo técnico se gana su lugar.
|
||||
if (!modoManual.value && /conexión|conectar|resolver/i.test(e.message)) modoManual.value = true
|
||||
} finally {
|
||||
guardando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function probar(c) {
|
||||
probando.value = c.ID
|
||||
resultado.value = { ...resultado.value, [c.ID]: null }
|
||||
try {
|
||||
const r = await api.post(apiUmind(`/umind/conexiones/${c.ID}/probar`), {})
|
||||
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.message } }
|
||||
} catch (e) {
|
||||
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
|
||||
} finally {
|
||||
probando.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function desconectar(c) {
|
||||
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
|
||||
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
|
||||
@@ -28,32 +80,95 @@ async function desconectar(c) {
|
||||
<template>
|
||||
<div>
|
||||
<p class="label mb-4">
|
||||
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
|
||||
Se soporta una cuenta activa a la vez.
|
||||
Las casillas que el agente puede consultar cuando se lo pedís — por ejemplo:
|
||||
<em>«revisame los correos de hoy y haceme un resumen»</em>. Solo lectura: nunca marca
|
||||
nada como leído ni manda nada. Podés conectar varias.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-4">
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
|
||||
Conectar Google
|
||||
</button>
|
||||
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
|
||||
Conectar Outlook
|
||||
</button>
|
||||
<button class="btn-primary" @click="showForm = !showForm">
|
||||
+ Casilla propia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Casilla de hosting / cPanel / Zoho: correo y contraseña, nada más. -->
|
||||
<form v-if="showForm" class="card p-4 mb-4 space-y-3" @submit.prevent="guardarImap">
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Correo</label>
|
||||
<input v-model="form.email" type="email" required placeholder="ventas@tunegocio.com" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contraseña</label>
|
||||
<input v-model="form.password" type="password" autocomplete="new-password" required class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="modoManual" class="grid sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="label">Servidor IMAP</label>
|
||||
<input v-model="form.host" placeholder="mail.tunegocio.com" class="input font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Puerto</label>
|
||||
<input v-model="form.puerto" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Encriptación</label>
|
||||
<select v-model="form.encriptado" class="input">
|
||||
<option value="ssl">SSL (993)</option>
|
||||
<option value="starttls">STARTTLS (143)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">
|
||||
Se prueba la conexión antes de guardar. Gmail y Outlook van por sus botones de arriba.
|
||||
</p>
|
||||
<button type="submit" class="btn-primary" :disabled="guardando">
|
||||
{{ guardando ? 'Probando…' : 'Conectar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card divide-y divide-borde">
|
||||
<UiEmptyState
|
||||
v-if="conexiones.length === 0"
|
||||
titulo="Sin cuentas conectadas"
|
||||
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
|
||||
detalle="Conectá una casilla y vas a poder pedirle al agente que te revise el correo y te lo resuma, sin abrir el webmail."
|
||||
/>
|
||||
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span>
|
||||
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||
{{ c.activo ? 'activa' : 'inactiva' }}
|
||||
</span>
|
||||
<div v-for="c in conexiones" :key="c.ID" class="p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-texto">{{ c.email }}</span>
|
||||
<span class="ml-2 text-xs text-tenue">{{ nombreProveedor(c.proveedor) }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||
{{ c.activo ? 'activa' : 'inactiva' }}
|
||||
</span>
|
||||
<p
|
||||
v-if="resultado[c.ID]"
|
||||
class="text-xs mt-1"
|
||||
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
{{ resultado[c.ID].texto }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||
<button
|
||||
v-if="c.proveedor === 'imap'"
|
||||
class="text-brand hover:underline disabled:opacity-40"
|
||||
:disabled="probando === c.ID"
|
||||
@click="probar(c)"
|
||||
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="desconectar(c)">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user