From 8c3ab79e88fd6f1c01cd9ac42b49eb9ce4fb50c1 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:36:08 -0500 Subject: [PATCH] =?UTF-8?q?feat(soporte):=20leer=20el=20buz=C3=B3n=20por?= =?UTF-8?q?=20IMAP,=20no=20solo=20esperar=20el=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los clientes escriben a soporte@ desde su correo de siempre. Hasta ahora eso solo llegaba si un proveedor (SendGrid/Mailgun) nos hacía POST; si nadie lo configuraba, los correos quedaban sin leer en el buzón. Ahora el cron entra al buzón cada 2 minutos, baja los no leídos, abre ticket (o los engancha al hilo si son respuesta) y los marca como leídos. La lógica de ingesta se movió a services para que webhook e IMAP se comporten igual. La contraseña del buzón se guarda cifrada (AES-GCM con APP_KEY) y no vuelve al navegador. De paso, dos bugs que impedían guardar la configuración: el formulario mandaba id=0 (gorm.Model serializa "ID"), así que cada guardado creaba una fila nueva en vez de editar la que se usa; y Updates con struct ignoraba los booleanos en false, así que desactivar algo no tenía efecto. Co-Authored-By: Claude Opus 5 --- go.mod | 3 + go.sum | 6 + pkg/models/soporte_webhook.go | 51 +++- pkg/services/cron_service.go | 9 +- pkg/services/soporte_imap_service.go | 277 ++++++++++++++++++ pkg/services/soporte_imap_test.go | 56 ++++ pkg/services/soporte_ingesta_service.go | 144 +++++++++ .../services/soporte_ingesta_test.go | 26 +- resources/views/soporte_webhook.html | 112 +++++++ rest/controllers/soporte_controller.go | 201 +++++-------- rest/routes/user.go | 2 + 11 files changed, 732 insertions(+), 155 deletions(-) create mode 100644 pkg/services/soporte_imap_service.go create mode 100644 pkg/services/soporte_imap_test.go create mode 100644 pkg/services/soporte_ingesta_service.go rename rest/controllers/soporte_controller_test.go => pkg/services/soporte_ingesta_test.go (69%) diff --git a/go.mod b/go.mod index ad0880d..28e6567 100755 --- a/go.mod +++ b/go.mod @@ -75,6 +75,9 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect diff --git a/go.sum b/go.sum index e428e0d..c2504ec 100755 --- a/go.sum +++ b/go.sum @@ -175,6 +175,12 @@ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5O github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/pkg/models/soporte_webhook.go b/pkg/models/soporte_webhook.go index a83ea77..cc3ac06 100644 --- a/pkg/models/soporte_webhook.go +++ b/pkg/models/soporte_webhook.go @@ -7,23 +7,38 @@ import ( type SoporteWebhookConfig struct { gorm.Model - Activo bool `json:"activo" gorm:"column:activo;default:true"` - Nombre string `json:"nombre" gorm:"column:nombre;size:100"` - Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic - ApiKey string `json:"api_key" gorm:"column:api_key;size:255"` - EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app - ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"` - MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"` - AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user + Activo bool `json:"activo" gorm:"column:activo;default:true"` + Nombre string `json:"nombre" gorm:"column:nombre;size:100"` + Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic + ApiKey string `json:"api_key" gorm:"column:api_key;size:255"` + EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app + ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"` + MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"` + AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user // SMTP salida para notificaciones y auto-respuesta - SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"` - SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"` - SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"` - SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES + SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"` + SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"` + SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"` + SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none - SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"` - SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"` + SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"` + SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"` + + // IMAP entrante: leer el buzón directamente en vez de depender de que un + // proveedor nos haga POST. Es lo único que hace falta para responderle a un + // cliente que escribe a soporte@ desde su correo de siempre. + ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"` + ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"` + ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"` + ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"` + ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY + ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls + ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"` + + // Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para + // que el formulario sepa que puede mandar el campo vacío sin borrarla. + TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"` } func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" } @@ -42,7 +57,13 @@ func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) { func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error { if s.ID > 0 { - return app.Http.Database.DB.Model(s).Updates(s).Error + // Select("*") para que los booleanos en false (activo, responder_auto, + // imap_activo) también se guarden: Updates con struct ignora los ceros, + // así que desactivar algo no tenía efecto. + return app.Http.Database.DB.Model(&SoporteWebhookConfig{}). + Where("id = ?", s.ID). + Select("*").Omit("id", "created_at", "deleted_at"). + Updates(s).Error } return app.Http.Database.DB.Create(s).Error } diff --git a/pkg/services/cron_service.go b/pkg/services/cron_service.go index f497a0e..55f82ac 100644 --- a/pkg/services/cron_service.go +++ b/pkg/services/cron_service.go @@ -78,8 +78,15 @@ func IniciarCron() { return } + // Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está + // configurado, así que registrarlo siempre no cuesta. + if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil { + log.Printf("[CRON] Error registrando tarea soporte_imap: %v", err) + return + } + cronScheduler.Start() - log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min") + log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min, buzón de soporte cada 2min") } // DetenerCron para graceful shutdown diff --git a/pkg/services/soporte_imap_service.go b/pkg/services/soporte_imap_service.go new file mode 100644 index 0000000..b2948b4 --- /dev/null +++ b/pkg/services/soporte_imap_service.go @@ -0,0 +1,277 @@ +package services + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" + "sync" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// El poller corre por cron; este lock evita que dos corridas se pisen si una +// tarda más que el intervalo (buzón grande, servidor lento). +var imapEnCurso sync.Mutex + +// RevisarBuzonSoporte lee los correos sin leer del buzón configurado y los +// convierte en tickets. Cada correo procesado se marca como \Seen, que es lo +// que evita volver a leerlo; la deduplicación por Message-Id es el segundo +// cinturón por si el marcado falla. +func RevisarBuzonSoporte() { + cfg, err := models.GetSoporteWebhookActivo() + if err != nil || cfg == nil || !cfg.ImapActivo || cfg.ImapHost == "" { + return + } + if !imapEnCurso.TryLock() { + log.Printf("[SoporteIMAP] corrida anterior todavía en curso, se salta esta") + return + } + defer imapEnCurso.Unlock() + + n, err := revisarBuzon(cfg) + if err != nil { + log.Printf("[SoporteIMAP] %v", err) + return + } + if n > 0 { + log.Printf("[SoporteIMAP] %d correo(s) procesado(s)", n) + } +} + +// ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón +// "Probar" de la vista de configuración. +func ProbarConexionImap(cfg *models.SoporteWebhookConfig) error { + c, err := conectarImap(cfg) + if err != nil { + return err + } + defer c.Close() + carpeta := cfg.ImapCarpeta + if carpeta == "" { + carpeta = "INBOX" + } + if _, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil { + return fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err) + } + _ = c.Logout().Wait() + return nil +} + +func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) { + pass, err := DescifrarSecretoUmind(cfg.ImapPasswordEnc) + if err != nil || pass == "" { + return nil, fmt.Errorf("no hay contraseña IMAP guardada") + } + port := cfg.ImapPort + if port == 0 { + port = 993 + } + addr := fmt.Sprintf("%s:%d", cfg.ImapHost, port) + + var c *imapclient.Client + if strings.EqualFold(cfg.ImapEncryption, "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(cfg.ImapUsername, pass).Wait(); err != nil { + c.Close() + return nil, fmt.Errorf("login IMAP rechazado para %s: %w", cfg.ImapUsername, err) + } + return c, nil +} + +func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) { + c, err := conectarImap(cfg) + if err != nil { + return 0, err + } + defer c.Close() + + carpeta := cfg.ImapCarpeta + if carpeta == "" { + carpeta = "INBOX" + } + if _, err := c.Select(carpeta, nil).Wait(); err != nil { + return 0, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err) + } + + buscados, err := c.Search(&imap.SearchCriteria{ + NotFlag: []imap.Flag{imap.FlagSeen}, + }, &imap.SearchOptions{ReturnAll: true}).Wait() + if err != nil { + return 0, fmt.Errorf("búsqueda de no leídos falló: %w", err) + } + uids := buscados.AllUIDs() + if len(uids) == 0 { + return 0, nil + } + // ponytail: tope por corrida para no atragantarse con un buzón que nunca + // se leyó. Los que sobran quedan sin leer y entran en la corrida siguiente. + const maxPorCorrida = 50 + if len(uids) > maxPorCorrida { + uids = uids[:maxPorCorrida] + } + + msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{ + BodySection: []*imap.FetchItemBodySection{{}}, + }).Collect() + if err != nil { + return 0, fmt.Errorf("descarga de correos falló: %w", err) + } + + procesados := 0 + for _, m := range msgs { + var crudo []byte + for _, b := range m.BodySection { + crudo = b.Bytes + break + } + if len(crudo) == 0 { + continue + } + correo, err := parsearCorreo(crudo) + if err != nil { + log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err) + continue + } + if IngestarCorreoSoporte(cfg, correo) { + procesados++ + } + // Se marca leído aunque se haya ignorado por duplicado: si no, se + // vuelve a bajar en cada corrida para siempre. + if err := c.Store(imap.UIDSetNum(m.UID), &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil).Close(); err != nil { + log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", m.UID, err) + } + } + return procesados, nil +} + +// parsearCorreo saca remitente, asunto y cuerpo de texto de un mensaje RFC822. +func parsearCorreo(crudo []byte) (CorreoSoporte, error) { + msg, err := mail.ReadMessage(strings.NewReader(string(crudo))) + if err != nil { + return CorreoSoporte{}, err + } + dec := new(mime.WordDecoder) + decodificar := func(s string) string { + if out, err := dec.DecodeHeader(s); err == nil { + return out + } + return s + } + + from := decodificar(msg.Header.Get("From")) + nombre := ExtraerNombre(from) + if dir, err := mail.ParseAddress(from); err == nil && dir.Name != "" { + nombre = dir.Name + } + + cuerpo, err := cuerpoDeTexto(msg.Header.Get("Content-Type"), msg.Body) + if err != nil { + return CorreoSoporte{}, err + } + + return CorreoSoporte{ + From: from, + FromName: nombre, + Subject: decodificar(msg.Header.Get("Subject")), + Texto: limpiarCitas(cuerpo), + MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")), + }, nil +} + +// cuerpoDeTexto devuelve el text/plain del correo; si es multipart baja por las +// partes hasta encontrarlo y cae al HTML solo si no hay texto plano. +func cuerpoDeTexto(contentType string, cuerpo io.Reader) (string, error) { + medio, params, err := mime.ParseMediaType(contentType) + if err != nil { + medio = "text/plain" + params = map[string]string{} + } + + if !strings.HasPrefix(medio, "multipart/") { + b, err := io.ReadAll(io.LimitReader(cuerpo, 1<<20)) + if err != nil { + return "", err + } + return string(b), nil + } + + frontera := params["boundary"] + if frontera == "" { + return "", fmt.Errorf("multipart sin boundary") + } + lector := multipart.NewReader(cuerpo, frontera) + var html string + for { + parte, err := lector.NextPart() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + tipoParte, _, _ := mime.ParseMediaType(parte.Header.Get("Content-Type")) + if strings.HasPrefix(tipoParte, "multipart/") { + anidado, err := cuerpoDeTexto(parte.Header.Get("Content-Type"), parte) + if err == nil && anidado != "" { + return anidado, nil + } + continue + } + if tipoParte != "text/plain" && tipoParte != "text/html" { + continue + } + var lect io.Reader = io.LimitReader(parte, 1<<20) + switch strings.ToLower(parte.Header.Get("Content-Transfer-Encoding")) { + case "quoted-printable": + lect = quotedprintable.NewReader(lect) + case "base64": + lect = base64.NewDecoder(base64.StdEncoding, lect) + } + b, err := io.ReadAll(lect) + if err != nil { + continue + } + if tipoParte == "text/plain" { + return string(b), nil + } + html = string(b) + } + return html, nil +} + +// limpiarCitas corta el correo en la primera línea citada o en el separador +// típico de respuesta, para que el ticket no repita todo el hilo anterior. +func limpiarCitas(texto string) string { + lineas := strings.Split(strings.ReplaceAll(texto, "\r\n", "\n"), "\n") + var out []string + for _, l := range lineas { + t := strings.TrimSpace(l) + if strings.HasPrefix(t, ">") || + strings.HasPrefix(t, "-----Original Message-----") || + strings.HasPrefix(t, "-----Mensaje original-----") || + (strings.HasPrefix(t, "El ") && strings.HasSuffix(t, "escribió:")) || + (strings.HasPrefix(t, "On ") && strings.HasSuffix(t, "wrote:")) { + break + } + out = append(out, l) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} diff --git a/pkg/services/soporte_imap_test.go b/pkg/services/soporte_imap_test.go new file mode 100644 index 0000000..cd2d99b --- /dev/null +++ b/pkg/services/soporte_imap_test.go @@ -0,0 +1,56 @@ +package services + +import "testing" + +func TestParsearCorreoMultipartQuotedPrintable(t *testing.T) { + crudo := "From: =?UTF-8?Q?Juan_P=C3=A9rez?= \r\n" + + "Subject: =?UTF-8?Q?No_puedo_entrar_a_la_plataforma?=\r\n" + + "Message-Id: \r\n" + + "Content-Type: multipart/alternative; boundary=\"XX\"\r\n" + + "\r\n" + + "--XX\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + + "Hola, la contrase=C3=B1a no me sirve.\r\n" + + "\r\n" + + "El 3 de marzo, Soporte escribi=C3=B3:\r\n" + + "> proba de nuevo\r\n" + + "--XX\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n" + + "\r\n" + + "

ignorame

\r\n" + + "--XX--\r\n" + + c, err := parsearCorreo([]byte(crudo)) + if err != nil { + t.Fatalf("parsearCorreo: %v", err) + } + if c.FromName != "Juan Pérez" { + t.Errorf("FromName = %q, want %q", c.FromName, "Juan Pérez") + } + if ExtraerEmail(c.From) != "juan@cliente.com" { + t.Errorf("From = %q", c.From) + } + if c.Subject != "No puedo entrar a la plataforma" { + t.Errorf("Subject = %q", c.Subject) + } + if c.MessageID != "" { + t.Errorf("MessageID = %q", c.MessageID) + } + // Cuerpo decodificado, sin la cita del hilo anterior ni el HTML. + if c.Texto != "Hola, la contraseña no me sirve." { + t.Errorf("Texto = %q", c.Texto) + } +} + +func TestParsearCorreoPlano(t *testing.T) { + crudo := "From: ana@cliente.com\r\nSubject: Consulta\r\n\r\nHola\r\n" + c, err := parsearCorreo([]byte(crudo)) + if err != nil { + t.Fatalf("parsearCorreo: %v", err) + } + if c.Texto != "Hola" || c.Subject != "Consulta" { + t.Errorf("got %+v", c) + } +} diff --git a/pkg/services/soporte_ingesta_service.go b/pkg/services/soporte_ingesta_service.go new file mode 100644 index 0000000..2874dd9 --- /dev/null +++ b/pkg/services/soporte_ingesta_service.go @@ -0,0 +1,144 @@ +package services + +import ( + "log" + "regexp" + "strconv" + "strings" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde +// llegó (webhook de SendGrid/Mailgun o buzón IMAP). Los dos caminos terminan +// acá para que enhebrado, deduplicación y auto-respuesta se comporten igual. +type CorreoSoporte struct { + From string // puede venir como "Nombre " + FromName string + Subject string + Texto string + MessageID string +} + +var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`) + +// IngestarCorreoSoporte convierte un correo en ticket nuevo o en respuesta a un +// ticket existente. Devuelve true si se procesó algo (false = ignorado por +// duplicado o por venir vacío). +func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool { + if e.From == "" || e.Subject == "" { + return false + } + + // Deduplicación: el proveedor puede reintentar la entrega, y el poller IMAP + // puede releer un correo si falló el marcado como leído. + if models.EmailMessageIDYaProcesado(e.MessageID) { + log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID) + return false + } + + fromEmail := ExtraerEmail(e.From) + fromName := e.FromName + if fromName == "" { + fromName = ExtraerNombre(e.From) + } + if fromName == "" { + fromName = fromEmail + } + + contenido := strings.TrimSpace(e.Texto) + if len(contenido) > 5000 { + contenido = contenido[:5000] + } + + // Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el + // auto-ack) y ese ticket es del mismo remitente, es una respuesta — se + // agrega como mensaje en vez de abrir un ticket nuevo. + if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil { + msg := &models.TicketMensaje{ + TicketID: hilo.ID, + Contenido: contenido, + EsAdmin: false, + AutorNombre: fromName, + MessageID: e.MessageID, + } + if err := models.CreateTicketMensaje(msg); err != nil { + log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err) + return false + } + if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" { + _ = models.UpdateTicketEstado(hilo.ID, "abierto") + } + log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail) + return true + } + + ticket := &models.ProyectoTicket{ + AutorNombre: fromName, + EmailFrom: fromEmail, + Titulo: e.Subject, + Descripcion: contenido, + Estado: "abierto", + Origen: "email", + MessageID: e.MessageID, + } + if cfg != nil && cfg.AsignarA != nil { + ticket.AsignadoA = cfg.AsignarA + } + if err := models.CreateProyectoTicket(ticket); err != nil { + log.Printf("[Soporte] Error creando ticket: %v", err) + return false + } + log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject) + + SendSoporteNotifAdmin(ticket) + if cfg != nil && cfg.ResponderAuto { + SendSoporteAutoRespuesta(ticket) + } + return true +} + +// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta: +// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo +// remitente, para que nadie pueda inyectar mensajes en el ticket de otro +// adivinando el número), y si no hay token, por el último ticket abierto de ese +// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:). +func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket { + if m := ticketRefRe.FindStringSubmatch(subject); m != nil { + id, _ := strconv.ParseUint(m[1], 10, 32) + if id > 0 { + t, err := models.GetTicketByID(uint(id)) + if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) { + return t + } + } + } + lower := strings.ToLower(strings.TrimSpace(subject)) + if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") { + if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil { + return t + } + } + return nil +} + +// ExtraerEmail saca la dirección de un "Nombre ". +func ExtraerEmail(s string) string { + s = strings.TrimSpace(s) + if idx := strings.LastIndex(s, "<"); idx >= 0 { + s = s[idx+1:] + } + if idx := strings.LastIndex(s, ">"); idx >= 0 { + s = s[:idx] + } + return strings.TrimSpace(s) +} + +// ExtraerNombre saca el nombre de un "Nombre " ("" si no trae). +func ExtraerNombre(s string) string { + s = strings.TrimSpace(s) + if idx := strings.Index(s, "<"); idx >= 0 { + return strings.TrimSpace(s[:idx]) + } + return "" +} diff --git a/rest/controllers/soporte_controller_test.go b/pkg/services/soporte_ingesta_test.go similarity index 69% rename from rest/controllers/soporte_controller_test.go rename to pkg/services/soporte_ingesta_test.go index 933ef7c..9f9f74d 100644 --- a/rest/controllers/soporte_controller_test.go +++ b/pkg/services/soporte_ingesta_test.go @@ -1,4 +1,4 @@ -package controllers +package services import ( "testing" @@ -18,9 +18,9 @@ func TestExtractEmailSimple(t *testing.T) { {"", "onlybrackets"}, } for _, tt := range tests { - got := extractEmail(tt.input) + got := ExtraerEmail(tt.input) if got != tt.want { - t.Errorf("extractEmail(%q) = %q, want %q", tt.input, got, tt.want) + t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.input, got, tt.want) } } } @@ -38,9 +38,9 @@ func TestExtractNameSimple(t *testing.T) { {"", ""}, } for _, tt := range tests { - got := extractName(tt.input) + got := ExtraerNombre(tt.input) if got != tt.want { - t.Errorf("extractName(%q) = %q, want %q", tt.input, got, tt.want) + t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.input, got, tt.want) } } } @@ -57,24 +57,24 @@ func TestExtractEmailRealWorld(t *testing.T) { {"", "", ""}, } for _, tt := range inputs { - gotMail := extractEmail(tt.full) - gotName := extractName(tt.full) + gotMail := ExtraerEmail(tt.full) + gotName := ExtraerNombre(tt.full) if gotMail != tt.mail { - t.Errorf("extractEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail) + t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail) } if gotName != tt.name { - t.Errorf("extractName(%q) = %q, want %q", tt.full, gotName, tt.name) + t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.full, gotName, tt.name) } } } func TestExtractEmailEdgeCases(t *testing.T) { // Formato RFC 5322 con nombre y ángulos - if got := extractEmail("a"); got != "b@c.com" { - t.Errorf("extractEmail('a') = %q, want 'b@c.com'", got) + if got := ExtraerEmail("a"); got != "b@c.com" { + t.Errorf("ExtraerEmail('a') = %q, want 'b@c.com'", got) } // Múltiples brackets — usa el último par - if got := extractEmail(""); got != "b@c.com" { - t.Errorf("extractEmail('') = %q, want 'b@c.com'", got) + if got := ExtraerEmail(""); got != "b@c.com" { + t.Errorf("ExtraerEmail('') = %q, want 'b@c.com'", got) } } diff --git a/resources/views/soporte_webhook.html b/resources/views/soporte_webhook.html index bc39fe2..b2eecfe 100644 --- a/resources/views/soporte_webhook.html +++ b/resources/views/soporte_webhook.html @@ -110,6 +110,76 @@

Si se deja vacío, se usará la configuración SMTP general del sistema.

+ +
+
+

Buzón entrante (IMAP)

+ +
+

+ Alternativa al webhook: en vez de esperar que el proveedor nos avise, entramos al buzón y bajamos + los correos sin leer. Cada uno abre un ticket (o se agrega al hilo si es respuesta) y queda marcado como leído. +

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+

Probá la conexión después de guardar: la contraseña se cifra al guardarse.

+
+