Files
soft_usite/pkg/services/soporte_imap_service.go
T
Lizandro GuarnizoandClaude Opus 5 6afad025c2 feat(soporte): leer solo los correos recientes, no todo el buzón
Sin ventana, la primera corrida convierte en tickets todo lo que haya sin leer,
que en una casilla de años es una avalancha. Ahora hay un desplegable de
antigüedad máxima —6h, 12h, 24h, 3 días o todo— y por defecto 12 horas.

El corte se hace en dos pasos porque IMAP no da para más: al servidor se le
pide SINCE con un día de margen (SINCE compara solo la fecha, no la hora) y el
corte fino por hora se aplica contra la fecha real de cada mensaje. Afinar el
SINCE sería perder los correos del borde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:39:00 -05:00

392 lines
12 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
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
}
// 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
}