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:
Lizandro Guarnizo
2026-08-23 20:14:35 -05:00
co-authored by Claude Opus 5
parent eddec45857
commit 9da2a057f2
8 changed files with 154 additions and 28 deletions
@@ -102,6 +102,13 @@ func atenderCasilla(canal models.UmindCanal) {
_ = MarcarLeido(c, correo.UID)
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,
// 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 {
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n == 0 {
+32
View File
@@ -76,3 +76,35 @@ func TestLeerBandejaIMAPNoTocaLaCasilla(t *testing.T) {
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)
}
}
}