// email/idle.go – IMAP IDLE Watcher für Echtzeit-Email-Benachrichtigungen package email import ( "context" "crypto/tls" "fmt" "log/slog" "sync/atomic" "time" imap "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "my-brain-importer/internal/config" ) // IdleWatcher überwacht einen IMAP-Account per IDLE auf neue Nachrichten. type IdleWatcher struct { acc config.EmailAccount onNew func(accountName, summary string) fetching atomic.Bool } // NewIdleWatcher erstellt einen IdleWatcher für einen einzelnen Account. // onNew wird aufgerufen wenn neue Emails gefunden wurden (mit Account-Name und Zusammenfassung). func NewIdleWatcher(acc config.EmailAccount, onNew func(accountName, summary string)) *IdleWatcher { return &IdleWatcher{acc: acc, onNew: onNew} } // Run startet die IDLE-Schleife. Blockiert bis ctx abgebrochen wird. func (w *IdleWatcher) Run(ctx context.Context) { for { if ctx.Err() != nil { return } slog.Info("IDLE: Verbinde", "account", accountLabel(w.acc), "host", w.acc.Host) if err := w.runOnce(ctx); err != nil { slog.Warn("IDLE: Fehler, Neuverbindung in 60s", "account", accountLabel(w.acc), "fehler", err) select { case <-ctx.Done(): return case <-time.After(60 * time.Second): } } } } func (w *IdleWatcher) runOnce(ctx context.Context) error { // numMsgs wird atomar geschrieben/gelesen: UnilateralDataHandler läuft in einem // separaten Goroutine (imapclient-intern), IDLE-Loop liest im Hauptgoroutine. var numMsgs atomic.Uint32 hasNew := make(chan struct{}, 1) addr := fmt.Sprintf("%s:%d", w.acc.Host, w.acc.Port) options := &imapclient.Options{ UnilateralDataHandler: &imapclient.UnilateralDataHandler{ Mailbox: func(data *imapclient.UnilateralDataMailbox) { if data.NumMessages != nil && *data.NumMessages > numMsgs.Load() { numMsgs.Store(*data.NumMessages) select { case hasNew <- struct{}{}: default: } } }, }, } var ( c *imapclient.Client err error ) switch { case w.acc.TLS: tlsCfg := &tls.Config{ServerName: w.acc.Host} options.TLSConfig = tlsCfg c, err = imapclient.DialTLS(addr, options) case w.acc.StartTLS: tlsCfg := &tls.Config{ServerName: w.acc.Host} options.TLSConfig = tlsCfg c, err = imapclient.DialStartTLS(addr, options) default: c, err = imapclient.DialInsecure(addr, options) } if err != nil { return fmt.Errorf("verbinden: %w", err) } defer func() { c.Logout().Wait() c.Close() }() if err := c.Login(w.acc.User, w.acc.Password).Wait(); err != nil { return fmt.Errorf("login: %w", err) } folder := w.acc.Folder if folder == "" { folder = "INBOX" } selectData, err := c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait() if err != nil { return fmt.Errorf("select: %w", err) } numMsgs.Store(selectData.NumMessages) slog.Info("IDLE: Aktiv", "account", accountLabel(w.acc), "folder", folder, "numMsgs", selectData.NumMessages) for { if ctx.Err() != nil { return nil } idleCmd, err := c.Idle() if err != nil { return fmt.Errorf("IDLE starten: %w", err) } select { case <-ctx.Done(): idleCmd.Close() idleCmd.Wait() return nil case <-hasNew: idleCmd.Close() if err := idleCmd.Wait(); err != nil { slog.Warn("IDLE Wait Fehler", "account", accountLabel(w.acc), "fehler", err) } slog.Info("IDLE: Neue Email erkannt", "account", accountLabel(w.acc)) // Nur einen gleichzeitigen Fetch erlauben if !w.fetching.Swap(true) { go func() { defer w.fetching.Store(false) w.notifyNewEmail() }() } } } } func (w *IdleWatcher) notifyNewEmail() { summary, err := SummarizeUnreadAccount(w.acc) if err != nil { slog.Error("IDLE: Email-Zusammenfassung fehlgeschlagen", "account", accountLabel(w.acc), "fehler", err) return } if summary == "📭 Keine ungelesenen Emails." { return } w.onNew(accountLabel(w.acc), summary) }