diff --git a/pkg/services/notif_dispatch.go b/pkg/services/notif_dispatch.go index 1938c99..ba82f15 100644 --- a/pkg/services/notif_dispatch.go +++ b/pkg/services/notif_dispatch.go @@ -412,10 +412,22 @@ func sendTelegramAdmin(mensaje string) { log.Printf("[Notif] Error obteniendo configs telegram: %v", err) return } + // Un mismo chat puede estar cargado en más de una configuración (dos bots + // apuntando al mismo grupo, o la misma config duplicada). Sin esto, cada + // aviso llega repetido tantas veces como filas haya. + yaEnviado := map[string]bool{} + for _, cfg := range configs { if !cfg.Activo { continue } + destino := cfg.BotToken + "→" + cfg.ChatID + if yaEnviado[destino] { + log.Printf("[Notif] Chat %s repetido en la config %d (%s), no se manda de nuevo", cfg.ChatID, cfg.ID, cfg.Nombre) + continue + } + yaEnviado[destino] = true + ts := &TelegramService{BotToken: cfg.BotToken} sendErr := ts.SendMessage(cfg.ChatID, mensaje) logEntry := &models.TelegramLog{ diff --git a/pkg/services/soporte_service.go b/pkg/services/soporte_service.go index 7bda731..dfd9bea 100644 --- a/pkg/services/soporte_service.go +++ b/pkg/services/soporte_service.go @@ -6,6 +6,7 @@ import ( "log" "net/smtp" "strings" + "sync" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" @@ -33,61 +34,87 @@ func soporteSendMail(to, subject, htmlBody string) error { if port == 0 { port = 587 } - auth := smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost) - 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", fromName, from, to, subject, htmlBody)) addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port) - enc := strings.ToLower(cfg.SmtpEncryption) - if enc == "tls" { - tlsCfg := &tls.Config{ServerName: cfg.SmtpHost} - conn, err := tls.Dial("tcp", addr, tlsCfg) - if err != nil { - return fmt.Errorf("soporte SMTP TLS: %w", err) - } - client, err := smtp.NewClient(conn, cfg.SmtpHost) - if err != nil { - conn.Close() - return fmt.Errorf("soporte SMTP client: %w", err) - } - defer client.Close() - if err = client.Auth(auth); err != nil { - return fmt.Errorf("soporte SMTP auth: %w", err) - } - if err = client.Mail(from); err != nil { - return err - } - if err = client.Rcpt(to); err != nil { - return err - } - w, err := client.Data() - if err != nil { - return err - } - _, err = w.Write(msg) - if err != nil { - return err - } - return w.Close() - } - if enc == "starttls" { - tlsCfg := &tls.Config{ServerName: cfg.SmtpHost} - conn, err := smtp.Dial(addr) - if err != nil { - goto fallback - } - if err = conn.StartTLS(tlsCfg); err != nil { - conn.Close() - goto fallback - } - if err = conn.Auth(auth); err != nil { - conn.Close() - return fmt.Errorf("soporte SMTP STARTTLS auth: %w", err) - } - return smtp.SendMail(addr, auth, from, []string{to}, msg) - } - return smtp.SendMail(addr, auth, from, []string{to}, msg) + 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", + fromName, from, to, subject, htmlBody)) -fallback: - return app.Http.Mail.Send(to, subject, htmlBody) + if err := enviarPorSMTP(cfg, addr, from, to, msg); err != nil { + return fmt.Errorf("SMTP de soporte (%s): %w", addr, err) + } + 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 +// 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. +func ProbarEnvioSoporte(destino string) error { + if strings.TrimSpace(destino) == "" { + return fmt.Errorf("indicá a qué dirección mandar la prueba") + } + cuerpo := `

Esto es una prueba del envío de soporte.

+

Si te llegó, el acuse automático de los tickets también va a salir por acá.

