feat(umind): el canal correo puede acotarse a ciertos remitentes
"Responder a: todos los que escriban / solo a estos remitentes" — direcciones completas o dominios, separados por coma. Un dominio (@miempresa.com) habilita a toda esa empresa; lo que no pasa el filtro se marca leído y no se contesta: decisión tomada, no correo pendiente que se relee en cada revisión. El match de dominio es contra el dominio entero, nunca por sufijo: "empresa.com" no habilita a alguien de "malaempresa.com" ni de un subdominio. Ese es el clásico filtro que parece cerrado y no lo está, y acá decide a quién le habla el agente en nombre del negocio — hay un test por cada agujero. El filtro y el intervalo también se pueden cambiar por el update del canal sin recrearlo, porque recrearlo pide la contraseña de la casilla de nuevo. De paso quedó verificado que el módulo de soporte sigue intacto tras la unificación del SMTP: los catorce tests de soporte (auto-respuesta, parseo IMAP, clasificador, resúmenes) pasan en verde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
eddec45857
commit
9da2a057f2
@@ -38,7 +38,7 @@ 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',
|
usuario: '', password: '', imap_host: '', smtp_host: '', imap_encriptado: 'ssl', smtp_encriptado: 'starttls',
|
||||||
intervalo_minutos: 5,
|
intervalo_minutos: 5, responder_a: 'todos', filtro_remitentes: '',
|
||||||
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,7 @@ async function guardarCanal() {
|
|||||||
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,
|
intervalo_minutos: Number(canalForm.value.intervalo_minutos) || 5,
|
||||||
|
filtro_remitentes: canalForm.value.responder_a === 'algunos' ? canalForm.value.filtro_remitentes : '',
|
||||||
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,
|
||||||
})
|
})
|
||||||
@@ -180,6 +181,7 @@ async function eliminarCanal(c) {
|
|||||||
<!-- Un canal de correo no tiene webhook: se revisa por intervalo. -->
|
<!-- Un canal de correo no tiene webhook: se revisa por intervalo. -->
|
||||||
<p v-if="c.tipo === 'correo'" class="label mt-1">
|
<p v-if="c.tipo === 'correo'" class="label mt-1">
|
||||||
Revisa la casilla cada {{ c.intervalo_minutos >= 60 ? '1 hora' : `${c.intervalo_minutos} minutos` }}
|
Revisa la casilla cada {{ c.intervalo_minutos >= 60 ? '1 hora' : `${c.intervalo_minutos} minutos` }}
|
||||||
|
· {{ c.filtro_remitentes ? `responde solo a: ${c.filtro_remitentes}` : 'responde a todos' }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="label mt-1 break-all">
|
<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>
|
||||||
@@ -235,6 +237,32 @@ async function eliminarCanal(c) {
|
|||||||
</select>
|
</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>
|
<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>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Responder a</label>
|
||||||
|
<div class="flex flex-col gap-1.5 mt-1">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input v-model="canalForm.responder_a" type="radio" value="todos" class="text-brand focus:ring-brand" />
|
||||||
|
Todos los que escriban
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input v-model="canalForm.responder_a" type="radio" value="algunos" class="text-brand focus:ring-brand" />
|
||||||
|
Solo a estos remitentes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<template v-if="canalForm.responder_a === 'algunos'">
|
||||||
|
<textarea
|
||||||
|
v-model="canalForm.filtro_remitentes"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
placeholder="juan@cliente.com, @miempresa.com Direcciones completas o dominios, separados por coma."
|
||||||
|
class="input font-mono mt-2"
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-tenue mt-1">
|
||||||
|
Un dominio (ej. <span class="font-mono">@miempresa.com</span>) habilita a todos los de esa empresa.
|
||||||
|
El resto de los correos se ignoran sin responder.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<details class="text-xs text-tenue">
|
<details class="text-xs text-tenue">
|
||||||
<summary class="cursor-pointer">Servidores (solo si la detección automática falla)</summary>
|
<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 class="grid sm:grid-cols-2 gap-3 mt-2">
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ type UmindCanal struct {
|
|||||||
// memoria, por la misma razón que el aviso de tope: los despliegues borran
|
// memoria, por la misma razón que el aviso de tope: los despliegues borran
|
||||||
// la RAM varias veces por día.
|
// la RAM varias veces por día.
|
||||||
UltimaRevision *time.Time `json:"ultima_revision" gorm:"column:ultima_revision"`
|
UltimaRevision *time.Time `json:"ultima_revision" gorm:"column:ultima_revision"`
|
||||||
|
// FiltroRemitentes acota a quién le responde el canal correo: direcciones
|
||||||
|
// o dominios separados por coma. Vacío = responde a todos. Un correo que
|
||||||
|
// no pasa el filtro se marca leído y no se contesta — el agente decidió,
|
||||||
|
// no está pendiente.
|
||||||
|
FiltroRemitentes string `json:"filtro_remitentes" gorm:"column:filtro_remitentes;type:text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarcarRevisionCanal deja constancia de que la casilla se acaba de revisar.
|
// MarcarRevisionCanal deja constancia de que la casilla se acaba de revisar.
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ func atenderCasilla(canal models.UmindCanal) {
|
|||||||
_ = MarcarLeido(c, correo.UID)
|
_ = MarcarLeido(c, correo.UID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// El filtro del canal: si el dueño lo acotó a ciertos remitentes, lo
|
||||||
|
// que no matchea se marca leído y no se contesta — decisión tomada,
|
||||||
|
// no correo pendiente.
|
||||||
|
if !RemitentePermitido(canal.FiltroRemitentes, remitente) {
|
||||||
|
_ = MarcarLeido(c, correo.UID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// La sesión es el remitente: sus correos siguientes conservan el hilo,
|
// La sesión es el remitente: sus correos siguientes conservan el hilo,
|
||||||
// igual que un chat de WhatsApp conserva el número.
|
// igual que un chat de WhatsApp conserva el número.
|
||||||
@@ -137,6 +144,45 @@ func atenderCasilla(canal models.UmindCanal) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemitentePermitido decide si el canal le responde a este remitente.
|
||||||
|
// El filtro son direcciones o dominios separados por coma (o salto de línea):
|
||||||
|
// "juan@x.com, @empresa.com, otra.com". Vacío = todos.
|
||||||
|
//
|
||||||
|
// Un dominio matchea con o sin @ adelante, y siempre contra el dominio ENTERO:
|
||||||
|
// "empresa.com" no habilita a alguien de "malaempresa.com" — ese sufijo suelto
|
||||||
|
// sería un filtro que parece cerrado y no lo está.
|
||||||
|
func RemitentePermitido(filtro, email string) bool {
|
||||||
|
filtro = strings.TrimSpace(filtro)
|
||||||
|
if filtro == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
partes := strings.SplitN(email, "@", 2)
|
||||||
|
if len(partes) != 2 || partes[1] == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
dominio := partes[1]
|
||||||
|
|
||||||
|
for _, entrada := range strings.FieldsFunc(filtro, func(r rune) bool { return r == ',' || r == '\n' || r == ';' }) {
|
||||||
|
e := strings.ToLower(strings.TrimSpace(entrada))
|
||||||
|
if e == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(e, "@") && !strings.HasPrefix(e, "@") {
|
||||||
|
// Dirección completa: match exacto.
|
||||||
|
if e == email {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Dominio, con o sin @ adelante.
|
||||||
|
if strings.TrimPrefix(e, "@") == dominio {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func puertoDe(s string, def int) int {
|
func puertoDe(s string, def int) int {
|
||||||
var n int
|
var n int
|
||||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n == 0 {
|
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n == 0 {
|
||||||
|
|||||||
@@ -76,3 +76,35 @@ func TestLeerBandejaIMAPNoTocaLaCasilla(t *testing.T) {
|
|||||||
t.Error("el SELECT de INBOX tiene que ser de solo lectura")
|
t.Error("el SELECT de INBOX tiene que ser de solo lectura")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// El filtro de remitentes es una decisión de a quién le habla el agente en
|
||||||
|
// nombre del negocio: un match de más responde a un desconocido, un match de
|
||||||
|
// menos ignora a un cliente.
|
||||||
|
func TestRemitentePermitido(t *testing.T) {
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
filtro string
|
||||||
|
email string
|
||||||
|
permite bool
|
||||||
|
}{
|
||||||
|
{"sin filtro responde a todos", "", "cualquiera@x.com", true},
|
||||||
|
{"dirección exacta", "juan@cliente.com", "juan@cliente.com", true},
|
||||||
|
{"dirección exacta, mayúsculas", "Juan@Cliente.com", "juan@cliente.com", true},
|
||||||
|
{"otra dirección no pasa", "juan@cliente.com", "pedro@cliente.com", false},
|
||||||
|
{"dominio con @", "@miempresa.com", "ana@miempresa.com", true},
|
||||||
|
{"dominio sin @", "miempresa.com", "ana@miempresa.com", true},
|
||||||
|
// El clásico agujero: "empresa.com" no puede habilitar sufijos.
|
||||||
|
{"sufijo parecido no pasa", "empresa.com", "ana@malaempresa.com", false},
|
||||||
|
{"subdominio no pasa", "empresa.com", "ana@mail.empresa.com", false},
|
||||||
|
{"lista mixta, segundo matchea", "juan@x.com, @cliente.com", "ana@cliente.com", true},
|
||||||
|
{"lista mixta, ninguno", "juan@x.com, @cliente.com", "ana@otro.com", false},
|
||||||
|
{"saltos de línea como separador", "a@x.com\nb@y.com", "b@y.com", true},
|
||||||
|
{"remitente sin dominio no pasa", "@cliente.com", "cosa-rara", false},
|
||||||
|
{"espacios alrededor", " juan@x.com , @y.com ", "juan@x.com", true},
|
||||||
|
}
|
||||||
|
for _, c := range casos {
|
||||||
|
if got := RemitentePermitido(c.filtro, c.email); got != c.permite {
|
||||||
|
t.Errorf("%s: RemitentePermitido(%q, %q) = %v, quiero %v", c.nombre, c.filtro, c.email, got, c.permite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
<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-CPU0iTo1.js"></script>
|
<script type="module" crossorigin src="/orchestrator/assets/index-B4CrdUeB.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DsqIzlWM.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,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -564,6 +565,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
|
|||||||
"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,
|
"intervalo_minutos": canal.IntervaloMinutos,
|
||||||
|
"filtro_remitentes": canal.FiltroRemitentes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"items": out})
|
return c.JSON(fiber.Map{"items": out})
|
||||||
@@ -578,6 +580,7 @@ type umindCanalReq struct {
|
|||||||
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"`
|
IntervaloMinutos int `json:"intervalo_minutos"`
|
||||||
|
FiltroRemitentes string `json:"filtro_remitentes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
||||||
@@ -636,6 +639,7 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
|||||||
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, IntervaloMinutos: intervalo,
|
UsarArchivosDocs: req.UsarArchivosDocs, IntervaloMinutos: intervalo,
|
||||||
|
FiltroRemitentes: strings.TrimSpace(req.FiltroRemitentes),
|
||||||
}
|
}
|
||||||
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()})
|
||||||
@@ -663,6 +667,16 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||||
}
|
}
|
||||||
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
|
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
|
||||||
|
// Solo si vinieron en el body: ajustar el filtro o el intervalo no obliga
|
||||||
|
// a recrear el canal, que pediría la contraseña de nuevo.
|
||||||
|
var presentes map[string]json.RawMessage
|
||||||
|
_ = json.Unmarshal(c.Body(), &presentes)
|
||||||
|
if _, ok := presentes["filtro_remitentes"]; ok {
|
||||||
|
updates["filtro_remitentes"] = strings.TrimSpace(req.FiltroRemitentes)
|
||||||
|
}
|
||||||
|
if _, ok := presentes["intervalo_minutos"]; ok && req.IntervaloMinutos >= 2 {
|
||||||
|
updates["intervalo_minutos"] = req.IntervaloMinutos
|
||||||
|
}
|
||||||
if len(req.Credenciales) > 0 {
|
if len(req.Credenciales) > 0 {
|
||||||
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user