Files
soft_usite/rest/controllers/soporte_controller.go
T
Lizandro GuarnizoandClaude Opus 5 6afad025c2 feat(soporte): leer solo los correos recientes, no todo el buzón
Sin ventana, la primera corrida convierte en tickets todo lo que haya sin leer,
que en una casilla de años es una avalancha. Ahora hay un desplegable de
antigüedad máxima —6h, 12h, 24h, 3 días o todo— y por defecto 12 horas.

El corte se hace en dos pasos porque IMAP no da para más: al servidor se le
pide SINCE con un día de margen (SINCE compara solo la fecha, no la hora) y el
corte fino por hora se aplica contra la fecha real de cada mensaje. Afinar el
SINCE sería perder los correos del borde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:39:00 -05:00

340 lines
11 KiB
Go

package controllers
import (
"crypto/subtle"
"fmt"
"log"
"regexp"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// ─── Webhook de correo entrante ───────────────────────────────────────────────
// Recibe notificaciones de SendGrid, Mailgun, etc.
// POST /webhooks/soporte/:provider?key=<ApiKey de la config activa>
// provider: sendgrid, mailgun, generic
type soporteEmailIn struct {
From string `json:"from" form:"from"`
Subject string `json:"subject" form:"subject"`
Text string `json:"text" form:"text"`
Html string `json:"html" form:"html"`
Sender string `json:"sender" form:"sender"`
FromName string `json:"from_name" form:"from_name"`
MessageID string `json:"message_id" form:"message_id"`
}
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
// X-Api-Key) contra el ApiKey guardado en la config activa, en tiempo constante.
// Si la config no tiene ApiKey configurado, se rechaza toda petición: hay que
// definirlo en /app/soporte-webhook y usar esa misma URL con ?key=... en el proveedor.
func validarWebhookKey(c *fiber.Ctx, cfg *models.SoporteWebhookConfig) bool {
if cfg.ApiKey == "" {
return false
}
key := c.Query("key")
if key == "" {
key = c.Get("X-Webhook-Key")
}
if key == "" {
key = c.Get("X-Api-Key")
}
if key == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.ApiKey)) == 1
}
func SoporteWebhook(c *fiber.Ctx) error {
provider := c.Params("provider", "generic")
cfg, err := models.GetSoporteWebhookActivo()
if err != nil {
log.Printf("[SoporteWebhook] Sin config activa: %v", err)
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "sin config"})
}
if !validarWebhookKey(c, cfg) {
log.Printf("[SoporteWebhook] Petición rechazada (key inválida o ausente) desde IP %s", c.IP())
return c.Status(401).JSON(fiber.Map{"ok": false, "error": "no autorizado"})
}
var emails []soporteEmailIn
switch provider {
case "sendgrid":
var sg struct {
From string `json:"from" form:"from"`
Subject string `json:"subject" form:"subject"`
Text string `json:"text" form:"text"`
Html string `json:"html" form:"html"`
Sender string `json:"sender" form:"sender"`
FromName string `json:"from_name" form:"from_name"`
Headers string `json:"headers" form:"headers"`
}
if err := c.BodyParser(&sg); err != nil {
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "body inválido"})
}
emails = append(emails, soporteEmailIn{
From: sg.From, Subject: sg.Subject, Text: sg.Text, Html: sg.Html,
Sender: sg.Sender, FromName: sg.FromName, MessageID: extractMessageIDFromHeaders(sg.Headers),
})
case "mailgun":
var mg struct {
From string `form:"from"`
Subject string `form:"subject"`
Text string `form:"body-plain"`
Html string `form:"body-html"`
Sender string `form:"sender"`
FromName string `form:"from_name"`
MessageID string `form:"Message-Id"`
}
if err := c.BodyParser(&mg); err != nil {
return c.Status(200).JSON(fiber.Map{"ok": false})
}
emails = append(emails, soporteEmailIn{
From: mg.From, Subject: mg.Subject, Text: mg.Text,
Html: mg.Html, Sender: mg.Sender, FromName: mg.FromName, MessageID: mg.MessageID,
})
default:
var generic struct {
From string `json:"from"`
Subject string `json:"subject"`
Text string `json:"text"`
Html string `json:"html"`
MessageID string `json:"message_id"`
}
if err := c.BodyParser(&generic); err != nil {
return c.Status(200).JSON(fiber.Map{"ok": false})
}
emails = append(emails, soporteEmailIn{
From: generic.From, Subject: generic.Subject, Text: generic.Text, Html: generic.Html,
MessageID: generic.MessageID,
})
}
for _, e := range emails {
contenido := e.Text
if contenido == "" {
contenido = e.Html
}
services.IngestarCorreoSoporte(cfg, services.CorreoSoporte{
From: e.From, FromName: e.FromName, Subject: e.Subject,
Texto: contenido, MessageID: e.MessageID,
})
}
return c.Status(200).JSON(fiber.Map{"ok": true})
}
func extractMessageIDFromHeaders(headers string) string {
if headers == "" {
return ""
}
if m := messageIDHeaderRe.FindStringSubmatch(headers); m != nil {
return m[1]
}
return ""
}
// ─── Asignar ticket a usuario ─────────────────────────────────────────────────
// PUT /app/tickets/:ticketID/asignar
func AsignarTicket(c *fiber.Ctx) error {
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
type Req struct {
AsignadoID uint `json:"asignado_id"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
if err := app.Http.Database.DB.Model(&models.ProyectoTicket{}).
Where("id = ?", ticketID).
Update("asignado_a", req.AsignadoID).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Listar admins para asignación ────────────────────────────────────────────
// GET /app/tickets/admins
func GetSoporteAdmins(c *fiber.Ctx) error {
var admins []models.Users
app.Http.Database.DB.Where("is_admin = ?", true).
Or("role_id IN (SELECT id FROM roles WHERE name = 'soporte' OR name = 'admin')").
Find(&admins)
type item struct {
ID uint `json:"id"`
Name string `json:"name"`
}
result := make([]item, 0)
for _, a := range admins {
result = append(result, item{ID: a.ID, Name: a.Name})
}
return c.JSON(result)
}
// ─── Configuración del webhook ────────────────────────────────────────────────
func SoporteWebhookConfigPage(c *fiber.Ctx) error {
cfg, _ := models.GetSoporteWebhookActivo()
return c.Render("soporte_webhook", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
"cfg": cfg,
}, "layouts/main")
}
func GetSoporteWebhookConfig(c *fiber.Ctx) error {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil {
return c.JSON(fiber.Map{"data": nil})
}
cfg.TieneImapPassword = cfg.ImapPasswordEnc != ""
return c.JSON(fiber.Map{"data": cfg})
}
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
type body struct {
ID uint `json:"id"`
Nombre string `json:"nombre"`
Provider string `json:"provider"`
ApiKey string `json:"api_key"`
EmailDestino string `json:"email_destino"`
ResponderAuto bool `json:"responder_auto"`
MensajeAuto string `json:"mensaje_auto"`
AsignarA *uint `json:"asignar_a"`
SmtpHost string `json:"smtp_host"`
SmtpPort int `json:"smtp_port"`
SmtpUsername string `json:"smtp_username"`
SmtpPassword string `json:"smtp_password"`
SmtpEncryption string `json:"smtp_encryption"`
SmtpFromAddr string `json:"smtp_from_addr"`
SmtpFromName string `json:"smtp_from_name"`
ImapActivo bool `json:"imap_activo"`
ImapHost string `json:"imap_host"`
ImapPort int `json:"imap_port"`
ImapUsername string `json:"imap_username"`
ImapPassword string `json:"imap_password"`
ImapEncryption string `json:"imap_encryption"`
ImapCarpeta string `json:"imap_carpeta"`
ImapHorasAtras int `json:"imap_horas_atras"`
ClasificarConIA bool `json:"clasificar_con_ia"`
ContextoNegocio string `json:"contexto_negocio"`
}
var b body
if err := c.BodyParser(&b); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
port := b.SmtpPort
if port == 0 {
port = 587
}
enc := b.SmtpEncryption
if enc == "" {
enc = "starttls"
}
imapPort := b.ImapPort
if imapPort == 0 {
imapPort = 993
}
imapEnc := b.ImapEncryption
if imapEnc == "" {
imapEnc = "ssl"
}
carpeta := b.ImapCarpeta
if carpeta == "" {
carpeta = "INBOX"
}
// La contraseña IMAP solo viaja cuando el admin la escribe de nuevo: si el
// formulario la manda vacía, se conserva la que ya estaba guardada.
passEnc := ""
if b.ImapPassword != "" {
enc, err := services.CifrarSecretoUmind(b.ImapPassword)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
passEnc = enc
} else if b.ID > 0 {
if actual, err := models.GetSoporteWebhookActivo(); err == nil && actual != nil && actual.ID == b.ID {
passEnc = actual.ImapPasswordEnc
}
}
cfg := &models.SoporteWebhookConfig{
Nombre: b.Nombre,
Provider: b.Provider,
ApiKey: b.ApiKey,
EmailDestino: b.EmailDestino,
ResponderAuto: b.ResponderAuto,
MensajeAuto: b.MensajeAuto,
AsignarA: b.AsignarA,
SmtpHost: b.SmtpHost,
SmtpPort: port,
SmtpUsername: b.SmtpUsername,
SmtpPassword: b.SmtpPassword,
SmtpEncryption: enc,
SmtpFromAddr: b.SmtpFromAddr,
SmtpFromName: b.SmtpFromName,
ImapActivo: b.ImapActivo,
ImapHost: b.ImapHost,
ImapPort: imapPort,
ImapUsername: b.ImapUsername,
ImapPasswordEnc: passEnc,
ImapEncryption: imapEnc,
ImapCarpeta: carpeta,
ImapHorasAtras: b.ImapHorasAtras,
ClasificarConIA: b.ClasificarConIA,
ContextoNegocio: b.ContextoNegocio,
Activo: true,
}
cfg.ID = b.ID
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// ProbarImapSoporte valida las credenciales del buzón contra el servidor.
// POST /app/api/soporte-webhook/probar-imap
func ProbarImapSoporte(c *fiber.Ctx) error {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil {
return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"})
}
detalle, err := services.ProbarConexionImap(cfg)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "message": detalle})
}
// RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
// POST /app/api/soporte-webhook/revisar-buzon
func RevisarBuzonAhora(c *fiber.Ctx) error {
encontrados, procesados, err := services.RevisarBuzonSoporteConDetalle()
if err != nil {
if encontrados > 0 {
// Se hizo trabajo igual: decir las dos cosas, no solo el fallo.
return c.Status(200).JSON(fiber.Map{"ok": false, "message": fmt.Sprintf(
"%d correo(s) encontrados, %d convertido(s) en ticket — %s", encontrados, procesados, err.Error())})
}
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
if encontrados == 0 {
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
}
return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf(
"%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)})
}