llm mail integration

This commit is contained in:
Christoph K.
2026-03-19 21:46:12 +01:00
parent fdc7a8588d
commit 0e7aa3e7f2
19 changed files with 1707 additions and 306 deletions

View File

@@ -0,0 +1,150 @@
// email/client.go IMAP-Client für Email-Abfragen
package email
import (
"crypto/tls"
"fmt"
imap "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"my-brain-importer/internal/config"
)
// Message repräsentiert eine Email (ohne Body für schnelle Übersichten).
type Message struct {
Subject string
From string
Date string
}
// Client wraps die IMAP-Verbindung.
type Client struct {
c *imapclient.Client
}
// Connect öffnet eine IMAP-Verbindung.
func Connect() (*Client, error) {
cfg := config.Cfg.Email
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
var (
c *imapclient.Client
err error
)
switch {
case cfg.TLS:
tlsCfg := &tls.Config{ServerName: cfg.Host}
c, err = imapclient.DialTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
case cfg.StartTLS:
tlsCfg := &tls.Config{ServerName: cfg.Host}
c, err = imapclient.DialStartTLS(addr, &imapclient.Options{TLSConfig: tlsCfg})
default:
c, err = imapclient.DialInsecure(addr, nil)
}
if err != nil {
return nil, fmt.Errorf("IMAP verbinden: %w", err)
}
if err := c.Login(cfg.User, cfg.Password).Wait(); err != nil {
c.Close()
return nil, fmt.Errorf("IMAP login: %w", err)
}
return &Client{c: c}, nil
}
// Close schließt die Verbindung.
func (cl *Client) Close() {
cl.c.Logout().Wait()
cl.c.Close()
}
// FetchRecent holt die letzten n Emails (Envelope-Daten, kein Body).
func (cl *Client) FetchRecent(n uint32) ([]Message, error) {
folder := config.Cfg.Email.Folder
if folder == "" {
folder = "INBOX"
}
selectData, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).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)
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
if err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
return parseMessages(msgs), nil
}
// FetchUnread holt ungelesene Emails (Envelope-Daten, kein Body).
func (cl *Client) FetchUnread() ([]Message, error) {
folder := config.Cfg.Email.Folder
if folder == "" {
folder = "INBOX"
}
if _, err := cl.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).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...)
msgs, err := cl.c.Fetch(seqSet, &imap.FetchOptions{Envelope: true}).Collect()
if err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
return parseMessages(msgs), nil
}
func parseMessages(msgs []*imapclient.FetchMessageBuffer) []Message {
result := make([]Message, 0, len(msgs))
for _, msg := range msgs {
if msg.Envelope == nil {
continue
}
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)
}
}
result = append(result, m)
}
return result
}

View File

@@ -0,0 +1,156 @@
// email/summary.go LLM-Zusammenfassung von Emails via LocalAI
package email
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
openai "github.com/sashabaranov/go-openai"
"my-brain-importer/internal/config"
)
// 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.")
}
// SummarizeUnread fasst ungelesene Emails zusammen.
func SummarizeUnread() (string, error) {
cl, err := Connect()
if err != nil {
return "", fmt.Errorf("Email-Verbindung: %w", err)
}
defer cl.Close()
msgs, err := cl.FetchUnread()
if err != nil {
return "", fmt.Errorf("Emails abrufen: %w", err)
}
if len(msgs) == 0 {
return "📭 Keine ungelesenen Emails.", nil
}
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs), "typ", "unread")
return summarizeWithLLM(msgs, "Fasse diese ungelesenen Emails zusammen. Hebe wichtige, dringende oder actionable Emails hervor.")
}
// 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.")
}
// SummarizeMessages fasst eine übergebene Liste von Nachrichten zusammen (für Tests ohne IMAP).
func SummarizeMessages(msgs []Message, instruction string) (string, error) {
return summarizeWithLLM(msgs, instruction)
}
func fetchAndSummarize(n uint32, instruction string) (string, error) {
cl, err := Connect()
if err != nil {
return "", fmt.Errorf("Email-Verbindung: %w", err)
}
defer cl.Close()
msgs, err := cl.FetchRecent(n)
if err != nil {
return "", fmt.Errorf("Emails abrufen: %w", err)
}
if len(msgs) == 0 {
return "📭 Keine Emails gefunden.", nil
}
slog.Info("Email-Zusammenfassung gestartet", "anzahl", len(msgs))
return summarizeWithLLM(msgs, instruction)
}
// emailModel gibt das konfigurierte Modell für Email-Zusammenfassungen zurück.
// Fällt auf chat.model zurück wenn email.model nicht gesetzt ist.
func emailModel() string {
if config.Cfg.Email.Model != "" {
return config.Cfg.Email.Model
}
return config.Cfg.Chat.Model
}
// formatEmailList formatiert Emails als lesbaren Text (Fallback und Eingabe fürs LLM).
func formatEmailList(msgs []Message) string {
var sb strings.Builder
for i, m := range msgs {
fmt.Fprintf(&sb, "[%d] Von: %s | Datum: %s | Betreff: %s\n", i+1, m.From, m.Date, m.Subject)
}
return sb.String()
}
func summarizeWithLLM(msgs []Message, instruction string) (string, error) {
emailList := formatEmailList(msgs)
model := emailModel()
chatClient := config.NewChatClient()
ctx := context.Background()
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Analysiere Email-Listen und antworte auf Deutsch, präzise und strukturiert.`
userPrompt := fmt.Sprintf("%s\n\nEmail-Liste:\n%s", instruction, emailList)
slog.Debug("[LLM] Email Prompt",
"model", model,
"emails", len(msgs),
"system", systemPrompt,
"user", userPrompt,
)
start := time.Now()
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
Temperature: 0.5,
MaxTokens: 600,
})
if err != nil {
slog.Warn("[LLM] nicht erreichbar, Fallback-Liste", "fehler", err)
return fallbackList(msgs), nil
}
defer stream.Close()
var answer strings.Builder
for {
resp, err := stream.Recv()
if err != nil {
break
}
if len(resp.Choices) > 0 {
answer.WriteString(resp.Choices[0].Delta.Content)
}
}
result := answer.String()
slog.Debug("[LLM] Email Antwort",
"dauer", time.Since(start).Round(time.Millisecond),
"zeichen", len(result),
"antwort", result,
)
if strings.TrimSpace(result) == "" {
slog.Warn("[LLM] leere Antwort, Fallback-Liste")
return fallbackList(msgs), nil
}
slog.Info("[LLM] Email-Zusammenfassung abgeschlossen", "dauer", time.Since(start).Round(time.Millisecond), "zeichen", len(result))
return result, nil
}
// fallbackList gibt eine einfache formatierte Liste zurück wenn das LLM nicht verfügbar ist.
func fallbackList(msgs []Message) string {
var sb strings.Builder
sb.WriteString("⚠️ *LLM nicht verfügbar ungefilterte Email-Liste:*\n\n")
for i, m := range msgs {
fmt.Fprintf(&sb, "**[%d]** %s\n📤 %s\n📌 %s\n\n", i+1, m.Date, m.From, m.Subject)
}
return sb.String()
}