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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsearCorreoMultipartQuotedPrintable(t *testing.T) {
|
||||
crudo := "From: =?UTF-8?Q?Juan_P=C3=A9rez?= <juan@cliente.com>\r\n" +
|
||||
"Subject: =?UTF-8?Q?No_puedo_entrar_a_la_plataforma?=\r\n" +
|
||||
"Message-Id: <abc123@cliente.com>\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" +
|
||||
"<p>ignorame</p>\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 != "<abc123@cliente.com>" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 <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 ""
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractEmailSimple(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"user@example.com", "user@example.com"},
|
||||
{"<user@example.com>", "user@example.com"},
|
||||
{"John Doe <john@example.com>", "john@example.com"},
|
||||
{"\"John Doe\" <john@example.com>", "john@example.com"},
|
||||
{" spaced@example.com ", "spaced@example.com"},
|
||||
{"", ""},
|
||||
{"<onlybrackets>", "onlybrackets"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ExtraerEmail(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNameSimple(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"John Doe <john@example.com>", "John Doe"},
|
||||
{"<user@example.com>", ""},
|
||||
{"user@example.com", ""},
|
||||
{" Spaces Here <spaces@example.com>", "Spaces Here"},
|
||||
{"\"Quoted Name\" <q@example.com>", "\"Quoted Name\""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ExtraerNombre(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmailRealWorld(t *testing.T) {
|
||||
inputs := []struct {
|
||||
full string
|
||||
mail string
|
||||
name string
|
||||
}{
|
||||
{"María López <maria@example.com>", "maria@example.com", "María López"},
|
||||
{"soporte@u-s.app", "soporte@u-s.app", ""},
|
||||
{"Cliente Final <cliente+tag@dominio.co>", "cliente+tag@dominio.co", "Cliente Final"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, tt := range inputs {
|
||||
gotMail := ExtraerEmail(tt.full)
|
||||
gotName := ExtraerNombre(tt.full)
|
||||
if gotMail != tt.mail {
|
||||
t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
|
||||
}
|
||||
if 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 := ExtraerEmail("a<b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("ExtraerEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
|
||||
}
|
||||
// Múltiples brackets — usa el último par
|
||||
if got := ExtraerEmail("<a><b@c.com>"); got != "b@c.com" {
|
||||
t.Errorf("ExtraerEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user