154 lines
3.9 KiB
Go
154 lines
3.9 KiB
Go
// 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)
|
||
}
|