zwischenstand

This commit is contained in:
Christoph K.
2026-03-20 23:24:56 +01:00
parent b1a576f61e
commit 905981cd1e
25 changed files with 3607 additions and 217 deletions

View File

@@ -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 {

View 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)
}

View File

@@ -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()

View File

@@ -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")
}
}