` + return soporteSendMail(destino, "Prueba de envío de soporte", cuerpo) } // SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email @@ -123,8 +150,10 @@ func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) { go func() { if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil { log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err) + guardarErrorAcuse(fmt.Sprintf("el acuse al cliente %s no salió: %v", ticket.EmailFrom, err)) } else { log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID) + guardarErrorAcuse("") } }() } @@ -165,3 +194,25 @@ func SendSoporteNotifAdmin(ticket *models.ProyectoTicket) { } }() } + +// El acuse se manda en segundo plano, así que su error no puede devolverse al +// que creó el ticket. Se guarda acá para poder mostrarlo en la pantalla de +// configuración, que es donde alguien lo va a ver. +var ( + muErrorAcuse sync.Mutex + ultimoErrAcuse string +) + +func guardarErrorAcuse(msg string) { + muErrorAcuse.Lock() + defer muErrorAcuse.Unlock() + ultimoErrAcuse = msg +} + +// UltimoErrorAcuse devuelve el último fallo al mandarle el acuse a un cliente +// ("" si el último salió bien). +func UltimoErrorAcuse() string { + muErrorAcuse.Lock() + defer muErrorAcuse.Unlock() + return ultimoErrAcuse +} diff --git a/resources/views/soporte_webhook.html b/resources/views/soporte_webhook.html index ce17b02..c9c631f 100644 --- a/resources/views/soporte_webhook.html +++ b/resources/views/soporte_webhook.html @@ -108,6 +108,21 @@

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

+ +
+ + + +
+

+ Sale por el mismo camino que el acuse automático que recibe el cliente cuando se le crea un ticket. + Si esta prueba falla, ese acuse tampoco está llegando. +

@@ -291,6 +306,9 @@ function soporteWebhook() { ocupado: false, mensajeImap: '', errorImap: false, + emailPrueba: '', + mensajeEnvio: '', + errorEnvio: false, async init() { try { @@ -381,6 +399,20 @@ function soporteWebhook() { this.ocupado = false; }, + async probarEnvio() { + this.ocupado = true; + this.mensajeEnvio = ''; + try { + const r = await axios.post('/app/soporte/webhook/probar-envio', { email: this.emailPrueba }); + this.errorEnvio = false; + this.mensajeEnvio = r.data?.message || 'Enviado'; + } catch (e) { + this.errorEnvio = true; + this.mensajeEnvio = e.response?.data?.error || e.message; + } + this.ocupado = false; + }, + probarImap() { return this.llamarImap('/app/soporte/webhook/probar-imap', 'Conexión correcta'); }, revisarAhora() { return this.llamarImap('/app/soporte/webhook/revisar-buzon', 'Buzón revisado'); }, }; diff --git a/rest/controllers/soporte_controller.go b/rest/controllers/soporte_controller.go index c297d4b..281e5a8 100644 --- a/rest/controllers/soporte_controller.go +++ b/rest/controllers/soporte_controller.go @@ -343,6 +343,9 @@ func RevisarBuzonAhora(c *fiber.Ctx) error { if descartes := services.UltimosCorreosDescartados(); len(descartes) > 0 { msg += fmt.Sprintf(" El filtro descartó %d: %s", len(descartes), strings.Join(descartes, " | ")) } + if e := services.UltimoErrorAcuse(); e != "" { + msg += " ⚠️ " + e + } return c.JSON(fiber.Map{"ok": true, "message": msg}) } @@ -380,3 +383,19 @@ func GetAgentesParaBorrador(c *fiber.Ctx) error { } return c.JSON(salida) } + +// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que el acuse +// automático de los tickets. +// POST /app/soporte/webhook/probar-envio +func ProbarEnvioSoporte(c *fiber.Ctx) error { + var b struct { + Email string `json:"email"` + } + if err := c.BodyParser(&b); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + if err := services.ProbarEnvioSoporte(b.Email); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true, "message": "Correo de prueba enviado a " + b.Email}) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index a4647d9..eaabf09 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -540,6 +540,7 @@ func UserRoutes(app fiber.Router) { protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte) protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora) protected.Get("/soporte/agentes", controllers.GetAgentesParaBorrador) + protected.Post("/soporte/webhook/probar-envio", controllers.ProbarEnvioSoporte) // Configuración de notificaciones protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)