feat(soporte): leer el buzón por IMAP, no solo esperar el webhook
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1ebae791f6
commit
8c3ab79e88
@@ -5,7 +5,6 @@ import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -28,7 +27,6 @@ type soporteEmailIn struct {
|
||||
MessageID string `json:"message_id" form:"message_id"`
|
||||
}
|
||||
|
||||
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
||||
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
|
||||
|
||||
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
|
||||
@@ -121,111 +119,19 @@ func SoporteWebhook(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
for _, e := range emails {
|
||||
if e.From == "" || e.Subject == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Deduplicación: el proveedor puede reintentar la entrega del mismo correo.
|
||||
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
||||
log.Printf("[SoporteWebhook] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
||||
continue
|
||||
}
|
||||
|
||||
fromEmail := extractEmail(e.From)
|
||||
fromName := e.FromName
|
||||
if fromName == "" {
|
||||
fromName = extractName(e.From)
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = fromEmail
|
||||
}
|
||||
|
||||
contenido := e.Text
|
||||
if contenido == "" {
|
||||
contenido = e.Html
|
||||
}
|
||||
contenido = strings.TrimSpace(contenido)
|
||||
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. Si no hay token
|
||||
// pero el remitente tiene un ticket abierto reciente, también se enhebra.
|
||||
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("[SoporteWebhook] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
||||
continue
|
||||
}
|
||||
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
||||
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
||||
}
|
||||
log.Printf("[SoporteWebhook] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
||||
continue
|
||||
}
|
||||
|
||||
ticket := &models.ProyectoTicket{
|
||||
AutorNombre: fromName,
|
||||
EmailFrom: fromEmail,
|
||||
Titulo: e.Subject,
|
||||
Descripcion: contenido,
|
||||
Estado: "abierto",
|
||||
Origen: "email",
|
||||
MessageID: e.MessageID,
|
||||
}
|
||||
if cfg.AsignarA != nil {
|
||||
ticket.AsignadoA = cfg.AsignarA
|
||||
}
|
||||
if err := models.CreateProyectoTicket(ticket); err != nil {
|
||||
log.Printf("[SoporteWebhook] Error creando ticket: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[SoporteWebhook] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||
|
||||
// Notificar admin
|
||||
services.SendSoporteNotifAdmin(ticket)
|
||||
|
||||
// Auto-responder (el asunto incluye [Ticket #N] para poder enhebrar la respuesta)
|
||||
if cfg.ResponderAuto {
|
||||
services.SendSoporteAutoRespuesta(ticket)
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func extractMessageIDFromHeaders(headers string) string {
|
||||
if headers == "" {
|
||||
return ""
|
||||
@@ -291,6 +197,7 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"data": nil})
|
||||
}
|
||||
cfg.TieneImapPassword = cfg.ImapPasswordEnc != ""
|
||||
return c.JSON(fiber.Map{"data": cfg})
|
||||
}
|
||||
|
||||
@@ -311,6 +218,13 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
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"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
@@ -324,22 +238,56 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
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,
|
||||
Activo: true,
|
||||
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,
|
||||
Activo: true,
|
||||
}
|
||||
cfg.ID = b.ID
|
||||
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
||||
@@ -350,21 +298,22 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func extractEmail(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
||||
s = s[idx+1:]
|
||||
// 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"})
|
||||
}
|
||||
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
if err := services.ProbarConexionImap(cfg); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Conexión IMAP correcta"})
|
||||
}
|
||||
|
||||
func extractName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "<"); idx >= 0 {
|
||||
return strings.TrimSpace(s[:idx])
|
||||
}
|
||||
return ""
|
||||
// RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
|
||||
// POST /app/api/soporte-webhook/revisar-buzon
|
||||
func RevisarBuzonAhora(c *fiber.Ctx) error {
|
||||
services.RevisarBuzonSoporte()
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Buzón revisado, mirá la lista de tickets"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user