Files
ai-agent/internal/agents/tool/email/idle.go
Christoph K. 905981cd1e zwischenstand
2026-03-20 23:24:56 +01:00

154 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}