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>
145 lines
4.4 KiB
Go
145 lines
4.4 KiB
Go
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 <mail@x.com>"
|
|
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 <mail@x.com>".
|
|
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 <mail@x.com>" ("" 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 ""
|
|
}
|