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
@@ -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