Una respuesta a un hilo existente saltea el clasificador; un correo nuevo no. Por eso el sistema parecía funcionar solo con las respuestas: los correos nuevos que el modelo consideraba "no soporte" desaparecían sin ticket, sin acuse al cliente y sin más rastro que un renglón de log que nadie mira. Dos cambios: - El filtro solo opina sobre desconocidos. Si el remitente es un cliente o un usuario del portal, siempre se abre el ticket. Para eso la resolución del remitente pasa a correr antes del filtro. A un cliente registrado no se le descarta el correo por lo que diga un modelo. - Lo descartado se ve. "Revisar buzón ahora" informa cuántos dejó afuera el filtro, con asunto, remitente y motivo. Guardado en memoria: es diagnóstico de hace un rato, no algo que valga una tabla. Además, si la respuesta automática está apagada, el log lo dice al crear el ticket — era la otra explicación posible de "no me contestó" y no se distinguía. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
13 KiB
Go
423 lines
13 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/mail"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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(criterioNoLeidos(cfg.ImapHorasAtras), &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil {
|
|
sinLeer = len(r.AllUIDs())
|
|
}
|
|
_ = c.Logout().Wait()
|
|
// El conteo del servidor es por día; el corte por hora se aplica al leer,
|
|
// así que este número puede ser un poco mayor que el que se va a procesar.
|
|
ventana := "sin leer"
|
|
if cfg.ImapHorasAtras > 0 {
|
|
ventana = fmt.Sprintf("sin leer de las últimas ~%d horas", cfg.ImapHorasAtras)
|
|
}
|
|
return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d %s.", carpeta, datos.NumMessages, sinLeer, ventana), 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, cfg.ImapHorasAtras)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if len(correos) == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
fallosAlMarcar := 0
|
|
var descartes []string
|
|
|
|
procesados := 0
|
|
for _, correo := range correos {
|
|
ok, descarte := IngestarCorreoSoporteConDetalle(cfg, correo.Correo)
|
|
if ok {
|
|
procesados++
|
|
} else if descarte != "" {
|
|
descartes = append(descartes, descarte)
|
|
}
|
|
// 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++
|
|
}
|
|
}
|
|
ultimosDescartes.guardar(descartes)
|
|
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
|
|
}
|
|
|
|
// ultimosDescartes guarda lo que el filtro dejó afuera en la última corrida,
|
|
// para poder mostrarlo en la pantalla de configuración. En memoria a propósito:
|
|
// es información de diagnóstico de hace un rato, no algo que valga una tabla.
|
|
var ultimosDescartes = &descartesRecientes{}
|
|
|
|
type descartesRecientes struct {
|
|
mu sync.Mutex
|
|
items []string
|
|
}
|
|
|
|
func (d *descartesRecientes) guardar(items []string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if len(items) > 0 {
|
|
d.items = items
|
|
}
|
|
}
|
|
|
|
// UltimosCorreosDescartados devuelve los descartes de la última corrida que
|
|
// tuvo alguno.
|
|
func UltimosCorreosDescartados() []string {
|
|
ultimosDescartes.mu.Lock()
|
|
defer ultimosDescartes.mu.Unlock()
|
|
return append([]string(nil), ultimosDescartes.items...)
|
|
}
|
|
|
|
// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada,
|
|
// recibidos dentro de ella.
|
|
//
|
|
// SINCE de IMAP compara solo la fecha, no la hora, así que acá se pide un día
|
|
// de más y el corte fino por hora se hace después contra la fecha real de cada
|
|
// mensaje (ver dentroDeLaVentana). Pedirle al servidor que filtre de más sería
|
|
// perder correos del borde.
|
|
func criterioNoLeidos(horasAtras int) *imap.SearchCriteria {
|
|
c := &imap.SearchCriteria{NotFlag: []imap.Flag{imap.FlagSeen}}
|
|
if horasAtras > 0 {
|
|
c.Since = time.Now().Add(-time.Duration(horasAtras)*time.Hour).AddDate(0, 0, -1)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// dentroDeLaVentana es el corte por hora que IMAP no puede hacer.
|
|
func dentroDeLaVentana(recibido time.Time, horasAtras int) bool {
|
|
if horasAtras <= 0 || recibido.IsZero() {
|
|
return true
|
|
}
|
|
return recibido.After(time.Now().Add(-time.Duration(horasAtras) * time.Hour))
|
|
}
|
|
|
|
// 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, horasAtras int) ([]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(criterioNoLeidos(horasAtras), &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{
|
|
InternalDate: true,
|
|
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 {
|
|
if !dentroDeLaVentana(m.InternalDate, horasAtras) {
|
|
continue
|
|
}
|
|
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
|
|
}
|