zwischenstand
This commit is contained in:
@@ -3,7 +3,12 @@ package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime/quotedprintable"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
imap "github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
@@ -18,15 +23,43 @@ type Message struct {
|
||||
Date string
|
||||
}
|
||||
|
||||
// Client wraps die IMAP-Verbindung.
|
||||
type Client struct {
|
||||
c *imapclient.Client
|
||||
// SelectMessage koppelt eine Message mit ihrer IMAP-Sequenznummer für UI-Zwecke.
|
||||
type SelectMessage struct {
|
||||
Message
|
||||
SeqNum uint32
|
||||
Unread bool // true = \Seen flag nicht gesetzt
|
||||
}
|
||||
|
||||
// Connect öffnet eine IMAP-Verbindung.
|
||||
// MessageWithBody repräsentiert eine Email mit Text-Inhalt (für Datenbankimport).
|
||||
type MessageWithBody struct {
|
||||
Message
|
||||
Body string
|
||||
}
|
||||
|
||||
// Client wraps die IMAP-Verbindung.
|
||||
type Client struct {
|
||||
c *imapclient.Client
|
||||
folder string // INBOX-Ordner (leer = "INBOX")
|
||||
}
|
||||
|
||||
// Connect öffnet eine IMAP-Verbindung mit dem Legacy-Email-Block aus der Config.
|
||||
func Connect() (*Client, error) {
|
||||
cfg := config.Cfg.Email
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
acc := config.EmailAccount{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
User: cfg.User,
|
||||
Password: cfg.Password,
|
||||
TLS: cfg.TLS,
|
||||
StartTLS: cfg.StartTLS,
|
||||
Folder: cfg.Folder,
|
||||
}
|
||||
return ConnectAccount(acc)
|
||||
}
|
||||
|
||||
// ConnectAccount öffnet eine IMAP-Verbindung für einen bestimmten EmailAccount.
|
||||
func ConnectAccount(acc config.EmailAccount) (*Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", acc.Host, acc.Port)
|
||||
|
||||
var (
|
||||
c *imapclient.Client
|
||||
@@ -34,11 +67,11 @@ func Connect() (*Client, error) {
|
||||
)
|
||||
|
||||
switch {
|
||||
case cfg.TLS:
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||
case acc.TLS:
|
||||
tlsCfg := &tls.Config{ServerName: acc.Host}
|
||||
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||
case cfg.StartTLS:
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||
case acc.StartTLS:
|
||||
tlsCfg := &tls.Config{ServerName: acc.Host}
|
||||
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
|
||||
default:
|
||||
c, err = imapclient.DialInsecure(addr, nil)
|
||||
@@ -47,12 +80,12 @@ func Connect() (*Client, error) {
|
||||
return nil, fmt.Errorf("IMAP verbinden: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
|
||||
if err := c.Login(acc.User, acc.Password).Wait(); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("IMAP login: %w", err)
|
||||
}
|
||||
|
||||
return &Client{c: c}, nil
|
||||
return &Client{c: c, folder: acc.Folder}, nil
|
||||
}
|
||||
|
||||
// Close schließt die Verbindung.
|
||||
@@ -61,9 +94,28 @@ func (cl *Client) Close() {
|
||||
cl.c.Close()
|
||||
}
|
||||
|
||||
// EnsureFolder legt einen IMAP-Ordner an falls er nicht existiert.
|
||||
// Strato-kompatibel: ignoriert alle "already exists"-Varianten.
|
||||
func (cl *Client) EnsureFolder(folder string) error {
|
||||
err := cl.c.Create(folder, nil).Wait()
|
||||
if err == nil {
|
||||
slog.Info("IMAP: Ordner angelegt", "ordner", folder)
|
||||
return nil
|
||||
}
|
||||
errLower := strings.ToLower(err.Error())
|
||||
if strings.Contains(errLower, "already exists") ||
|
||||
strings.Contains(errLower, "alreadyexists") ||
|
||||
strings.Contains(errLower, "mailbox exists") ||
|
||||
strings.Contains(errLower, "exists") {
|
||||
return nil // Ordner existiert bereits — kein Fehler
|
||||
}
|
||||
slog.Error("IMAP: Ordner anlegen fehlgeschlagen", "ordner", folder, "fehler", err)
|
||||
return fmt.Errorf("IMAP create folder %s: %w", folder, err)
|
||||
}
|
||||
|
||||
// FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body).
|
||||
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
||||
folder := config.Cfg.Email.Folder
|
||||
folder := cl.folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
@@ -94,7 +146,7 @@ func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
|
||||
|
||||
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
|
||||
func (cl *Client) FetchUnread() ([]Message, error) {
|
||||
folder := config.Cfg.Email.Folder
|
||||
folder := cl.folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
@@ -129,7 +181,7 @@ func (cl *Client) FetchUnread() ([]Message, error) {
|
||||
// FetchUnreadSeqNums holt ungelesene Emails und gibt zusätzlich die Sequenznummern zurück.
|
||||
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
|
||||
func (cl *Client) FetchUnreadSeqNums() ([]Message, []uint32, error) {
|
||||
folder := config.Cfg.Email.Folder
|
||||
folder := cl.folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
@@ -172,6 +224,328 @@ func (cl *Client) MoveMessages(seqNums []uint32, destFolder string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchUnreadForSelect gibt ungelesene Emails mit ihren Sequenznummern zurück.
|
||||
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
|
||||
func (cl *Client) FetchUnreadForSelect() ([]SelectMessage, error) {
|
||||
folder := cl.folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
|
||||
return nil, fmt.Errorf("IMAP select: %w", err)
|
||||
}
|
||||
|
||||
searchData, err := cl.c.Search(&imap.SearchCriteria{
|
||||
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||
}, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP search: %w", err)
|
||||
}
|
||||
|
||||
seqNums := searchData.AllSeqNums()
|
||||
if len(seqNums) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddNum(seqNums...)
|
||||
|
||||
rawMsgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||
}
|
||||
|
||||
seqToMsg := make(map[uint32]*imapclient.FetchMessageBuffer, len(rawMsgs))
|
||||
for _, m := range rawMsgs {
|
||||
if m.Envelope != nil {
|
||||
seqToMsg[m.SeqNum] = m
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]SelectMessage, 0, len(seqNums))
|
||||
for _, sn := range seqNums {
|
||||
m, ok := seqToMsg[sn]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, SelectMessage{
|
||||
Message: parseMessage(m),
|
||||
SeqNum: sn,
|
||||
Unread: true,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FetchRecentForSelect gibt die letzten n Emails mit Sequenznummern und Unread-Status zurück.
|
||||
// Selektiert den Ordner im Lese-Schreib-Modus (für nachfolgendes Verschieben).
|
||||
func (cl *Client) FetchRecentForSelect(n uint32) ([]SelectMessage, error) {
|
||||
folder := cl.folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
selectData, err := cl.c.Select(folder, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP select: %w", err)
|
||||
}
|
||||
if selectData.NumMessages == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
start := uint32(1)
|
||||
if selectData.NumMessages > n {
|
||||
start = selectData.NumMessages - n + 1
|
||||
}
|
||||
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddRange(start, selectData.NumMessages)
|
||||
|
||||
rawMsgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true, Flags: true}).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||
}
|
||||
|
||||
result := make([]SelectMessage, 0, len(rawMsgs))
|
||||
for _, m := range rawMsgs {
|
||||
if m.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
unread := true
|
||||
for _, f := range m.Flags {
|
||||
if f == imap.FlagSeen {
|
||||
unread = false
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, SelectMessage{
|
||||
Message: parseMessage(m),
|
||||
SeqNum: m.SeqNum,
|
||||
Unread: unread,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MoveOldMessages verschiebt alle Emails im Ordner, die älter als olderThanDays Tage sind, nach destFolder.
|
||||
// Gibt die Anzahl verschobener Nachrichten zurück. olderThanDays <= 0 ist ein No-op.
|
||||
func (cl *Client) MoveOldMessages(folder, destFolder string, olderThanDays int) (int, error) {
|
||||
if olderThanDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -olderThanDays).Truncate(24 * time.Hour)
|
||||
|
||||
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
|
||||
return 0, fmt.Errorf("IMAP select %s: %w", folder, err)
|
||||
}
|
||||
|
||||
searchData, err := cl.c.Search(&imap.SearchCriteria{Before: cutoff}, nil).Wait()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("IMAP search: %w", err)
|
||||
}
|
||||
|
||||
seqNums := searchData.AllSeqNums()
|
||||
if len(seqNums) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddNum(seqNums...)
|
||||
|
||||
if _, err := cl.c.Move(seqSet, destFolder).Wait(); err != nil {
|
||||
return 0, fmt.Errorf("IMAP move: %w", err)
|
||||
}
|
||||
|
||||
return len(seqNums), nil
|
||||
}
|
||||
|
||||
// MoveSpecificMessages selektiert den Inbox-Ordner und verschiebt die angegebenen Sequenznummern.
|
||||
func (cl *Client) MoveSpecificMessages(seqNums []uint32, destFolder string) error {
|
||||
folder := cl.folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
|
||||
return fmt.Errorf("IMAP select: %w", err)
|
||||
}
|
||||
return cl.MoveMessages(seqNums, destFolder)
|
||||
}
|
||||
|
||||
// CleanupOldEmails löscht Emails im Ordner, die älter als retentionDays sind.
|
||||
// Gibt die Anzahl gelöschter Nachrichten zurück. retentionDays <= 0 ist ein No-op.
|
||||
func (cl *Client) CleanupOldEmails(folder string, retentionDays int) (int, error) {
|
||||
if retentionDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays).Truncate(24 * time.Hour)
|
||||
|
||||
if _, err := cl.c.Select(folder, nil).Wait(); err != nil {
|
||||
return 0, fmt.Errorf("IMAP select %s: %w", folder, err)
|
||||
}
|
||||
|
||||
searchData, err := cl.c.Search(&imap.SearchCriteria{Before: cutoff}, nil).Wait()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("IMAP search: %w", err)
|
||||
}
|
||||
|
||||
seqNums := searchData.AllSeqNums()
|
||||
if len(seqNums) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddNum(seqNums...)
|
||||
|
||||
storeFlags := &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagDeleted},
|
||||
}
|
||||
if _, err := cl.c.Store(seqSet, storeFlags, nil).Collect(); err != nil {
|
||||
return 0, fmt.Errorf("IMAP store flags: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cl.c.Expunge().Collect(); err != nil {
|
||||
return 0, fmt.Errorf("IMAP expunge: %w", err)
|
||||
}
|
||||
|
||||
return len(seqNums), nil
|
||||
}
|
||||
|
||||
// FetchWithBody holt bis zu n Emails aus dem angegebenen Ordner mit Text-Body.
|
||||
// Emails werden in Batches von 50 gefetcht um den IMAP-Server nicht zu überlasten.
|
||||
func (cl *Client) FetchWithBody(folder string, n uint32) ([]MessageWithBody, error) {
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
|
||||
selectData, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP select %s: %w", folder, err)
|
||||
}
|
||||
if selectData.NumMessages == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Letzte n Nachrichten
|
||||
total := selectData.NumMessages
|
||||
start := uint32(1)
|
||||
if total > n {
|
||||
start = total - n + 1
|
||||
}
|
||||
|
||||
bodySec := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText}
|
||||
hdrSec := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierHeader}
|
||||
|
||||
var result []MessageWithBody
|
||||
batchSize := uint32(50)
|
||||
|
||||
for i := start; i <= total; i += batchSize {
|
||||
end := i + batchSize - 1
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
var seqSet imap.SeqSet
|
||||
seqSet.AddRange(i, end)
|
||||
|
||||
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{
|
||||
Envelope: true,
|
||||
BodySection: []*imap.FetchItemBodySection{bodySec, hdrSec},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IMAP fetch batch %d-%d: %w", i, end, err)
|
||||
}
|
||||
|
||||
for _, msg := range msgs {
|
||||
if msg.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
m := MessageWithBody{Message: parseMessage(msg)}
|
||||
|
||||
// Content-Transfer-Encoding aus Header lesen
|
||||
enc := ""
|
||||
if hdr := msg.FindBodySection(hdrSec); hdr != nil {
|
||||
for _, line := range strings.Split(string(hdr), "\n") {
|
||||
if strings.HasPrefix(strings.ToLower(line), "content-transfer-encoding:") {
|
||||
enc = strings.TrimSpace(strings.ToLower(strings.SplitN(line, ":", 2)[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if body := msg.FindBodySection(bodySec); body != nil {
|
||||
m.Body = decodeBody(body, enc)
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decodeBody dekodiert einen Email-Body je nach Content-Transfer-Encoding.
|
||||
func decodeBody(raw []byte, enc string) string {
|
||||
var text string
|
||||
switch enc {
|
||||
case "base64":
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(string(raw)), "\r\n", "")
|
||||
if decoded, err := base64.StdEncoding.DecodeString(cleaned); err == nil {
|
||||
text = string(decoded)
|
||||
} else if decoded, err := base64.RawStdEncoding.DecodeString(cleaned); err == nil {
|
||||
text = string(decoded)
|
||||
} else {
|
||||
text = string(raw) // Fallback: roh
|
||||
}
|
||||
case "quoted-printable":
|
||||
r := quotedprintable.NewReader(strings.NewReader(string(raw)))
|
||||
if buf := new(strings.Builder); true {
|
||||
buf.Grow(len(raw))
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(tmp)
|
||||
buf.Write(tmp[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
text = buf.String()
|
||||
}
|
||||
default:
|
||||
text = string(raw)
|
||||
}
|
||||
|
||||
// Kürzen auf max 2000 Zeichen
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) > 2000 {
|
||||
text = text[:2000]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// parseMessage extrahiert eine Message aus einem FetchMessageBuffer.
|
||||
func parseMessage(msg *imapclient.FetchMessageBuffer) Message {
|
||||
m := Message{
|
||||
Subject: msg.Envelope.Subject,
|
||||
Date: msg.Envelope.Date.Format("2006-01-02 15:04"),
|
||||
}
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
addr := msg.Envelope.From[0]
|
||||
if addr.Name != "" {
|
||||
m.From = fmt.Sprintf("%s <%s@%s>", addr.Name, addr.Mailbox, addr.Host)
|
||||
} else {
|
||||
m.From = fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
|
||||
result := make([]Message, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
|
||||
153
internal/agents/tool/email/idle.go
Normal file
153
internal/agents/tool/email/idle.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -11,60 +11,510 @@ import (
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
|
||||
"my-brain-importer/internal/config"
|
||||
"my-brain-importer/internal/triage"
|
||||
)
|
||||
|
||||
// Summarize verbindet sich mit IMAP, holt die letzten 20 Emails und fasst sie per LLM zusammen.
|
||||
func Summarize() (string, error) {
|
||||
return fetchAndSummarize(20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
|
||||
accounts := config.AllEmailAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return "", fmt.Errorf("Kein Email-Account konfiguriert")
|
||||
}
|
||||
if len(accounts) == 1 {
|
||||
return fetchAndSummarizeAccount(accounts[0], 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
|
||||
}
|
||||
var parts []string
|
||||
for _, acc := range accounts {
|
||||
result, err := fetchAndSummarizeAccount(acc, 20, "Fasse diese Emails kurz zusammen und hebe wichtige oder dringende hervor.")
|
||||
if err != nil {
|
||||
slog.Error("Email-Fehler", "account", acc.Name, "fehler", err)
|
||||
parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result))
|
||||
}
|
||||
return strings.Join(parts, "\n\n"), nil
|
||||
}
|
||||
|
||||
// SummarizeUnread fasst ungelesene Emails zusammen.
|
||||
// Wenn email.processed_folder konfiguriert ist, werden die Emails danach dorthin verschoben.
|
||||
// SummarizeUnread fasst ungelesene Emails für alle konfigurierten Accounts zusammen.
|
||||
// Wenn email.processed_folder konfiguriert ist, werden Emails danach dorthin verschoben.
|
||||
func SummarizeUnread() (string, error) {
|
||||
cl, err := Connect()
|
||||
accounts := config.AllEmailAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return "", fmt.Errorf("Kein Email-Account konfiguriert")
|
||||
}
|
||||
if len(accounts) == 1 {
|
||||
return SummarizeUnreadAccount(accounts[0])
|
||||
}
|
||||
|
||||
var parts []string
|
||||
allEmpty := true
|
||||
for _, acc := range accounts {
|
||||
result, err := SummarizeUnreadAccount(acc)
|
||||
if err != nil {
|
||||
slog.Error("Email-Fehler", "account", acc.Name, "fehler", err)
|
||||
parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
|
||||
continue
|
||||
}
|
||||
if result != "📭 Keine ungelesenen Emails." {
|
||||
allEmpty = false
|
||||
parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result))
|
||||
}
|
||||
}
|
||||
if allEmpty {
|
||||
return "📭 Keine ungelesenen Emails.", nil
|
||||
}
|
||||
return strings.Join(parts, "\n\n"), nil
|
||||
}
|
||||
|
||||
// SummarizeUnreadAccount fasst ungelesene Emails für einen bestimmten Account zusammen.
|
||||
// Wenn triage_folder konfiguriert ist, werden unwichtige Emails vorher aussortiert.
|
||||
func SummarizeUnreadAccount(acc config.EmailAccount) (string, error) {
|
||||
// Phase 1: Triage – Emails sortieren (eigene Verbindung)
|
||||
if acc.TriageUnimportantFolder != "" || acc.TriageImportantFolder != "" {
|
||||
if err := triageUnread(acc); err != nil {
|
||||
slog.Warn("[Triage] fehlgeschlagen, übersprungen", "account", accountLabel(acc), "fehler", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Zusammenfassung der verbleibenden wichtigen Emails (frische Verbindung)
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||||
return "", fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
processedFolder := config.Cfg.Email.ProcessedFolder
|
||||
|
||||
var msgs []Message
|
||||
var seqNums []uint32
|
||||
|
||||
if processedFolder != "" {
|
||||
if acc.ProcessedFolder != "" {
|
||||
msgs, seqNums, err = cl.FetchUnreadSeqNums()
|
||||
} else {
|
||||
msgs, err = cl.FetchUnread()
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Emails abrufen: %w", err)
|
||||
return "", fmt.Errorf("Emails abrufen (%s): %w", accountLabel(acc), err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return "📭 Keine ungelesenen Emails.", nil
|
||||
}
|
||||
|
||||
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread")
|
||||
result, err := summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
|
||||
slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs), "typ", "unread")
|
||||
result, err := summarizeWithLLMModel(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.", accountModel(acc))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Nach dem Zusammenfassen: Emails in Processed-Ordner verschieben
|
||||
if processedFolder != "" && len(seqNums) > 0 {
|
||||
if moveErr := cl.MoveMessages(seqNums, processedFolder); moveErr != nil {
|
||||
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", processedFolder)
|
||||
if acc.ProcessedFolder != "" && len(seqNums) > 0 {
|
||||
if moveErr := cl.MoveMessages(seqNums, acc.ProcessedFolder); moveErr != nil {
|
||||
slog.Warn("Emails konnten nicht verschoben werden", "fehler", moveErr, "ordner", acc.ProcessedFolder)
|
||||
} else {
|
||||
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", processedFolder)
|
||||
slog.Info("Emails verschoben", "anzahl", len(seqNums), "ordner", acc.ProcessedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TriageRecentAllAccounts klassifiziert die letzten n Emails aller Accounts manuell
|
||||
// und verschiebt sie in die konfigurierten Triage-Ordner.
|
||||
func TriageRecentAllAccounts(n uint32) (string, error) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return "", fmt.Errorf("kein Email-Account konfiguriert")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, acc := range accounts {
|
||||
if acc.TriageImportantFolder == "" && acc.TriageUnimportantFolder == "" {
|
||||
lines = append(lines, fmt.Sprintf("⚠️ **%s:** kein triage_important_folder / triage_unimportant_folder konfiguriert", accountLabel(acc)))
|
||||
continue
|
||||
}
|
||||
wichtig, unwichtig, err := triageRecentAccount(acc, n)
|
||||
if err != nil {
|
||||
lines = append(lines, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("✅ **%s:** %d wichtig → `%s`, %d unwichtig → `%s`",
|
||||
accountLabel(acc), wichtig, acc.TriageImportantFolder, unwichtig, acc.TriageUnimportantFolder))
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// triageRecentAccount klassifiziert die letzten n Emails eines Accounts.
|
||||
// Gibt Anzahl wichtiger und unwichtiger Emails zurück.
|
||||
func triageRecentAccount(acc config.EmailAccount, n uint32) (wichtig, unwichtig int, err error) {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("verbinden: %w", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
// Ordner vorab anlegen, unabhängig davon ob Emails verschoben werden
|
||||
if acc.TriageImportantFolder != "" {
|
||||
if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil {
|
||||
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err)
|
||||
}
|
||||
}
|
||||
if acc.TriageUnimportantFolder != "" {
|
||||
if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil {
|
||||
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err)
|
||||
}
|
||||
}
|
||||
|
||||
msgs, err := cl.FetchRecentForSelect(n)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
model := accountModel(acc)
|
||||
var wichtigSeqNums, unwichtigSeqNums []uint32
|
||||
|
||||
slog.Info("[Triage] Manuell gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if ClassifyImportance(msg.Message, model) {
|
||||
wichtigSeqNums = append(wichtigSeqNums, msg.SeqNum)
|
||||
} else {
|
||||
unwichtigSeqNums = append(unwichtigSeqNums, msg.SeqNum)
|
||||
}
|
||||
}
|
||||
|
||||
if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 {
|
||||
if ensureErr := cl.EnsureFolder(acc.TriageUnimportantFolder); ensureErr != nil {
|
||||
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", ensureErr)
|
||||
}
|
||||
if moveErr := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); moveErr != nil {
|
||||
slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "fehler", moveErr)
|
||||
}
|
||||
}
|
||||
|
||||
if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 {
|
||||
if ensureErr := cl.EnsureFolder(acc.TriageImportantFolder); ensureErr != nil {
|
||||
slog.Warn("[Triage] Ordner anlegen fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", ensureErr)
|
||||
}
|
||||
if moveErr := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); moveErr != nil {
|
||||
slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "fehler", moveErr)
|
||||
}
|
||||
}
|
||||
|
||||
return len(wichtigSeqNums), len(unwichtigSeqNums), nil
|
||||
}
|
||||
|
||||
// triageUnread klassifiziert alle ungelesenen Emails eines Accounts und verschiebt
|
||||
// wichtige in TriageImportantFolder und unwichtige in TriageUnimportantFolder.
|
||||
// Läuft sequentiell: eine Email nach der anderen.
|
||||
func triageUnread(acc config.EmailAccount) error {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verbinden: %w", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
// Ordner vorab anlegen
|
||||
if acc.TriageImportantFolder != "" {
|
||||
cl.EnsureFolder(acc.TriageImportantFolder)
|
||||
}
|
||||
if acc.TriageUnimportantFolder != "" {
|
||||
cl.EnsureFolder(acc.TriageUnimportantFolder)
|
||||
}
|
||||
|
||||
msgs, seqNums, err := cl.FetchUnreadSeqNums()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
model := accountModel(acc)
|
||||
var wichtigSeqNums, unwichtigSeqNums []uint32
|
||||
|
||||
slog.Info("[Triage] Klassifizierung gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
|
||||
for i, msg := range msgs {
|
||||
if ClassifyImportance(msg, model) {
|
||||
wichtigSeqNums = append(wichtigSeqNums, seqNums[i])
|
||||
} else {
|
||||
unwichtigSeqNums = append(unwichtigSeqNums, seqNums[i])
|
||||
}
|
||||
}
|
||||
|
||||
if acc.TriageUnimportantFolder != "" && len(unwichtigSeqNums) > 0 {
|
||||
if err := cl.EnsureFolder(acc.TriageUnimportantFolder); err != nil {
|
||||
slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageUnimportantFolder, "fehler", err)
|
||||
}
|
||||
if err := cl.MoveMessages(unwichtigSeqNums, acc.TriageUnimportantFolder); err != nil {
|
||||
slog.Warn("[Triage] Verschieben unwichtig fehlgeschlagen", "ordner", acc.TriageUnimportantFolder, "fehler", err)
|
||||
} else {
|
||||
slog.Info("[Triage] Unwichtige Emails verschoben", "anzahl", len(unwichtigSeqNums), "ordner", acc.TriageUnimportantFolder)
|
||||
}
|
||||
}
|
||||
|
||||
if acc.TriageImportantFolder != "" && len(wichtigSeqNums) > 0 {
|
||||
if err := cl.EnsureFolder(acc.TriageImportantFolder); err != nil {
|
||||
slog.Warn("[Triage] Ordner konnte nicht angelegt werden", "ordner", acc.TriageImportantFolder, "fehler", err)
|
||||
}
|
||||
if err := cl.MoveMessages(wichtigSeqNums, acc.TriageImportantFolder); err != nil {
|
||||
slog.Warn("[Triage] Verschieben wichtig fehlgeschlagen", "ordner", acc.TriageImportantFolder, "fehler", err)
|
||||
} else {
|
||||
slog.Info("[Triage] Wichtige Emails verschoben", "anzahl", len(wichtigSeqNums), "ordner", acc.TriageImportantFolder)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassifyImportance klassifiziert eine einzelne Email als wichtig (true) oder unwichtig (false).
|
||||
// Sucht zuerst ähnliche vergangene Entscheidungen in Qdrant (RAG) und gibt sie als Kontext mit.
|
||||
// Im Fehlerfall oder bei unklarer Antwort wird true (wichtig) zurückgegeben – sicherer Default.
|
||||
func ClassifyImportance(msg Message, model string) bool {
|
||||
// RAG: ähnliche vergangene Triage-Entscheidungen als Few-Shot-Beispiele
|
||||
ragQuery := fmt.Sprintf("Von: %s Betreff: %s", msg.From, msg.Subject)
|
||||
examples := triage.SearchSimilar(ragQuery)
|
||||
|
||||
var examplesText string
|
||||
if len(examples) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Ähnliche Entscheidungen aus der Vergangenheit:\n")
|
||||
for _, ex := range examples {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(ex.Text)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
examplesText = sb.String()
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf("%sVon: %s\nBetreff: %s\n\nIst diese Email wichtig? Antworte NUR mit einem einzigen Wort: wichtig oder unwichtig.",
|
||||
examplesText, msg.From, msg.Subject)
|
||||
|
||||
chatClient := config.NewChatClient()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := chatClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{Role: openai.ChatMessageRoleSystem, Content: "Du bist ein Email-Filter. Antworte immer nur mit einem einzigen Wort: wichtig oder unwichtig."},
|
||||
{Role: openai.ChatMessageRoleUser, Content: prompt},
|
||||
},
|
||||
Temperature: 0.1,
|
||||
MaxTokens: 300,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("[Triage] LLM-Fehler, Email als wichtig eingestuft", "betreff", msg.Subject, "fehler", err)
|
||||
return true
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
raw := resp.Choices[0].Message.Content
|
||||
// Reasoning-Modelle (z.B. Qwen3) geben Antwort nach </think>-Tag aus
|
||||
if idx := strings.LastIndex(raw, "</think>"); idx >= 0 {
|
||||
raw = raw[idx+len("</think>"):]
|
||||
}
|
||||
answer := strings.ToLower(strings.TrimSpace(raw))
|
||||
isImportant := !strings.Contains(answer, "unwichtig")
|
||||
slog.Info("[Triage] Email klassifiziert",
|
||||
"betreff", msg.Subject,
|
||||
"von", msg.From,
|
||||
"wichtig", isImportant,
|
||||
"rag_beispiele", len(examples),
|
||||
"antwort", answer,
|
||||
)
|
||||
|
||||
// Entscheidung für künftiges Lernen in Qdrant speichern
|
||||
if err := triage.StoreDecision(msg.Subject, msg.From, isImportant); err != nil {
|
||||
slog.Warn("[Triage] Entscheidung nicht gespeichert", "fehler", err)
|
||||
}
|
||||
|
||||
return isImportant
|
||||
}
|
||||
|
||||
// ExtractReminders sucht in den letzten Emails nach Terminen/Deadlines.
|
||||
func ExtractReminders() (string, error) {
|
||||
return fetchAndSummarize(30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
|
||||
accounts := config.AllEmailAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return "", fmt.Errorf("Kein Email-Account konfiguriert")
|
||||
}
|
||||
if len(accounts) == 1 {
|
||||
return fetchAndSummarizeAccount(accounts[0], 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
|
||||
}
|
||||
var parts []string
|
||||
for _, acc := range accounts {
|
||||
result, err := fetchAndSummarizeAccount(acc, 30, "Extrahiere alle Termine, Deadlines, Erinnerungen und wichtigen Daten aus diesen Emails. Liste sie strukturiert auf.")
|
||||
if err != nil {
|
||||
slog.Error("Email-Fehler", "account", acc.Name, "fehler", err)
|
||||
parts = append(parts, fmt.Sprintf("❌ **%s:** %v", accountLabel(acc), err))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("**%s:**\n%s", accountLabel(acc), result))
|
||||
}
|
||||
return strings.Join(parts, "\n\n"), nil
|
||||
}
|
||||
|
||||
// MoveUnread verschiebt alle ungelesenen Emails eines Accounts in den Zielordner.
|
||||
// Gibt die Anzahl verschobener Emails zurück.
|
||||
func MoveUnread(acc config.EmailAccount, destFolder string) (int, error) {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
|
||||
}
|
||||
defer cl.Close()
|
||||
|
||||
_, seqNums, err := cl.FetchUnreadSeqNums()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Emails abrufen: %w", err)
|
||||
}
|
||||
if len(seqNums) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := cl.MoveMessages(seqNums, destFolder); err != nil {
|
||||
return 0, fmt.Errorf("Verschieben nach %s: %w", destFolder, err)
|
||||
}
|
||||
return len(seqNums), nil
|
||||
}
|
||||
|
||||
// AccountSelectMessages enthält ungelesene Emails eines Accounts für die Discord-Auswahl.
|
||||
type AccountSelectMessages struct {
|
||||
Account config.EmailAccount
|
||||
AccIndex int
|
||||
Messages []SelectMessage
|
||||
}
|
||||
|
||||
// FetchUnreadForSelectAllAccounts holt ungelesene Emails aller Accounts für die Discord-Auswahl.
|
||||
func FetchUnreadForSelectAllAccounts() ([]AccountSelectMessages, error) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
var result []AccountSelectMessages
|
||||
for i, acc := range accounts {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err)
|
||||
}
|
||||
msgs, err := cl.FetchUnreadForSelect()
|
||||
cl.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err)
|
||||
}
|
||||
result = append(result, AccountSelectMessages{
|
||||
Account: acc,
|
||||
AccIndex: i,
|
||||
Messages: msgs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FetchRecentForSelectAllAccounts holt die letzten n Emails aller Accounts für die Discord-Auswahl.
|
||||
func FetchRecentForSelectAllAccounts(n uint32) ([]AccountSelectMessages, error) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
var result []AccountSelectMessages
|
||||
for i, acc := range accounts {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err)
|
||||
}
|
||||
msgs, err := cl.FetchRecentForSelect(n)
|
||||
cl.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Emails %s: %w", accountLabel(acc), err)
|
||||
}
|
||||
result = append(result, AccountSelectMessages{
|
||||
Account: acc,
|
||||
AccIndex: i,
|
||||
Messages: msgs,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MoveOldEmailsAllAccounts verschiebt alle Emails aller Accounts, die älter als olderThanDays Tage sind, nach destFolder.
|
||||
// Gibt die Gesamtanzahl verschobener Emails zurück.
|
||||
func MoveOldEmailsAllAccounts(destFolder string, olderThanDays int) (int, error) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
total := 0
|
||||
for _, acc := range accounts {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("Verbindung %s: %w", accountLabel(acc), err)
|
||||
}
|
||||
n, err := cl.MoveOldMessages(acc.Folder, destFolder, olderThanDays)
|
||||
cl.Close()
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("Verschieben %s: %w", accountLabel(acc), err)
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// MoveSpecificUnread verschiebt spezifische Emails (per Sequenznummer) eines Accounts in den Zielordner.
|
||||
func MoveSpecificUnread(accIndex int, seqNums []uint32, destFolder string) (int, error) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
if accIndex < 0 || accIndex >= len(accounts) {
|
||||
return 0, fmt.Errorf("ungültiger Account-Index %d", accIndex)
|
||||
}
|
||||
acc := accounts[accIndex]
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Email-Verbindung (%s): %w", accountLabel(acc), err)
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := cl.MoveSpecificMessages(seqNums, destFolder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(seqNums), nil
|
||||
}
|
||||
|
||||
// CleanupArchiveFolders löscht abgelaufene Emails aus allen konfigurierten Archivordnern.
|
||||
// Gibt eine menschenlesbare Zusammenfassung zurück.
|
||||
func CleanupArchiveFolders() (string, error) {
|
||||
accounts := config.AllEmailAccounts()
|
||||
var lines []string
|
||||
var errs []string
|
||||
total := 0
|
||||
|
||||
for _, acc := range accounts {
|
||||
for _, af := range acc.ArchiveFolders {
|
||||
if af.RetentionDays <= 0 {
|
||||
continue
|
||||
}
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err))
|
||||
continue
|
||||
}
|
||||
n, err := cl.CleanupOldEmails(af.IMAPFolder, af.RetentionDays)
|
||||
cl.Close()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s/%s: %v", accountLabel(acc), af.Name, err))
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🗑️ %s/%s: %d Email(s) gelöscht (älter als %d Tage)", accountLabel(acc), af.Name, n, af.RetentionDays))
|
||||
total += n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result string
|
||||
if len(lines) == 0 && len(errs) == 0 {
|
||||
result = "Kein Aufräumen notwendig."
|
||||
} else {
|
||||
result = strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
var combinedErr error
|
||||
if len(errs) > 0 {
|
||||
combinedErr = fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
slog.Info("Archiv-Aufräumen abgeschlossen", "gelöscht", total, "fehler", len(errs))
|
||||
return result, combinedErr
|
||||
}
|
||||
|
||||
// SummarizeMessages fasst eine übergebene Liste von Nachrichten zusammen (für Tests ohne IMAP).
|
||||
@@ -72,8 +522,8 @@ func SummarizeMessages(msgs []Message, instruction string) (string, error) {
|
||||
return summarizeWithLLM(msgs, instruction)
|
||||
}
|
||||
|
||||
func fetchAndSummarize(n uint32, instruction string) (string, error) {
|
||||
cl, err := Connect()
|
||||
func fetchAndSummarizeAccount(acc config.EmailAccount, n uint32, instruction string) (string, error) {
|
||||
cl, err := ConnectAccount(acc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Email-Verbindung: %w", err)
|
||||
}
|
||||
@@ -87,12 +537,27 @@ func fetchAndSummarize(n uint32, instruction string) (string, error) {
|
||||
return "📭 Keine Emails gefunden.", nil
|
||||
}
|
||||
|
||||
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs))
|
||||
return summarizeWithLLM(msgs, instruction)
|
||||
slog.Info("Email-Zusammenfassung gestartet", "account", accountLabel(acc), "anzahl", len(msgs))
|
||||
return summarizeWithLLMModel(msgs, instruction, accountModel(acc))
|
||||
}
|
||||
|
||||
// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück.
|
||||
// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist.
|
||||
// accountLabel gibt einen lesbaren Namen für einen Account zurück.
|
||||
func accountLabel(acc config.EmailAccount) string {
|
||||
if acc.Name != "" {
|
||||
return acc.Name
|
||||
}
|
||||
return acc.User
|
||||
}
|
||||
|
||||
// accountModel gibt das konfigurierte LLM-Modell für einen Account zurück.
|
||||
func accountModel(acc config.EmailAccount) string {
|
||||
if acc.Model != "" {
|
||||
return acc.Model
|
||||
}
|
||||
return config.Cfg.Chat.Model
|
||||
}
|
||||
|
||||
// emailModel gibt das konfigurierte Modell für den Legacy-Account zurück.
|
||||
func emailModel() string {
|
||||
if config.Cfg.Email.Model != "" {
|
||||
return config.Cfg.Email.Model
|
||||
@@ -110,8 +575,11 @@ func formatEmailList(msgs []Message) string {
|
||||
}
|
||||
|
||||
func summarizeWithLLM(msgs []Message, instruction string) (string, error) {
|
||||
return summarizeWithLLMModel(msgs, instruction, emailModel())
|
||||
}
|
||||
|
||||
func summarizeWithLLMModel(msgs []Message, instruction, model string) (string, error) {
|
||||
emailList := formatEmailList(msgs)
|
||||
model := emailModel()
|
||||
|
||||
chatClient := config.NewChatClient()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"my-brain-importer/internal/config"
|
||||
)
|
||||
|
||||
var testMessages = []Message{
|
||||
@@ -70,3 +72,46 @@ func TestMessage_DateFormat(t *testing.T) {
|
||||
t.Errorf("Datumsformat ungültig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLabel_WithName(t *testing.T) {
|
||||
acc := config.EmailAccount{Name: "Privat", User: "user@example.de"}
|
||||
if got := accountLabel(acc); got != "Privat" {
|
||||
t.Errorf("accountLabel: erwartet %q, got %q", "Privat", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLabel_FallsBackToUser(t *testing.T) {
|
||||
acc := config.EmailAccount{User: "user@example.de"}
|
||||
if got := accountLabel(acc); got != "user@example.de" {
|
||||
t.Errorf("accountLabel: erwartet %q, got %q", "user@example.de", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountModel_WithModel(t *testing.T) {
|
||||
acc := config.EmailAccount{Model: "custom-model"}
|
||||
if got := accountModel(acc); got != "custom-model" {
|
||||
t.Errorf("accountModel: erwartet %q, got %q", "custom-model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountModel_FallsBackToChatModel(t *testing.T) {
|
||||
orig := config.Cfg
|
||||
defer func() { config.Cfg = orig }()
|
||||
config.Cfg.Chat.Model = "default-model"
|
||||
|
||||
acc := config.EmailAccount{} // kein Model gesetzt
|
||||
if got := accountModel(acc); got != "default-model" {
|
||||
t.Errorf("accountModel: erwartet chat.model %q, got %q", "default-model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeUnread_NoAccountsConfigured(t *testing.T) {
|
||||
orig := config.Cfg
|
||||
defer func() { config.Cfg = orig }()
|
||||
config.Cfg = config.Config{} // leere Config, kein Email-Account
|
||||
|
||||
_, err := SummarizeUnread()
|
||||
if err == nil {
|
||||
t.Error("erwartet Fehler wenn kein Account konfiguriert")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user