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 }