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:
Lizandro Guarnizo
2026-08-23 20:08:46 -05:00
co-authored by Claude Opus 5
parent ffa24a068f
commit eddec45857
21 changed files with 987 additions and 143 deletions
+25 -4
View File
@@ -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'))
+72 -10
View File
@@ -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>
+130 -15
View File
@@ -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>