Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el correo y decide si es soporte o ruido —newsletters, notificaciones de plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que tener un ticket de más. Hay un campo de contexto del negocio para los casos raros de cada uno. Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe, Precedence, Auto-Submitted) sin gastar una llamada de IA. Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la deduplicación era solo un SELECT previo, que no sirve si el flag de leído no llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo se trata como "ya estaba", no como error — sin ticket repetido y sin segunda auto-respuesta. Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en vez de dejarlo solo en el log: es la señal de que van a volver a leerse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
362 lines
11 KiB
Go
362 lines
11 KiB
Go
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()
|
|
|
|
encontrados, procesados, err := revisarBuzon(cfg)
|
|
if err != nil {
|
|
log.Printf("[SoporteIMAP] %v", err)
|
|
return
|
|
}
|
|
if encontrados > 0 {
|
|
log.Printf("[SoporteIMAP] %d sin leer, %d procesado(s)", encontrados, procesados)
|
|
}
|
|
}
|
|
|
|
// RevisarBuzonSoporteConDetalle es la versión que usa el botón "Revisar buzón
|
|
// ahora": devuelve los números para poder mostrarlos, en vez de solo loguearlos.
|
|
func RevisarBuzonSoporteConDetalle() (encontrados, procesados int, err error) {
|
|
cfg, err := models.GetSoporteWebhookActivo()
|
|
if err != nil || cfg == nil {
|
|
return 0, 0, fmt.Errorf("no hay configuración de soporte guardada")
|
|
}
|
|
if !cfg.ImapActivo {
|
|
return 0, 0, fmt.Errorf("la lectura del buzón está desactivada: tildá \"Leer el buzón cada 2 minutos\" y guardá")
|
|
}
|
|
if cfg.ImapHost == "" {
|
|
return 0, 0, fmt.Errorf("falta el servidor IMAP en la configuración")
|
|
}
|
|
if !imapEnCurso.TryLock() {
|
|
return 0, 0, fmt.Errorf("hay una revisión en curso, probá en unos segundos")
|
|
}
|
|
defer imapEnCurso.Unlock()
|
|
return revisarBuzon(cfg)
|
|
}
|
|
|
|
// ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón
|
|
// "Probar" de la vista de configuración.
|
|
func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) {
|
|
c, err := conectarImap(cfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer c.Close()
|
|
carpeta := cfg.ImapCarpeta
|
|
if carpeta == "" {
|
|
carpeta = "INBOX"
|
|
}
|
|
datos, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait()
|
|
if err != nil {
|
|
return "", fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
|
}
|
|
sinLeer := 0
|
|
if r, err := c.UIDSearch(&imap.SearchCriteria{
|
|
NotFlag: []imap.Flag{imap.FlagSeen},
|
|
}, &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil {
|
|
sinLeer = len(r.AllUIDs())
|
|
}
|
|
_ = c.Logout().Wait()
|
|
return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d sin leer.", carpeta, datos.NumMessages, sinLeer), 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, int, error) {
|
|
c, err := conectarImap(cfg)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer c.Close()
|
|
|
|
correos, err := BajarNoLeidos(c, cfg.ImapCarpeta)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if len(correos) == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
fallosAlMarcar := 0
|
|
|
|
procesados := 0
|
|
for _, correo := range correos {
|
|
if IngestarCorreoSoporte(cfg, correo.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 := MarcarLeido(c, correo.UID); err != nil {
|
|
log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", correo.UID, err)
|
|
fallosAlMarcar++
|
|
}
|
|
}
|
|
if fallosAlMarcar > 0 {
|
|
return len(correos), procesados, fmt.Errorf(
|
|
"se procesaron %d correo(s), pero %d no se pudieron marcar como leídos en el servidor: van a volver a leerse",
|
|
procesados, fallosAlMarcar)
|
|
}
|
|
return len(correos), procesados, nil
|
|
}
|
|
|
|
// CorreoConUID es un correo del buzón junto con su UID, que es lo que hace
|
|
// falta después para marcarlo como leído.
|
|
type CorreoConUID struct {
|
|
UID imap.UID
|
|
Correo CorreoSoporte
|
|
}
|
|
|
|
// BajarNoLeidos abre la carpeta, busca los mensajes sin leer y los devuelve ya
|
|
// parseados. No los marca: eso pasa recién cuando se procesaron.
|
|
func BajarNoLeidos(c *imapclient.Client, carpeta string) ([]CorreoConUID, error) {
|
|
if carpeta == "" {
|
|
carpeta = "INBOX"
|
|
}
|
|
if _, err := c.Select(carpeta, nil).Wait(); err != nil {
|
|
return nil, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
|
}
|
|
|
|
// UIDSearch y no Search: Search devuelve números de secuencia, y de ahí no
|
|
// salen UIDs — la búsqueda "encontraba" mensajes y la lista quedaba vacía.
|
|
buscados, err := c.UIDSearch(&imap.SearchCriteria{
|
|
NotFlag: []imap.Flag{imap.FlagSeen},
|
|
}, &imap.SearchOptions{ReturnAll: true}).Wait()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err)
|
|
}
|
|
uids := buscados.AllUIDs()
|
|
if len(uids) == 0 {
|
|
return nil, 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]
|
|
}
|
|
|
|
// Peek para que el correo quede leído solo si llegamos a procesarlo.
|
|
msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{
|
|
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
|
|
}).Collect()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("descarga de correos falló: %w", err)
|
|
}
|
|
|
|
var out []CorreoConUID
|
|
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
|
|
}
|
|
out = append(out, CorreoConUID{UID: m.UID, Correo: correo})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// MarcarLeido pone el flag \Seen, que es lo que evita releer el mismo correo.
|
|
func MarcarLeido(c *imapclient.Client, uid imap.UID) error {
|
|
return c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
|
|
Op: imap.StoreFlagsAdd,
|
|
Silent: true,
|
|
Flags: []imap.Flag{imap.FlagSeen},
|
|
}, nil).Close()
|
|
}
|
|
|
|
// 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")),
|
|
Automatico: esCorreoAutomatico(msg.Header),
|
|
}, 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"))
|
|
}
|
|
|
|
// esCorreoAutomatico reconoce newsletters y notificaciones de máquina por sus
|
|
// encabezados estándar. Es gratis y no se equivoca, así que va antes que
|
|
// cualquier modelo.
|
|
func esCorreoAutomatico(h mail.Header) bool {
|
|
if h.Get("List-Unsubscribe") != "" || h.Get("List-Id") != "" {
|
|
return true
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
|
|
case "bulk", "list", "junk":
|
|
return true
|
|
}
|
|
if a := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); a != "" && a != "no" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|