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>
176 lines
6.7 KiB
Vue
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>
|