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'], ['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'], ['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 // 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. // deseable pero no bloquea: nadie queda preso de la guía por no haber chateado.
const configurando = computed( const configurando = computed(
@@ -368,7 +375,7 @@ watch(
v-for="[key, label] in ZONAS" v-for="[key, label] in ZONAS"
:key="key" :key="key"
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors" 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-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'" : 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-borde hover:border-brand/50'"
@click="tab = key" @click="tab = key"
@@ -387,6 +394,20 @@ watch(
</button> </button>
</div> </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"> <div v-if="mostrarAvanzado" class="flex gap-1.5 mb-6 flex-wrap">
<button <button
v-for="[key, label] in AVANZADO" 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 // Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
// como un error de tipeo. // 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 nombreCanal = (t) => NOMBRES_CANAL[t] || t
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo')) const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
+72 -10
View File
@@ -37,6 +37,8 @@ async function copiarWidget() {
function canalVacio() { function canalVacio() {
return { return {
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '', 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, usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
} }
} }
@@ -47,18 +49,33 @@ function nuevoCanal() {
} }
async function guardarCanal() { async function guardarCanal() {
const credenciales = let credenciales
canalForm.value.tipo === 'telegram' if (canalForm.value.tipo === 'telegram') {
? { bot_token: canalForm.value.bot_token } credenciales = { bot_token: canalForm.value.bot_token }
: { } else if (canalForm.value.tipo === 'correo') {
phone_number_id: canalForm.value.phone_number_id, // El host se deduce del dominio si el cliente no lo escribió: en hosting
access_token: canalForm.value.access_token, // común mail.<dominio> acierta casi siempre.
app_secret: canalForm.value.app_secret, const dominio = String(canalForm.value.usuario).split('@')[1] || ''
verify_token: canalForm.value.verify_token, 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 { try {
await api.post(apiUmind('/umind/canales'), { await api.post(apiUmind('/umind/canales'), {
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true, 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_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
usar_archivos_docs: canalForm.value.usar_archivos_docs, usar_archivos_docs: canalForm.value.usar_archivos_docs,
}) })
@@ -160,7 +177,11 @@ async function eliminarCanal(c) {
Leer archivos adjuntos (PDF, Word, texto) Leer archivos adjuntos (PDF, Word, texto)
</label> </label>
</div> </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> Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
</p> </p>
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue mt-1"> <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"> <select v-model="canalForm.tipo" class="input">
<option value="telegram">Telegram</option> <option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp Business</option> <option value="whatsapp">WhatsApp Business</option>
<option value="correo">Correo (tu casilla)</option>
</select> </select>
</div> </div>
<template v-if="canalForm.tipo === 'telegram'"> <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" /> <input v-model="canalForm.bot_token" type="password" required class="input" />
</div> </div>
</template> </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> <template v-else>
<div> <div>
<label class="label">Phone Number ID</label> <label class="label">Phone Number ID</label>
+130 -15
View File
@@ -1,4 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js' import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js' import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue' import UiEmptyState from '../../components/ui/UiEmptyState.vue'
@@ -8,16 +9,67 @@ const props = defineProps({
conexiones: { type: Array, default: () => [] }, 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']) 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) { function conectar(proveedor) {
// Navegación normal (no fetch): el backend redirige a Google/Microsoft. // Navegación normal (no fetch): el backend redirige a Google/Microsoft.
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`) 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) { async function desconectar(c) {
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
await api.del(apiUmind(`/umind/conexiones/${c.ID}`)) await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
@@ -28,32 +80,95 @@ async function desconectar(c) {
<template> <template>
<div> <div>
<p class="label mb-4"> <p class="label mb-4">
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre. Las casillas que el agente puede consultar cuando se lo pedís por ejemplo:
Se soporta una cuenta activa a la vez. <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> </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')"> <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 Conectar Google
</button> </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')"> <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 Conectar Outlook
</button> </button>
<button class="btn-primary" @click="showForm = !showForm">
+ Casilla propia
</button>
</div> </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"> <div class="card divide-y divide-borde">
<UiEmptyState <UiEmptyState
v-if="conexiones.length === 0" v-if="conexiones.length === 0"
titulo="Sin cuentas conectadas" 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 v-for="c in conexiones" :key="c.ID" class="p-4">
<div> <div class="flex items-center justify-between gap-3">
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span> <div class="min-w-0">
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span> <span class="font-medium text-texto">{{ c.email }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'"> <span class="ml-2 text-xs text-tenue">{{ nombreProveedor(c.proveedor) }}</span>
{{ c.activo ? 'activa' : 'inactiva' }} <span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
</span> {{ 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>
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
</div> </div>
</div> </div>
</div> </div>
+27
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm" "gorm.io/gorm"
@@ -36,6 +37,32 @@ type UmindCanal struct {
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se // Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
// le pasa al agente como si el cliente lo hubiera escrito. // le pasa al agente como si el cliente lo hubiera escrito.
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"` UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
// Solo para el canal correo: cada cuántos minutos se revisa la casilla.
// Lo elige el cliente — una inmobiliaria quiere 2, un estudio contable con
// 30 le sobra. Telegram y WhatsApp no lo usan: a ellos les llega webhook.
IntervaloMinutos int `json:"intervalo_minutos" gorm:"column:intervalo_minutos;default:5"`
// UltimaRevision es lo que decide si ya le toca — en la base y no en
// memoria, por la misma razón que el aviso de tope: los despliegues borran
// la RAM varias veces por día.
UltimaRevision *time.Time `json:"ultima_revision" gorm:"column:ultima_revision"`
}
// MarcarRevisionCanal deja constancia de que la casilla se acaba de revisar.
func MarcarRevisionCanal(id uint) {
ahora := time.Now()
app.Http.Database.DB.Model(&UmindCanal{}).Where("id = ?", id).
Update("ultima_revision", &ahora)
}
// GetCanalesCorreoPendientes devuelve los canales de correo activos a los que
// ya les toca revisión según su propio intervalo.
func GetCanalesCorreoPendientes() ([]UmindCanal, error) {
var items []UmindCanal
err := app.Http.Database.DB.
Where("tipo = ? AND activo = ?", "correo", true).
Where("ultima_revision IS NULL OR ultima_revision < NOW() - (intervalo_minutos || ' minutes')::interval").
Find(&items).Error
return items, err
} }
func (UmindCanal) TableName() string { return "umind_canales" } func (UmindCanal) TableName() string { return "umind_canales" }
+16 -1
View File
@@ -17,13 +17,17 @@ import (
type UmindConexion struct { type UmindConexion struct {
gorm.Model gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"` AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft | imap
Email string `json:"email" gorm:"column:email;size:255"` Email string `json:"email" gorm:"column:email;size:255"`
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"` AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"` RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"` ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
Scopes string `json:"scopes" gorm:"column:scopes;type:text"` Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"` Activo bool `json:"activo" gorm:"column:activo;default:true"`
// CredencialesEnc guarda el blob cifrado de una cuenta IMAP (host, puerto,
// usuario, contraseña, encriptación) — mismo esquema que los canales. Las
// conexiones OAuth no lo usan: sus tokens van en los campos de arriba.
CredencialesEnc string `json:"-" gorm:"column:credenciales_enc;type:text"`
} }
func (UmindConexion) TableName() string { return "umind_conexiones" } func (UmindConexion) TableName() string { return "umind_conexiones" }
@@ -49,6 +53,17 @@ func GetUmindConexionByID(id uint) (*UmindConexion, error) {
// GetUmindConexionActiva retorna la primera conexión activa del agente — // GetUmindConexionActiva retorna la primera conexión activa del agente —
// hoy se soporta una sola cuenta de correo conectada por agente, no una // hoy se soporta una sola cuenta de correo conectada por agente, no una
// bandeja por proveedor a la vez. // bandeja por proveedor a la vez.
// GetUmindConexionesActivas lista todas las cuentas conectadas de un agente.
// Antes se soportaba una sola; ahora un agente puede tener varias casillas y
// el modelo elige por dirección cuando el dueño se lo pide.
func GetUmindConexionesActivas(agenteID uint) ([]UmindConexion, error) {
var items []UmindConexion
err := app.Http.Database.DB.
Where("agente_id = ? AND activo = ?", agenteID, true).
Order("id ASC").Find(&items).Error
return items, err
}
func GetUmindConexionActiva(agenteID uint) (*UmindConexion, error) { func GetUmindConexionActiva(agenteID uint) (*UmindConexion, error) {
var c UmindConexion var c UmindConexion
err := app.Http.Database.DB.Where("agente_id = ? AND activo = ?", agenteID, true).First(&c).Error err := app.Http.Database.DB.Where("agente_id = ? AND activo = ?", agenteID, true).First(&c).Error
+7
View File
@@ -85,6 +85,13 @@ func IniciarCron() {
return return
} }
// Canales de correo de los agentes — corre cada minuto, pero cada casilla
// se revisa según el intervalo que eligió su dueño.
if _, err := cronScheduler.AddFunc("* * * * *", RevisarCanalesCorreo); err != nil {
log.Printf("[CRON] Error registrando tarea canales_correo: %v", err)
return
}
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está // Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
// configurado, así que registrarlo siempre no cuesta. // configurado, así que registrarlo siempre no cuesta.
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil { if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
+93
View File
@@ -0,0 +1,93 @@
package services
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
)
// DatosSMTP es lo mínimo para mandar un correo por el servidor de cualquiera:
// lo usa el buzón de soporte con su configuración y el canal de correo de un
// agente con las credenciales del cliente. Una sola implementación — el bug de
// STARTTLS que abría dos conexiones ya se pagó una vez.
type DatosSMTP struct {
Host string
Puerto int
Encriptado string // ssl | tls | starttls | none
Usuario string
Password string
Desde string
DesdeNombre string
}
// EnviarPorSMTP abre una sola conexión, la asegura según la configuración,
// manda el mensaje y cierra con QUIT.
func EnviarPorSMTP(d DatosSMTP, para, asunto, cuerpoHTML string) error {
if d.Host == "" || d.Desde == "" || para == "" {
return fmt.Errorf("faltan datos para el envío (servidor, remitente o destinatario)")
}
puerto := d.Puerto
if puerto == 0 {
puerto = 587
}
nombre := d.DesdeNombre
if nombre == "" {
nombre = d.Desde
}
addr := fmt.Sprintf("%s:%d", d.Host, puerto)
msg := []byte(fmt.Sprintf(
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
nombre, d.Desde, para, asunto, cuerpoHTML))
enc := strings.ToLower(strings.TrimSpace(d.Encriptado))
var cliente *smtp.Client
var err error
if enc == "tls" || enc == "ssl" {
conn, errDial := tls.Dial("tcp", addr, &tls.Config{ServerName: d.Host})
if errDial != nil {
return fmt.Errorf("no se pudo conectar por TLS: %w", errDial)
}
cliente, err = smtp.NewClient(conn, d.Host)
if err != nil {
conn.Close()
return fmt.Errorf("saludo SMTP rechazado: %w", err)
}
} else {
cliente, err = smtp.Dial(addr)
if err != nil {
return fmt.Errorf("no se pudo conectar: %w", err)
}
if enc != "none" {
if err := cliente.StartTLS(&tls.Config{ServerName: d.Host}); err != nil {
cliente.Close()
return fmt.Errorf("STARTTLS rechazado: %w", err)
}
}
}
defer cliente.Close()
if d.Usuario != "" {
if err := cliente.Auth(smtp.PlainAuth("", d.Usuario, d.Password, d.Host)); err != nil {
return fmt.Errorf("autenticación rechazada para %s: %w", d.Usuario, err)
}
}
if err := cliente.Mail(d.Desde); err != nil {
return fmt.Errorf("el servidor rechazó el remitente %s: %w", d.Desde, err)
}
if err := cliente.Rcpt(para); err != nil {
return fmt.Errorf("el servidor rechazó el destinatario %s: %w", para, err)
}
w, err := cliente.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("el servidor rechazó el mensaje: %w", err)
}
return cliente.Quit()
}
+6 -69
View File
@@ -1,10 +1,8 @@
package services package services
import ( import (
"crypto/tls"
"fmt" "fmt"
"log" "log"
"net/smtp"
"strings" "strings"
"sync" "sync"
@@ -41,77 +39,16 @@ func soporteSendMail(to, subject, htmlBody string) error {
if port == 0 { if port == 0 {
port = 587 port = 587
} }
addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port) if err := EnviarPorSMTP(DatosSMTP{
msg := []byte(fmt.Sprintf( Host: cfg.SmtpHost, Puerto: port, Encriptado: cfg.SmtpEncryption,
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", Usuario: cfg.SmtpUsername, Password: cfg.SmtpPassword,
fromName, from, to, subject, htmlBody)) Desde: from, DesdeNombre: fromName,
}, to, subject, htmlBody); err != nil {
if err := enviarPorSMTP(cfg, addr, from, to, msg); err != nil { return fmt.Errorf("SMTP de soporte (%s:%d): %w", cfg.SmtpHost, port, err)
return fmt.Errorf("SMTP de soporte (%s): %w", addr, err)
} }
return nil return nil
} }
// enviarPorSMTP abre una sola conexión, la asegura según la configuración,
// manda el mensaje y cierra con QUIT.
//
// Antes el camino STARTTLS abría una conexión, hacía StartTLS, autenticaba… y
// la descartaba para llamar a smtp.SendMail, que abre otra distinta: la primera
// quedaba colgada y el envío real salía por una conexión que podía no estar
// autenticada igual.
func enviarPorSMTP(cfg *models.SoporteWebhookConfig, addr, from, to string, msg []byte) error {
enc := strings.ToLower(strings.TrimSpace(cfg.SmtpEncryption))
var cliente *smtp.Client
var err error
if enc == "tls" || enc == "ssl" {
conn, errDial := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SmtpHost})
if errDial != nil {
return fmt.Errorf("no se pudo conectar por TLS: %w", errDial)
}
cliente, err = smtp.NewClient(conn, cfg.SmtpHost)
if err != nil {
conn.Close()
return fmt.Errorf("saludo SMTP rechazado: %w", err)
}
} else {
cliente, err = smtp.Dial(addr)
if err != nil {
return fmt.Errorf("no se pudo conectar: %w", err)
}
if enc != "none" {
if err := cliente.StartTLS(&tls.Config{ServerName: cfg.SmtpHost}); err != nil {
cliente.Close()
return fmt.Errorf("STARTTLS rechazado: %w", err)
}
}
}
defer cliente.Close()
if cfg.SmtpUsername != "" {
if err := cliente.Auth(smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)); err != nil {
return fmt.Errorf("autenticación rechazada para %s: %w", cfg.SmtpUsername, err)
}
}
if err := cliente.Mail(from); err != nil {
return fmt.Errorf("el servidor rechazó el remitente %s: %w", from, err)
}
if err := cliente.Rcpt(to); err != nil {
return fmt.Errorf("el servidor rechazó el destinatario %s: %w", to, err)
}
w, err := cliente.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("el servidor rechazó el mensaje: %w", err)
}
return cliente.Quit()
}
// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que usa el // ProbarEnvioSoporte manda un correo de prueba por el mismo camino que usa el
// acuse automático, y devuelve el error tal cual. Es la única forma de saber // acuse automático, y devuelve el error tal cual. Es la única forma de saber
// por qué no llega: el acuse real se manda en segundo plano. // por qué no llega: el acuse real se manda en segundo plano.
+59 -12
View File
@@ -119,13 +119,14 @@ func umindEmailTools() []agentTool {
Type: "function", Type: "function",
Function: agentToolFunc{ Function: agentToolFunc{
Name: "leer_bandeja", Name: "leer_bandeja",
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).", Description: "Busca o resume correos recibidos en las casillas conectadas del negocio (ej. revisar si llegó un comprobante, resumir los correos de hoy).",
Parameters: agentToolParam{ Parameters: agentToolParam{
Type: "object", Type: "object",
Properties: map[string]agentToolParam{ Properties: map[string]agentToolParam{
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"}, "consulta": {Type: "string", Description: "Qué buscar: remitente o palabras clave. Vacío = los correos más recientes"},
"cuenta": {Type: "string", Description: "Dirección de la casilla a revisar, si hay más de una conectada"},
}, },
Required: []string{"consulta"}, Required: []string{},
}, },
}, },
}, },
@@ -188,18 +189,61 @@ func executeUmindTool(agenteID uint, name string, args map[string]interface{}) s
// Microsoft Graph según el proveedor de la conexión activa del agente, // Microsoft Graph según el proveedor de la conexión activa del agente,
// refrescando el token primero si hace falta. // refrescando el token primero si hace falta.
func executeUmindEmailTool(agenteID uint, name string, args map[string]interface{}) string { func executeUmindEmailTool(agenteID uint, name string, args map[string]interface{}) string {
conexion, err := models.GetUmindConexionActiva(agenteID) conexiones, err := models.GetUmindConexionesActivas(agenteID)
if err != nil { if err != nil || len(conexiones) == 0 {
return `{"error": "no hay ninguna cuenta de correo conectada"}` return `{"error": "no hay ninguna cuenta de correo conectada"}`
} }
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err) // Con varias casillas, el modelo elige por dirección; con una sola, esa.
models.RegistrarEventoUmind(agenteID, "error", "email", "Error refrescando el token de la cuenta de correo conectada", err.Error()) // Si hay varias y no dijo cuál, se le listan para que pregunte o elija —
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}` // adivinar la casilla equivocada sería resumirle a alguien el correo que
// no pidió.
cuenta, _ := args["cuenta"].(string)
cuenta = strings.ToLower(strings.TrimSpace(cuenta))
var conexion *models.UmindConexion
if len(conexiones) == 1 {
conexion = &conexiones[0]
} else if cuenta != "" {
for i := range conexiones {
if strings.Contains(strings.ToLower(conexiones[i].Email), cuenta) {
conexion = &conexiones[i]
break
}
}
if conexion == nil {
return fmt.Sprintf(`{"error": "no hay ninguna cuenta que coincida con %q"}`, cuenta)
}
} else {
emails := make([]string, len(conexiones))
for i, cx := range conexiones {
emails[i] = cx.Email
}
b, _ := json.Marshal(map[string]interface{}{
"error": "hay varias cuentas conectadas: indicá cuál en el parámetro cuenta",
"cuentas": emails,
})
return string(b)
}
esIMAP := conexion.Proveedor == "imap"
// El refresco de token es cosa de OAuth; una cuenta IMAP no vence.
if !esIMAP {
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
models.RegistrarEventoUmind(agenteID, "error", "email", "Error refrescando el token de la cuenta de correo conectada", err.Error())
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
}
} }
switch name { switch name {
case "enviar_correo": case "enviar_correo":
// Las cuentas IMAP son de solo lectura a propósito: se conectan en "Lo
// que sabe" para consultar. Para que el agente RESPONDA correos está el
// canal de correo en "Dónde atiende", con sus guardas propias.
if esIMAP {
return `{"error": "esta cuenta es de solo lectura; para enviar correos configurá el canal de correo en Dónde atiende"}`
}
destinatario, _ := args["destinatario"].(string) destinatario, _ := args["destinatario"].(string)
asunto, _ := args["asunto"].(string) asunto, _ := args["asunto"].(string)
cuerpo, _ := args["cuerpo"].(string) cuerpo, _ := args["cuerpo"].(string)
@@ -223,9 +267,12 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
consulta, _ := args["consulta"].(string) consulta, _ := args["consulta"].(string)
var resultados []CorreoResumen var resultados []CorreoResumen
var lecErr error var lecErr error
if conexion.Proveedor == UmindOAuthGoogle { switch {
case esIMAP:
resultados, lecErr = LeerBandejaIMAP(conexion, consulta, 8)
case conexion.Proveedor == UmindOAuthGoogle:
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5) resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
} else { default:
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5) resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
} }
if lecErr != nil { if lecErr != nil {
@@ -233,7 +280,7 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
models.RegistrarEventoUmind(agenteID, "error", "email", "Error leyendo la bandeja de correo", lecErr.Error()) models.RegistrarEventoUmind(agenteID, "error", "email", "Error leyendo la bandeja de correo", lecErr.Error())
return `{"error": "no se pudo leer la bandeja"}` return `{"error": "no se pudo leer la bandeja"}`
} }
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados}) b, _ := json.Marshal(map[string]interface{}{"cuenta": conexion.Email, "resultados": resultados})
return string(b) return string(b)
default: default:
+151
View File
@@ -0,0 +1,151 @@
package services
import (
"fmt"
"log"
"strings"
"sync"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El canal correo: el agente atiende una casilla como atiende WhatsApp. La
// diferencia es que acá no hay webhook — se revisa la casilla cada tanto, con
// el intervalo que eligió el cliente en el canal.
// correoEnCurso evita que dos pasadas del cron se pisen si una tarda más que
// el minuto. Por proceso alcanza: el cron corre en uno solo.
var correoEnCurso sync.Mutex
// RevisarCanalesCorreo corre cada minuto y atiende solo los canales a los que
// ya les toca según su propio intervalo. Con un pool acotado: 100 casillas no
// deben salir a la red en el mismo instante.
func RevisarCanalesCorreo() {
if !correoEnCurso.TryLock() {
return
}
defer correoEnCurso.Unlock()
canales, err := models.GetCanalesCorreoPendientes()
if err != nil {
log.Printf("[UMIND_CORREO] No se pudieron listar los canales: %v", err)
return
}
if len(canales) == 0 {
return
}
const trabajadores = 8
cola := make(chan models.UmindCanal)
var wg sync.WaitGroup
for w := 0; w < trabajadores; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for canal := range cola {
atenderCasilla(canal)
}
}()
}
for _, c := range canales {
cola <- c
}
close(cola)
wg.Wait()
}
// atenderCasilla revisa una casilla y responde lo que corresponda. Se marca la
// revisión ANTES de procesar: si la casilla está rota, el error queda en
// Auditoría y no se reintenta hasta el próximo intervalo — martillar cada
// minuto un login que falla es la forma de que el host del cliente nos bloquee.
func atenderCasilla(canal models.UmindCanal) {
models.MarcarRevisionCanal(canal.ID)
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return
}
creds, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
registrarErrorCorreo(canal, "credenciales del canal corruptas", err)
return
}
cred := CredencialesIMAP{
Host: creds["imap_host"], Usuario: creds["usuario"],
Password: creds["password"], Encriptado: creds["imap_encriptado"],
}
fmt.Sscanf(creds["imap_puerto"], "%d", &cred.Puerto)
c, err := conectarIMAPGenerico(cred)
if err != nil {
registrarErrorCorreo(canal, "no se pudo entrar a la casilla", err)
return
}
defer c.Close()
correos, err := BajarNoLeidos(c, "INBOX", 24)
if err != nil {
registrarErrorCorreo(canal, "no se pudo leer la casilla", err)
return
}
for _, correo := range correos {
// Las guardas que separan "asistente útil" de "bucle con otro robot":
// nunca contestar correo automático, nunca contestarse a sí mismo.
e := correo.Correo
remitente := strings.ToLower(ExtraerEmail(e.From))
if e.Automatico || remitente == "" || remitente == strings.ToLower(creds["usuario"]) {
_ = MarcarLeido(c, correo.UID)
continue
}
if strings.TrimSpace(e.Texto) == "" {
_ = MarcarLeido(c, correo.UID)
continue
}
// La sesión es el remitente: sus correos siguientes conservan el hilo,
// igual que un chat de WhatsApp conserva el número.
sessionID := "mail:" + remitente
respuesta, err := ProcessWidgetMessage(agente, sessionID, e.Texto)
if err != nil {
registrarErrorCorreo(canal, "el agente no pudo responder un correo", err)
// Sin marcar leído: se reintenta en la próxima revisión.
continue
}
asunto := e.Subject
if asunto != "" && !strings.HasPrefix(strings.ToLower(asunto), "re:") {
asunto = "Re: " + asunto
}
if asunto == "" {
asunto = "Re: tu consulta"
}
cuerpo := strings.ReplaceAll(respuesta, "\n", "<br>")
err = EnviarPorSMTP(DatosSMTP{
Host: creds["smtp_host"], Encriptado: creds["smtp_encriptado"],
Usuario: creds["usuario"], Password: creds["password"],
Desde: creds["usuario"], DesdeNombre: agente.Nombre,
Puerto: puertoDe(creds["smtp_puerto"], 587),
}, ExtraerEmail(e.From), asunto, cuerpo)
if err != nil {
registrarErrorCorreo(canal, "no se pudo enviar la respuesta", err)
continue
}
// Leído recién cuando la respuesta salió: si algo falló en el medio,
// el correo queda sin leer y se reintenta.
_ = MarcarLeido(c, correo.UID)
}
}
func puertoDe(s string, def int) int {
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n == 0 {
return def
}
return n
}
func registrarErrorCorreo(canal models.UmindCanal, mensaje string, err error) {
log.Printf("[UMIND_CORREO] canal %d: %s: %v", canal.ID, mensaje, err)
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_correo", mensaje, err.Error())
}
+78
View File
@@ -0,0 +1,78 @@
package services
import (
"os"
"strings"
"testing"
)
func TestPuertoDe(t *testing.T) {
casos := map[string]int{"993": 993, "587": 587, "": 587, "abc": 587, "0": 587}
for entrada, quiero := range casos {
if got := puertoDe(entrada, 587); got != quiero {
t.Errorf("puertoDe(%q) = %d, quiero %d", entrada, got, quiero)
}
}
}
// Las propiedades que separan "asistente útil" de "incidente". Van sobre el
// código porque son de flujo con IMAP y SMTP reales de por medio — pero si
// alguien las borra, este test es el único que grita.
func TestGuardasDelCanalCorreo(t *testing.T) {
b, err := os.ReadFile("umind_canal_correo_service.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
// La revisión se marca ANTES de procesar: una casilla con la contraseña
// cambiada no puede reintentar login cada minuto — el host del cliente
// termina bloqueando nuestra IP como atacante.
i := strings.Index(s, "func atenderCasilla")
marca := strings.Index(s[i:], "MarcarRevisionCanal")
conexion := strings.Index(s[i:], "conectarIMAPGenerico")
if marca < 0 || conexion < 0 || marca > conexion {
t.Error("la revisión tiene que marcarse antes de conectar: si no, un login que falla se reintenta cada minuto")
}
// Nunca responder correo automático ni responderse a sí mismo: es la
// diferencia entre un asistente y un bucle con otro autoresponder.
if !strings.Contains(s, "e.Automatico") {
t.Error("falta la guarda de correos automáticos")
}
if !strings.Contains(s, `remitente == strings.ToLower(creds["usuario"])`) {
t.Error("falta la guarda de no responderse a sí mismo")
}
// El correo se marca leído recién cuando la respuesta salió: si el envío
// falla, queda sin leer y se reintenta en la próxima revisión.
envio := strings.Index(s, "EnviarPorSMTP")
leido := strings.LastIndex(s, "MarcarLeido")
if envio < 0 || leido < envio {
t.Error("el marcado como leído tiene que ir después del envío de la respuesta")
}
}
// La casilla de una conexión (Lo que sabe) es de SOLO lectura: se consulta a
// pedido y el estado leído/no-leído es del cliente. Si alguna vez esta función
// marca algo, el asistente le está pisando la bandeja personal.
func TestLeerBandejaIMAPNoTocaLaCasilla(t *testing.T) {
b, err := os.ReadFile("umind_conexion_imap.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
i := strings.Index(s, "func LeerBandejaIMAP")
j := strings.Index(s[i:], "\nfunc ")
cuerpo := s[i : i+j]
if strings.Contains(cuerpo, "MarcarLeido") || strings.Contains(cuerpo, "StoreFlagsAdd") {
t.Error("LeerBandejaIMAP no puede marcar correos: la casilla es del cliente")
}
if !strings.Contains(cuerpo, "Peek: true") {
t.Error("el fetch tiene que ir con Peek para no marcar como leído")
}
if !strings.Contains(cuerpo, "ReadOnly: true") {
t.Error("el SELECT de INBOX tiene que ser de solo lectura")
}
}
+162
View File
@@ -0,0 +1,162 @@
package services
import (
"encoding/json"
"fmt"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// CredencialesIMAP es el blob que viaja cifrado en UmindConexion.CredencialesEnc.
type CredencialesIMAP struct {
Host string `json:"host"`
Puerto int `json:"puerto"`
Usuario string `json:"usuario"`
Password string `json:"password"`
Encriptado string `json:"encriptado"` // ssl | starttls
}
// CifrarCredencialesIMAP / DescifrarCredencialesIMAP — mismo esquema AES-GCM
// que los canales: un solo blob, nunca campos sueltos en claro.
func CifrarCredencialesIMAP(c CredencialesIMAP) (string, error) {
b, err := json.Marshal(c)
if err != nil {
return "", err
}
return CifrarSecretoUmind(string(b))
}
func DescifrarCredencialesIMAP(enc string) (CredencialesIMAP, error) {
var c CredencialesIMAP
claro, err := DescifrarSecretoUmind(enc)
if err != nil {
return c, err
}
err = json.Unmarshal([]byte(claro), &c)
return c, err
}
func conectarIMAPGenerico(cred CredencialesIMAP) (*imapclient.Client, error) {
puerto := cred.Puerto
if puerto == 0 {
puerto = 993
}
addr := fmt.Sprintf("%s:%d", cred.Host, puerto)
var c *imapclient.Client
var err error
if strings.EqualFold(cred.Encriptado, "starttls") {
c, err = imapclient.DialStartTLS(addr, nil)
} else {
c, err = imapclient.DialTLS(addr, nil)
}
if err != nil {
return nil, fmt.Errorf("no se pudo conectar a %s: %w", addr, err)
}
if err := c.Login(cred.Usuario, cred.Password).Wait(); err != nil {
c.Close()
return nil, fmt.Errorf("login rechazado para %s: %w", cred.Usuario, err)
}
return c, nil
}
// LeerBandejaIMAP trae los últimos correos de la casilla, más recientes
// primero. Es la tool "leer_bandeja" cuando la cuenta es IMAP: el dueño le
// pide al agente "revisame los correos de hoy y resumime" y esto es lo que el
// modelo recibe para resumir.
//
// Con Peek y sin marcar NADA: es la casilla personal del cliente y el estado
// leído/no-leído es suyo — un asistente que le "lee" los correos no puede
// dejárselos como leídos.
func LeerBandejaIMAP(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
if limite <= 0 || limite > 10 {
limite = 10
}
cred, err := DescifrarCredencialesIMAP(conexion.CredencialesEnc)
if err != nil {
return nil, fmt.Errorf("credenciales de la cuenta corruptas: %w", err)
}
c, err := conectarIMAPGenerico(cred)
if err != nil {
return nil, err
}
defer c.Close()
buzon, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return nil, fmt.Errorf("no se pudo abrir INBOX: %w", err)
}
if buzon.NumMessages == 0 {
return []CorreoResumen{}, nil
}
// Los últimos N por número de secuencia: el orden de llegada. Se traen
// algunos de más cuando hay filtro, porque el filtrado es local.
traer := uint32(limite)
if consulta != "" {
traer = uint32(limite * 4)
}
desde := uint32(1)
if buzon.NumMessages > traer {
desde = buzon.NumMessages - traer + 1
}
var seq imap.SeqSet
seq.AddRange(desde, buzon.NumMessages)
msgs, err := c.Fetch(seq, &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}).Collect()
if err != nil {
return nil, fmt.Errorf("no se pudieron traer los correos: %w", err)
}
filtro := strings.ToLower(strings.TrimSpace(consulta))
var out []CorreoResumen
// Al revés: los más recientes primero.
for i := len(msgs) - 1; i >= 0 && len(out) < limite; i-- {
var crudo []byte
for _, b := range msgs[i].BodySection {
crudo = b.Bytes
break
}
if len(crudo) == 0 {
continue
}
correo, err := parsearCorreo(crudo)
if err != nil {
continue
}
if filtro != "" &&
!strings.Contains(strings.ToLower(correo.Subject), filtro) &&
!strings.Contains(strings.ToLower(correo.From), filtro) &&
!strings.Contains(strings.ToLower(correo.Texto), filtro) {
continue
}
extracto := correo.Texto
if len([]rune(extracto)) > 300 {
extracto = string([]rune(extracto)[:300]) + "…"
}
out = append(out, CorreoResumen{
De: correo.From,
Asunto: correo.Subject,
Extracto: extracto,
})
}
return out, nil
}
// ProbarConexionIMAPGenerica valida credenciales sin tocar nada del buzón.
func ProbarConexionIMAPGenerica(cred CredencialesIMAP) error {
c, err := conectarIMAPGenerico(cred)
if err != nil {
return err
}
defer c.Close()
if _, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return fmt.Errorf("no se pudo abrir INBOX: %w", err)
}
_ = c.Logout().Wait()
return nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title> <title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-kEapBzai.js"></script> <script type="module" crossorigin src="/orchestrator/assets/index-CPU0iTo1.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-CeuZ8PUd.css"> <link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DsqIzlWM.css">
</head> </head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens, <!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la que son los que cambian con el tema. Una utilidad acá le ganaba a la
+26 -2
View File
@@ -563,6 +563,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError, "webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes, "usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
"usar_archivos_docs": canal.UsarArchivosDocs, "usar_archivos_docs": canal.UsarArchivosDocs,
"intervalo_minutos": canal.IntervaloMinutos,
} }
} }
return c.JSON(fiber.Map{"items": out}) return c.JSON(fiber.Map{"items": out})
@@ -576,6 +577,7 @@ type umindCanalReq struct {
UsarWhisperAudio bool `json:"usar_whisper_audio"` UsarWhisperAudio bool `json:"usar_whisper_audio"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes"` UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
UsarArchivosDocs bool `json:"usar_archivos_docs"` UsarArchivosDocs bool `json:"usar_archivos_docs"`
IntervaloMinutos int `json:"intervalo_minutos"`
} }
func CreateUmindCanalHandler(c *fiber.Ctx) error { func CreateUmindCanalHandler(c *fiber.Ctx) error {
@@ -600,18 +602,40 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
} }
} }
case "correo":
for _, k := range []string{"imap_host", "smtp_host", "usuario", "password"} {
if strings.TrimSpace(req.Credenciales[k]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
}
}
// Probar ANTES de guardar: un canal de correo con la contraseña mal no
// falla acá adelante sino en el cron, donde nadie lo está mirando.
cred := services.CredencialesIMAP{
Host: req.Credenciales["imap_host"], Usuario: req.Credenciales["usuario"],
Password: req.Credenciales["password"], Encriptado: req.Credenciales["imap_encriptado"],
}
fmt.Sscanf(req.Credenciales["imap_puerto"], "%d", &cred.Puerto)
if err := services.ProbarConexionIMAPGenerica(cred); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la casilla rechazó la conexión: " + err.Error()})
}
default: default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram o whatsapp"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram, whatsapp o correo"})
} }
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales) credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
intervalo := req.IntervaloMinutos
if intervalo < 2 {
// Menos de 2 minutos es martillar el servidor de correo del cliente
// para ganar segundos que un correo no necesita.
intervalo = 5
}
canal := &models.UmindCanal{ canal := &models.UmindCanal{
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc, AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes, UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
UsarArchivosDocs: req.UsarArchivosDocs, UsarArchivosDocs: req.UsarArchivosDocs, IntervaloMinutos: intervalo,
} }
if err := models.CreateUmindCanal(canal); err != nil { if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -0,0 +1,103 @@
package controllers
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Cuentas de correo IMAP (solo lectura) ───────────────────────────────────
//
// Viven en "Lo que sabe": el dueño le pide al agente "revisame los correos de
// hoy y resumime" y el agente consulta la casilla en ese momento — nada de
// fondo, nada de polling. Para que el agente RESPONDA correos está el canal
// de correo en "Dónde atiende", que es otra cosa y tiene otras guardas.
// CrearConexionImapHandler conecta una casilla por IMAP. Se prueba ANTES de
// guardar: una contraseña mal cargada tiene que fallar acá, con la persona
// mirando, y no después dentro de una conversación.
// POST /umind/conexiones/imap
func CrearConexionImapHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
Email string `json:"email"`
Host string `json:"host"`
Puerto int `json:"puerto"`
Password string `json:"password"`
Encriptado string `json:"encriptado"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" || !strings.Contains(email, "@") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "poné la dirección de la casilla, ej: ventas@tunegocio.com"})
}
if strings.TrimSpace(req.Password) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "falta la contraseña de la casilla"})
}
// Sin host se prueba el clásico mail.<dominio>: en hosting común acierta
// casi siempre y el cliente no ve un solo campo técnico.
host := strings.TrimSpace(req.Host)
if host == "" {
host = "mail." + strings.SplitN(email, "@", 2)[1]
}
cred := services.CredencialesIMAP{
Host: host, Puerto: req.Puerto, Usuario: email,
Password: req.Password, Encriptado: req.Encriptado,
}
if err := services.ProbarConexionIMAPGenerica(cred); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "la casilla rechazó la conexión: " + err.Error() +
" — si tu correo es de Gmail u Outlook, usá los botones de conectar con Google/Microsoft",
})
}
enc, err := services.CifrarCredencialesIMAP(cred)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
conexion := &models.UmindConexion{
AgenteID: req.AgenteID, Proveedor: "imap", Email: email,
CredencialesEnc: enc, Activo: true,
}
if err := models.CreateUmindConexion(conexion); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": conexion.ID, "host": host})
}
// ProbarConexionImapAgenteHandler reconecta una cuenta ya guardada y dice si
// sigue andando — la contraseña pudo cambiar del lado del cliente.
// POST /umind/conexiones/:id/probar
func ProbarConexionImapAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoConexion(c, uint(id)); err != nil {
return err
}
conexion, err := models.GetUmindConexionByID(uint(id))
if err != nil {
return errSinAcceso(c)
}
if conexion.Proveedor != "imap" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "esta cuenta es OAuth: se prueba sola al usarse"})
}
cred, err := services.DescifrarCredencialesIMAP(conexion.CredencialesEnc)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "credenciales corruptas"})
}
if err := services.ProbarConexionIMAPGenerica(cred); err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "message": "La casilla responde bien"})
}
+2
View File
@@ -65,6 +65,8 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...) g.Get("/umind/conexiones", r(controllers.GetUmindConexionesHandler)...)
g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...) g.Get("/umind/conexiones/conectar", w(controllers.UmindConectarHandler)...)
g.Post("/umind/conexiones/imap", w(controllers.CrearConexionImapHandler)...)
g.Post("/umind/conexiones/:id/probar", w(controllers.ProbarConexionImapAgenteHandler)...)
g.Delete("/umind/conexiones/:id", w(controllers.DeleteUmindConexionHandler)...) g.Delete("/umind/conexiones/:id", w(controllers.DeleteUmindConexionHandler)...)
g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...) g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...)