Files
soft_usite/orchestrator/src/views/agente/TabConexiones.vue
T
Lizandro GuarnizoandClaude Opus 5 eddec45857 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>
2026-08-23 20:08:46 -05:00

176 lines
6.7 KiB
Vue

<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'
const props = defineProps({
agenteId: { type: Number, required: true },
conexiones: { type: Array, default: () => [] },
})
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}`))
emit('recargar')
}
</script>
<template>
<div>
<p class="label mb-4">
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 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="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">
<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>
</div>
</div>
</div>
</template>