Files
soft_usite/pkg/services/soporte_imap_service.go
T
Lizandro GuarnizoandClaude Opus 5 5875f9e36b fix(soporte): el buzón IMAP nunca se leía — la búsqueda pedía UIDs a una búsqueda por secuencia
Usé c.Search, que devuelve números de secuencia, y después le pedí los UIDs al
resultado. go-imap devuelve nil ahí, así que la lista salía siempre vacía: el
cron conectaba bien, buscaba bien, y se iba sin leer nada ni escribir un solo
error en el log. Va con UIDSearch.

Con eso se arreglan las dos cosas: no leía y no creaba tickets.

Además:
- Fetch con Peek: el correo queda marcado como leído solo si se llegó a
  procesar. Antes, un fallo a mitad de camino lo perdía para siempre.
- "Probar conexión" ahora informa cuántos mensajes hay en la carpeta y cuántos
  sin leer; "Revisar buzón ahora" informa cuántos encontró y cuántos convirtió
  en ticket, y avisa si la lectura está desactivada.
- Un correo sin asunto abre ticket como "(sin asunto)" en vez de descartarse en
  silencio, y los descartes quedan en el log.

Los tres tests nuevos corren contra un servidor IMAP en memoria: bajar los no
leídos, que marcar como leído los saque de la próxima corrida, y que bajarlos
sin procesarlos no los marque. El primero falla si se vuelve a poner Search.

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

337 lines
9.8 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
}
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)
}
}
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")),
}, 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"))
}