Compare commits

...

2 commits

Author SHA1 Message Date
DonVoo
03ce0ad8ea Count account test without fetching bodies 2026-07-14 18:29:37 +02:00
DonVoo
a17d2c8a2c Use header-only IMAP lists 2026-07-14 18:23:31 +02:00
6 changed files with 472 additions and 190 deletions

View file

@ -14,8 +14,11 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
) )
const mailboxListLimit = 200
func RegisterRoutes(mux *http.ServeMux) { func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", homeHandler) mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/login", loginHandler) mux.HandleFunc("/login", loginHandler)
@ -394,13 +397,13 @@ func sourceMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return return
} }
messages, err := fetchSourceFolderMessages(account, folder) headers, err := fetchSourceFolderHeaders(account, folder)
if err != nil { if err != nil {
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return return
} }
renderSourceMailboxMessageList(w, account, folder, messages) renderSourceMailboxMessageList(w, account, folder, headers)
fmt.Fprintf(w, `<div id="read" hx-swap-oob="innerHTML">%s</div>`, renderSourceMailboxRead(account, folder, 0, messages)) fmt.Fprintf(w, `<div id="read" hx-swap-oob="innerHTML">%s</div>`, renderSourceMailboxRead(account, folder, firstHeaderUID(headers)))
} }
func sourceMailboxMessageHandler(w http.ResponseWriter, r *http.Request) { func sourceMailboxMessageHandler(w http.ResponseWriter, r *http.Request) {
@ -409,23 +412,19 @@ func sourceMailboxMessageHandler(w http.ResponseWriter, r *http.Request) {
} }
accountName := strings.TrimSpace(r.URL.Query().Get("source")) accountName := strings.TrimSpace(r.URL.Query().Get("source"))
folder := strings.TrimSpace(r.URL.Query().Get("folder")) folder := strings.TrimSpace(r.URL.Query().Get("folder"))
index, err := strconv.Atoi(r.URL.Query().Get("index")) uid := parseUIDParam(r)
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
account, err := GetAccount(accountName) account, err := GetAccount(accountName)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
messages, err := fetchSourceFolderMessages(account, folder) raw, err := fetchSourceMailboxMessage(account, folder, uid)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderSourceMailboxRead(account, folder, index, messages)) fmt.Fprint(w, renderSourceMailboxReadMessage(account, folder, raw))
} }
func targetMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) { func targetMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) {
@ -444,13 +443,13 @@ func targetMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return return
} }
messages, err := fetchTargetFolderMessages(account, folder) headers, err := fetchTargetFolderHeaders(account, folder)
if err != nil { if err != nil {
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return return
} }
renderTargetMailboxMessageList(w, account, folder, messages) renderTargetMailboxMessageList(w, account, folder, headers)
fmt.Fprintf(w, `<div id="read" hx-swap-oob="innerHTML">%s</div>`, renderTargetMailboxRead(account, folder, 0, messages)) fmt.Fprintf(w, `<div id="read" hx-swap-oob="innerHTML">%s</div>`, renderTargetMailboxRead(account, folder, firstHeaderUID(headers)))
} }
func targetMailboxMessageHandler(w http.ResponseWriter, r *http.Request) { func targetMailboxMessageHandler(w http.ResponseWriter, r *http.Request) {
@ -459,23 +458,19 @@ func targetMailboxMessageHandler(w http.ResponseWriter, r *http.Request) {
} }
accountName := strings.TrimSpace(r.URL.Query().Get("target")) accountName := strings.TrimSpace(r.URL.Query().Get("target"))
folder := strings.TrimSpace(r.URL.Query().Get("folder")) folder := strings.TrimSpace(r.URL.Query().Get("folder"))
index, err := strconv.Atoi(r.URL.Query().Get("index")) uid := parseUIDParam(r)
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
account, err := GetAccount(accountName) account, err := GetAccount(accountName)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
messages, err := fetchTargetFolderMessages(account, folder) raw, err := fetchTargetMailboxMessage(account, folder, uid)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTargetMailboxRead(account, folder, index, messages)) fmt.Fprint(w, renderTargetMailboxReadMessage(account, folder, raw))
} }
func archiveCreateHandler(w http.ResponseWriter, r *http.Request) { func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
@ -999,7 +994,7 @@ func transferMessagesHandler(w http.ResponseWriter, r *http.Request) {
folder := strings.TrimSpace(r.URL.Query().Get("folder")) folder := strings.TrimSpace(r.URL.Query().Get("folder"))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTransferMessageList(side, kind, value, folder)) fmt.Fprint(w, renderTransferMessageList(side, kind, value, folder))
fmt.Fprintf(w, `<div id="transfer-preview" hx-swap-oob="innerHTML">%s</div>`, renderTransferPreview(kind, value, folder, 0)) fmt.Fprintf(w, `<div id="transfer-preview" hx-swap-oob="innerHTML">%s</div>`, renderTransferPreview(kind, value, folder, 0, 0))
} }
func transferMessageHandler(w http.ResponseWriter, r *http.Request) { func transferMessageHandler(w http.ResponseWriter, r *http.Request) {
@ -1009,13 +1004,10 @@ func transferMessageHandler(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("kind")) kind := strings.TrimSpace(r.URL.Query().Get("kind"))
value := strings.TrimSpace(r.URL.Query().Get("value")) value := strings.TrimSpace(r.URL.Query().Get("value"))
folder := strings.TrimSpace(r.URL.Query().Get("folder")) folder := strings.TrimSpace(r.URL.Query().Get("folder"))
index, err := strconv.Atoi(r.URL.Query().Get("index")) index, _ := strconv.Atoi(r.URL.Query().Get("index"))
if err != nil { uid := parseUIDParam(r)
http.Error(w, "bad index", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTransferPreview(kind, value, folder, index)) fmt.Fprint(w, renderTransferPreview(kind, value, folder, index, uid))
} }
func transferSide(value string) string { func transferSide(value string) string {
@ -1183,12 +1175,12 @@ func renderInitialTransferList(side, kind string, accounts []Account, archives [
func renderInitialTransferPreview(leftKind, leftValue, rightKind, rightValue string) string { func renderInitialTransferPreview(leftKind, leftValue, rightKind, rightValue string) string {
if leftValue != "" { if leftValue != "" {
if folder, err := firstTransferFolder(leftKind, leftValue); err == nil { if folder, err := firstTransferFolder(leftKind, leftValue); err == nil {
return renderTransferPreview(leftKind, leftValue, folder, 0) return renderTransferPreview(leftKind, leftValue, folder, 0, 0)
} }
} }
if rightValue != "" { if rightValue != "" {
if folder, err := firstTransferFolder(rightKind, rightValue); err == nil { if folder, err := firstTransferFolder(rightKind, rightValue); err == nil {
return renderTransferPreview(rightKind, rightValue, folder, 0) return renderTransferPreview(rightKind, rightValue, folder, 0, 0)
} }
} }
return renderEmptyPreviewPane("Nachricht links oder rechts waehlen.") return renderEmptyPreviewPane("Nachricht links oder rechts waehlen.")
@ -1271,56 +1263,45 @@ func renderTransferMessageList(side, kind, value, folder string) string {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String() return b.String()
} }
messages, err := fetchTargetFolderMessages(account, folder) messages, err := fetchTargetFolderHeaders(account, folder)
if err != nil { if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String() return b.String()
} }
renderTransferRawMessages(&b, side, kind, value, folder, messages) renderTransferHeaders(&b, side, kind, value, folder, messages)
default: default:
account, err := GetAccount(value) account, err := GetAccount(value)
if err != nil { if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String() return b.String()
} }
messages, err := fetchSourceFolderMessages(account, folder) messages, err := fetchSourceFolderHeaders(account, folder)
if err != nil { if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String() return b.String()
} }
renderTransferRawMessages(&b, side, kind, value, folder, messages) renderTransferHeaders(&b, side, kind, value, folder, messages)
} }
return b.String() return b.String()
} }
func renderTransferRawMessages(b *strings.Builder, side, kind, value, folder string, messages []RawMessage) { func renderTransferHeaders(b *strings.Builder, side, kind, value, folder string, messages []MessageHeader) {
for i, raw := range messages { for i, header := range messages {
subject := "(ohne Betreff)" subject, from, date := headerListFields(header)
from := "(ohne Absender)"
date := ""
if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil {
if v := decodeHeader(msg.Header.Get("Subject")); v != "" {
subject = v
}
if v := decodeHeader(msg.Header.Get("From")); v != "" {
from = v
}
date = decodeHeader(msg.Header.Get("Date"))
}
active := "" active := ""
if i == 0 { if i == 0 {
active = " active" active = " active"
} }
fmt.Fprintf(b, `<a class="msg-row%s" href="#" hx-get="/transfer/message?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s&amp;index=%d" hx-target="#transfer-preview" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`, fmt.Fprintf(b, `<a class="msg-row%s" href="#" hx-get="/transfer/message?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s&amp;uid=%d" hx-target="#transfer-preview" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
active, side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), i, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) active, side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), header.UID, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
} }
if len(messages) == 0 { if len(messages) == 0 {
b.WriteString(`<div class="ef-empty">Keine Nachrichten.</div>`) b.WriteString(`<div class="ef-empty">Keine Nachrichten.</div>`)
} }
} }
func renderTransferPreview(kind, value, folder string, index int) string { func renderTransferPreview(kind, value, folder string, index int, uid uint32) string {
raw, label, err := transferRawMessage(kind, value, folder, index) raw, label, err := transferRawMessage(kind, value, folder, index, uid)
if err != nil { if err != nil {
return renderEmptyPreviewPane(err.Error()) return renderEmptyPreviewPane(err.Error())
} }
@ -1339,7 +1320,7 @@ func renderTransferPreview(kind, value, folder string, index int) string {
return b.String() return b.String()
} }
func transferRawMessage(kind, value, folder string, index int) ([]byte, string, error) { func transferRawMessage(kind, value, folder string, index int, uid uint32) ([]byte, string, error) {
switch kind { switch kind {
case "archive": case "archive":
path, err := mboxPath(value, folder) path, err := mboxPath(value, folder)
@ -1353,27 +1334,15 @@ func transferRawMessage(kind, value, folder string, index int) ([]byte, string,
if err != nil { if err != nil {
return nil, value, err return nil, value, err
} }
messages, err := fetchTargetFolderMessages(account, folder) msg, err := fetchTargetMailboxMessage(account, folder, uid)
if err != nil { return msg.Body, account.DstUser, err
return nil, account.DstUser, err
}
if index < 0 || index >= len(messages) {
return nil, account.DstUser, fmt.Errorf("Nachricht waehlen.")
}
return messages[index].Body, account.DstUser, nil
default: default:
account, err := GetAccount(value) account, err := GetAccount(value)
if err != nil { if err != nil {
return nil, value, err return nil, value, err
} }
messages, err := fetchSourceFolderMessages(account, folder) msg, err := fetchSourceMailboxMessage(account, folder, uid)
if err != nil { return msg.Body, account.SrcUser, err
return nil, account.SrcUser, err
}
if index < 0 || index >= len(messages) {
return nil, account.SrcUser, fmt.Errorf("Nachricht waehlen.")
}
return messages[index].Body, account.SrcUser, nil
} }
} }
@ -1545,33 +1514,35 @@ func sourceFolderRole(folder Folder) string {
return "" return ""
} }
func fetchSourceFolderMessages(account Account, folder string) ([]RawMessage, error) { func fetchSourceFolderHeaders(account Account, folder string) ([]MessageHeader, error) {
if accountProto(account) == "pop3" { if accountProto(account) == "pop3" {
src, err := OpenPOP3Source(account) return nil, fmt.Errorf("POP3-Headerlisten sind noch nicht unterstuetzt.")
if err != nil {
return nil, err
}
defer src.Close()
return collectSourceMessages(src, folder)
} }
src, err := OpenIMAPSource(account) src, err := OpenIMAPSource(account)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer src.Close() defer src.Close()
return collectSourceMessages(src, folder) return src.Headers(folder, mailboxListLimit, 0)
} }
func collectSourceMessages(src SourceMailbox, folder string) ([]RawMessage, error) { func fetchSourceMailboxMessage(account Account, folder string, uid uint32) (RawMessage, error) {
var messages []RawMessage if accountProto(account) == "pop3" {
err := src.Fetch(folder, func(m RawMessage) error { return RawMessage{}, fmt.Errorf("POP3-Nachrichtenansicht ist noch nicht unterstuetzt.")
messages = append(messages, m)
return nil
})
if err != nil {
return messages, err
} }
return messages, nil src, err := OpenIMAPSource(account)
if err != nil {
return RawMessage{}, err
}
defer src.Close()
if uid == 0 {
headers, err := src.Headers(folder, 1, 0)
if err != nil {
return RawMessage{}, err
}
uid = firstHeaderUID(headers)
}
return src.FetchOne(folder, uid)
} }
func renderInitialSourceMailboxList(account Account) string { func renderInitialSourceMailboxList(account Account) string {
@ -1582,12 +1553,12 @@ func renderInitialSourceMailboxList(account Account) string {
if err != nil { if err != nil {
return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
} }
messages, err := fetchSourceFolderMessages(account, folder) headers, err := fetchSourceFolderHeaders(account, folder)
if err != nil { if err != nil {
return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
} }
var b strings.Builder var b strings.Builder
renderSourceMailboxMessageList(&b, account, folder, messages) renderSourceMailboxMessageList(&b, account, folder, headers)
return b.String() return b.String()
} }
@ -1599,11 +1570,11 @@ func renderInitialSourceMailboxRead(account Account) string {
if err != nil { if err != nil {
return `<div class="ef-empty">Nachricht waehlen.</div>` return `<div class="ef-empty">Nachricht waehlen.</div>`
} }
messages, err := fetchSourceFolderMessages(account, folder) headers, err := fetchSourceFolderHeaders(account, folder)
if err != nil { if err != nil {
return `<div class="ef-empty">Nachricht waehlen.</div>` return `<div class="ef-empty">Nachricht waehlen.</div>`
} }
return renderSourceMailboxRead(account, folder, 0, messages) return renderSourceMailboxRead(account, folder, firstHeaderUID(headers))
} }
func firstSourceFolder(account Account) (string, error) { func firstSourceFolder(account Account) (string, error) {
@ -1630,29 +1601,18 @@ func firstSourceFolder(account Account) (string, error) {
return folders[0].Name, nil return folders[0].Name, nil
} }
func renderSourceMailboxMessageList(w io.Writer, account Account, folder string, messages []RawMessage) { func renderSourceMailboxMessageList(w io.Writer, account Account, folder string, messages []MessageHeader) {
fmt.Fprint(w, `<div class="pane-head">Nachrichten</div>`) fmt.Fprint(w, `<div class="pane-head">Nachrichten</div>`)
for i, raw := range messages { for i, header := range messages {
subject := "(ohne Betreff)" subject, from, date := headerListFields(header)
from := "(ohne Absender)"
date := ""
if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil {
if v := decodeHeader(msg.Header.Get("Subject")); v != "" {
subject = v
}
if v := decodeHeader(msg.Header.Get("From")); v != "" {
from = v
}
date = decodeHeader(msg.Header.Get("Date"))
}
active := "" active := ""
if i == 0 { if i == 0 {
active = " active" active = " active"
} }
fmt.Fprintf(w, `<a class="msg-row%s" href="#" draggable="true" data-source="%s" data-folder="%s" data-index="%d" hx-get="/boxes/source/message?source=%s&amp;folder=%s&amp;index=%d" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`, fmt.Fprintf(w, `<a class="msg-row%s" href="#" draggable="true" data-source="%s" data-folder="%s" data-uid="%d" hx-get="/boxes/source/message?source=%s&amp;folder=%s&amp;uid=%d" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
active, active,
html.EscapeString(account.Name), html.EscapeString(folder), i, html.EscapeString(account.Name), html.EscapeString(folder), header.UID,
urlEsc(account.Name), urlEsc(folder), i, urlEsc(account.Name), urlEsc(folder), header.UID,
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
} }
if len(messages) == 0 { if len(messages) == 0 {
@ -1660,22 +1620,29 @@ func renderSourceMailboxMessageList(w io.Writer, account Account, folder string,
} }
} }
func renderSourceMailboxRead(account Account, folder string, index int, messages []RawMessage) string { func renderSourceMailboxRead(account Account, folder string, uid uint32) string {
if index < 0 || index >= len(messages) { if uid == 0 {
return `<div class="ef-empty">Nachricht waehlen.</div>` return `<div class="ef-empty">Nachricht waehlen.</div>`
} }
raw := messages[index].Body raw, err := fetchSourceMailboxMessage(account, folder, uid)
msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil {
return `<div class="ef-empty">Nachricht waehlen.</div>`
}
return renderSourceMailboxReadMessage(account, folder, raw)
}
func renderSourceMailboxReadMessage(account Account, folder string, raw RawMessage) string {
msg, err := mail.ReadMessage(bytes.NewReader(raw.Body))
var b strings.Builder var b strings.Builder
if err != nil { if err != nil {
renderMailboxReadPane(&b, "Quelle", account.Name, folder, index, "(unlesbar)", "", "", string(raw)) renderMailboxReadPane(&b, "Quelle", account.Name, folder, 0, "(unlesbar)", "", "", string(raw.Body))
return b.String() return b.String()
} }
renderMailboxReadPane(&b, "Quelle", account.Name, folder, index, renderMailboxReadPane(&b, "Quelle", account.Name, folder, 0,
decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date")), decodeHeader(msg.Header.Get("Date")),
messageBody(raw), messageBody(raw.Body),
) )
return b.String() return b.String()
} }
@ -1755,13 +1722,29 @@ func targetFolderRole(folder TargetFolder) string {
return "" return ""
} }
func fetchTargetFolderMessages(account Account, folder string) ([]RawMessage, error) { func fetchTargetFolderHeaders(account Account, folder string) ([]MessageHeader, error) {
dst, err := OpenIMAPTarget(account) dst, err := OpenIMAPTarget(account)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer dst.Close() defer dst.Close()
return dst.Fetch(folder) return dst.Headers(folder, mailboxListLimit, 0)
}
func fetchTargetMailboxMessage(account Account, folder string, uid uint32) (RawMessage, error) {
dst, err := OpenIMAPTarget(account)
if err != nil {
return RawMessage{}, err
}
defer dst.Close()
if uid == 0 {
headers, err := dst.Headers(folder, 1, 0)
if err != nil {
return RawMessage{}, err
}
uid = firstHeaderUID(headers)
}
return dst.FetchOne(folder, uid)
} }
func renderInitialTargetMailboxList(account Account) string { func renderInitialTargetMailboxList(account Account) string {
@ -1772,12 +1755,12 @@ func renderInitialTargetMailboxList(account Account) string {
if err != nil { if err != nil {
return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
} }
messages, err := fetchTargetFolderMessages(account, folder) headers, err := fetchTargetFolderHeaders(account, folder)
if err != nil { if err != nil {
return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error())) return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
} }
var b strings.Builder var b strings.Builder
renderTargetMailboxMessageList(&b, account, folder, messages) renderTargetMailboxMessageList(&b, account, folder, headers)
return b.String() return b.String()
} }
@ -1789,11 +1772,11 @@ func renderInitialTargetMailboxRead(account Account) string {
if err != nil { if err != nil {
return `<div class="ef-empty">Nachricht waehlen.</div>` return `<div class="ef-empty">Nachricht waehlen.</div>`
} }
messages, err := fetchTargetFolderMessages(account, folder) headers, err := fetchTargetFolderHeaders(account, folder)
if err != nil { if err != nil {
return `<div class="ef-empty">Nachricht waehlen.</div>` return `<div class="ef-empty">Nachricht waehlen.</div>`
} }
return renderTargetMailboxRead(account, folder, 0, messages) return renderTargetMailboxRead(account, folder, firstHeaderUID(headers))
} }
func firstTargetFolder(account Account) (string, error) { func firstTargetFolder(account Account) (string, error) {
@ -1817,29 +1800,18 @@ func firstTargetFolder(account Account) (string, error) {
return folders[0].Name, nil return folders[0].Name, nil
} }
func renderTargetMailboxMessageList(w io.Writer, account Account, folder string, messages []RawMessage) { func renderTargetMailboxMessageList(w io.Writer, account Account, folder string, messages []MessageHeader) {
fmt.Fprint(w, `<div class="pane-head">Nachrichten</div>`) fmt.Fprint(w, `<div class="pane-head">Nachrichten</div>`)
for i, raw := range messages { for i, header := range messages {
subject := "(ohne Betreff)" subject, from, date := headerListFields(header)
from := "(ohne Absender)"
date := ""
if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil {
if v := decodeHeader(msg.Header.Get("Subject")); v != "" {
subject = v
}
if v := decodeHeader(msg.Header.Get("From")); v != "" {
from = v
}
date = decodeHeader(msg.Header.Get("Date"))
}
active := "" active := ""
if i == 0 { if i == 0 {
active = " active" active = " active"
} }
fmt.Fprintf(w, `<a class="msg-row%s" href="#" draggable="true" data-target="%s" data-folder="%s" data-index="%d" hx-get="/boxes/target/message?target=%s&amp;folder=%s&amp;index=%d" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`, fmt.Fprintf(w, `<a class="msg-row%s" href="#" draggable="true" data-target="%s" data-folder="%s" data-uid="%d" hx-get="/boxes/target/message?target=%s&amp;folder=%s&amp;uid=%d" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
active, active,
html.EscapeString(account.Name), html.EscapeString(folder), i, html.EscapeString(account.Name), html.EscapeString(folder), header.UID,
urlEsc(account.Name), urlEsc(folder), i, urlEsc(account.Name), urlEsc(folder), header.UID,
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
} }
if len(messages) == 0 { if len(messages) == 0 {
@ -1847,26 +1819,62 @@ func renderTargetMailboxMessageList(w io.Writer, account Account, folder string,
} }
} }
func renderTargetMailboxRead(account Account, folder string, index int, messages []RawMessage) string { func renderTargetMailboxRead(account Account, folder string, uid uint32) string {
if index < 0 || index >= len(messages) { if uid == 0 {
return `<div class="ef-empty">Nachricht waehlen.</div>` return `<div class="ef-empty">Nachricht waehlen.</div>`
} }
raw := messages[index].Body raw, err := fetchTargetMailboxMessage(account, folder, uid)
msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil {
return `<div class="ef-empty">Nachricht waehlen.</div>`
}
return renderTargetMailboxReadMessage(account, folder, raw)
}
func renderTargetMailboxReadMessage(account Account, folder string, raw RawMessage) string {
msg, err := mail.ReadMessage(bytes.NewReader(raw.Body))
var b strings.Builder var b strings.Builder
if err != nil { if err != nil {
renderMailboxReadPane(&b, "Ziel", account.Name, folder, index, "(unlesbar)", "", "", string(raw)) renderMailboxReadPane(&b, "Ziel", account.Name, folder, 0, "(unlesbar)", "", "", string(raw.Body))
return b.String() return b.String()
} }
renderMailboxReadPane(&b, "Ziel", account.Name, folder, index, renderMailboxReadPane(&b, "Ziel", account.Name, folder, 0,
decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date")), decodeHeader(msg.Header.Get("Date")),
messageBody(raw), messageBody(raw.Body),
) )
return b.String() return b.String()
} }
func firstHeaderUID(headers []MessageHeader) uint32 {
if len(headers) == 0 {
return 0
}
return headers[0].UID
}
func parseUIDParam(r *http.Request) uint32 {
uid, _ := strconv.ParseUint(strings.TrimSpace(r.URL.Query().Get("uid")), 10, 32)
return uint32(uid)
}
func headerListFields(header MessageHeader) (subject, from, date string) {
subject = strings.TrimSpace(header.Subject)
if subject == "" {
subject = "(ohne Betreff)"
}
from = strings.TrimSpace(header.From)
if from == "" {
from = "(ohne Absender)"
}
if !header.Date.IsZero() {
date = header.Date.Format(time.RFC1123Z)
} else if !header.InternalDate.IsZero() {
date = header.InternalDate.Format(time.RFC1123Z)
}
return subject, from, date
}
func renderMailboxReadPane(w io.Writer, title, account, folder string, index int, subject, from, date, body string) { func renderMailboxReadPane(w io.Writer, title, account, folder string, index int, subject, from, date, body string) {
if subject == "" { if subject == "" {
subject = "(ohne Betreff)" subject = "(ohne Betreff)"

View file

@ -24,6 +24,17 @@ type RawMessage struct {
InternalDate time.Time // Original-Zeit, per APPEND erhalten InternalDate time.Time // Original-Zeit, per APPEND erhalten
} }
type MessageHeader struct {
UID uint32
MessageID string
Subject string
From string
Date time.Time
InternalDate time.Time
Flags []string
Size int64
}
// Folder ist ein Ordner der Quelle MIT den Infos, die 11-folders.go fuer die // Folder ist ein Ordner der Quelle MIT den Infos, die 11-folders.go fuer die
// Rollen-Zuordnung braucht: dekodierter Name, SPECIAL-USE-Attribute und der // Rollen-Zuordnung braucht: dekodierter Name, SPECIAL-USE-Attribute und der
// Hierarchie-Trenner. // Hierarchie-Trenner.
@ -36,7 +47,10 @@ type Folder struct {
// SourceMailbox abstrahiert Quelle (IMAP primaer, POP3 Fallback), damit // SourceMailbox abstrahiert Quelle (IMAP primaer, POP3 Fallback), damit
// 07-migrate.go gegen ein Interface arbeitet. // 07-migrate.go gegen ein Interface arbeitet.
type SourceMailbox interface { type SourceMailbox interface {
Folders() ([]Folder, error) // rekursiver Ordnerbaum Folders() ([]Folder, error) // rekursiver Ordnerbaum
Count(folder string) (int, error)
Headers(folder string, limit, offset int) ([]MessageHeader, error)
FetchOne(folder string, uid uint32) (RawMessage, error)
Fetch(folder string, fn func(RawMessage) error) error // BODY[] FLAGS INTERNALDATE Fetch(folder string, fn func(RawMessage) error) error // BODY[] FLAGS INTERNALDATE
Close() error Close() error
} }
@ -79,6 +93,18 @@ func (s *imapSource) Fetch(folder string, fn func(RawMessage) error) error {
return s.mailbox.fetchEach(folder, fn) return s.mailbox.fetchEach(folder, fn)
} }
func (s *imapSource) Count(folder string) (int, error) {
return s.mailbox.count(folder)
}
func (s *imapSource) Headers(folder string, limit, offset int) ([]MessageHeader, error) {
return s.mailbox.fetchHeaders(folder, limit, offset)
}
func (s *imapSource) FetchOne(folder string, uid uint32) (RawMessage, error) {
return s.mailbox.fetchOne(folder, uid)
}
func (s *imapSource) Close() error { func (s *imapSource) Close() error {
return s.mailbox.Close() return s.mailbox.Close()
} }
@ -154,6 +180,7 @@ func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error)
InternalDate: true, InternalDate: true,
BodySection: []*imap.FetchItemBodySection{section}, BodySection: []*imap.FetchItemBodySection{section},
}) })
defer cmd.Close()
for { for {
data := cmd.Next() data := cmd.Next()
@ -162,38 +189,111 @@ func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error)
} }
buf, err := data.Collect() buf, err := data.Collect()
if err != nil { if err != nil {
_ = cmd.Close()
return err return err
} }
body := buf.FindBodySection(section) if err := fn(rawMessageFromFetch(buf, section)); err != nil {
if body == nil {
body = []byte{}
}
if err := fn(RawMessage{
MessageID: messageIDFromFetch(buf, body),
Body: body,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
InternalDate: buf.InternalDate,
}); err != nil {
_ = cmd.Close()
return err return err
} }
} }
return cmd.Close() return cmd.Close()
} }
func (m *imapClientMailbox) fetchAll(folder string) ([]RawMessage, error) { func (m *imapClientMailbox) count(folder string) (int, error) {
var out []RawMessage selected, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
err := m.fetchEach(folder, func(m RawMessage) error {
out = append(out, m)
return nil
})
if err != nil { if err != nil {
return 0, err
}
return int(selected.NumMessages), nil
}
func (m *imapClientMailbox) fetchHeaders(folder string, limit, offset int) ([]MessageHeader, error) {
selected, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return nil, err
}
if selected.NumMessages == 0 {
return nil, nil
}
if limit <= 0 || limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
stop := int(selected.NumMessages) - offset
if stop <= 0 {
return nil, nil
}
start := stop - limit + 1
if start < 1 {
start = 1
}
seqSet := imap.SeqSet{}
seqSet.AddRange(uint32(start), uint32(stop))
cmd := m.c.Fetch(seqSet, &imap.FetchOptions{
Envelope: true,
Flags: true,
InternalDate: true,
RFC822Size: true,
UID: true,
})
defer cmd.Close()
var out []MessageHeader
for {
data := cmd.Next()
if data == nil {
break
}
buf, err := data.Collect()
if err != nil {
return out, err
}
out = append(out, messageHeaderFromFetch(buf))
}
if err := cmd.Close(); err != nil {
return out, err return out, err
} }
reverseHeaders(out)
return out, nil return out, nil
} }
func (m *imapClientMailbox) fetchOne(folder string, uid uint32) (RawMessage, error) {
if uid == 0 {
return RawMessage{}, fmt.Errorf("Nachricht ohne UID")
}
if _, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return RawMessage{}, err
}
section := &imap.FetchItemBodySection{Peek: true}
cmd := m.c.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{
Envelope: true,
Flags: true,
InternalDate: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{section},
})
defer cmd.Close()
data := cmd.Next()
if data == nil {
if err := cmd.Close(); err != nil {
return RawMessage{}, err
}
return RawMessage{}, fmt.Errorf("Nachricht nicht gefunden")
}
buf, err := data.Collect()
if err != nil {
return RawMessage{}, err
}
msg := rawMessageFromFetch(buf, section)
if err := cmd.Close(); err != nil {
return RawMessage{}, err
}
return msg, nil
}
func (m *imapClientMailbox) Close() error { func (m *imapClientMailbox) Close() error {
if m == nil || m.c == nil { if m == nil || m.c == nil {
return nil return nil
@ -262,9 +362,68 @@ func stringsToFlags(flags []string) []imap.Flag {
}()) }())
} }
func rawMessageFromFetch(buf *imapclient.FetchMessageBuffer, section *imap.FetchItemBodySection) RawMessage {
body := buf.FindBodySection(section)
if body == nil {
body = []byte{}
}
return RawMessage{
MessageID: messageIDFromFetch(buf, body),
Body: body,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
InternalDate: buf.InternalDate,
}
}
func messageHeaderFromFetch(buf *imapclient.FetchMessageBuffer) MessageHeader {
h := MessageHeader{
UID: uint32(buf.UID),
MessageID: messageIDFromFetch(buf, nil),
InternalDate: buf.InternalDate,
Flags: flagsToStrings(sanitizeIMAPFlags(buf.Flags)),
Size: buf.RFC822Size,
}
if buf.Envelope != nil {
h.Subject = strings.TrimSpace(buf.Envelope.Subject)
h.From = envelopeFrom(buf.Envelope)
h.Date = buf.Envelope.Date
}
if h.Date.IsZero() {
h.Date = h.InternalDate
}
return h
}
func envelopeFrom(env *imap.Envelope) string {
if env == nil || len(env.From) == 0 {
return ""
}
for _, addr := range env.From {
email := strings.TrimSpace(addr.Addr())
if email == "" {
continue
}
name := strings.TrimSpace(addr.Name)
if name != "" {
return (&mail.Address{Name: name, Address: email}).String()
}
return email
}
return ""
}
func reverseHeaders(headers []MessageHeader) {
for i, j := 0, len(headers)-1; i < j; i, j = i+1, j-1 {
headers[i], headers[j] = headers[j], headers[i]
}
}
func messageIDFromFetch(buf *imapclient.FetchMessageBuffer, body []byte) string { func messageIDFromFetch(buf *imapclient.FetchMessageBuffer, body []byte) string {
if buf != nil && buf.Envelope != nil && strings.TrimSpace(buf.Envelope.MessageID) != "" { if buf != nil && buf.Envelope != nil && strings.TrimSpace(buf.Envelope.MessageID) != "" {
return strings.TrimSpace(buf.Envelope.MessageID) return normalizeMessageID(buf.Envelope.MessageID)
}
if len(body) == 0 {
return ""
} }
return messageID(body) return messageID(body)
} }
@ -273,12 +432,16 @@ func messageID(body []byte) string {
msg, err := mail.ReadMessage(strings.NewReader(string(body))) msg, err := mail.ReadMessage(strings.NewReader(string(body)))
if err == nil { if err == nil {
if id := strings.TrimSpace(msg.Header.Get("Message-ID")); id != "" { if id := strings.TrimSpace(msg.Header.Get("Message-ID")); id != "" {
return id return normalizeMessageID(id)
} }
} }
return fmt.Sprintf("sha256:%x", sha256.Sum256(body)) return fmt.Sprintf("sha256:%x", sha256.Sum256(body))
} }
func normalizeMessageID(id string) string {
return strings.Trim(strings.TrimSpace(id), "<>")
}
func isAlreadyExistsError(err error) bool { func isAlreadyExistsError(err error) bool {
var imapErr *imap.Error var imapErr *imap.Error
if errors.As(err, &imapErr) { if errors.As(err, &imapErr) {

View file

@ -12,11 +12,12 @@ import (
// Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum // Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum
// zustellen und die Ordnerstruktur zerstoeren. // zustellen und die Ordnerstruktur zerstoeren.
type TargetMailbox interface { type TargetMailbox interface {
Folders() ([]TargetFolder, error) // vorhandene Ziel-Ordner (fuer Rollen-Zuordnung) Folders() ([]TargetFolder, error) // vorhandene Ziel-Ordner (fuer Rollen-Zuordnung)
Delim() string // Hierarchie-Trenner des Ziels Delim() string // Hierarchie-Trenner des Ziels
EnsureFolder(name string) error // anlegen + subscriben, falls fehlt EnsureFolder(name string) error // anlegen + subscriben, falls fehlt
Append(folder string, m RawMessage) error // mit m.Flags und m.InternalDate Append(folder string, m RawMessage) error // mit m.Flags und m.InternalDate
Fetch(folder string) ([]RawMessage, error) // Ziel-Audit: geschriebene Mail wiederfinden Headers(folder string, limit, offset int) ([]MessageHeader, error)
FetchOne(folder string, uid uint32) (RawMessage, error)
Close() error Close() error
} }
@ -120,8 +121,12 @@ func (t *imapTarget) Append(folder string, m RawMessage) error {
return err return err
} }
func (t *imapTarget) Fetch(folder string) ([]RawMessage, error) { func (t *imapTarget) Headers(folder string, limit, offset int) ([]MessageHeader, error) {
return t.mailbox.fetchAll(folder) return t.mailbox.fetchHeaders(folder, limit, offset)
}
func (t *imapTarget) FetchOne(folder string, uid uint32) (RawMessage, error) {
return t.mailbox.fetchOne(folder, uid)
} }
func (t *imapTarget) Close() error { func (t *imapTarget) Close() error {

View file

@ -210,12 +210,9 @@ func CheckAccount(name string) error {
} }
total := 0 total := 0
for _, folder := range folders { for _, folder := range folders {
folderTotal := 0 folderTotal, err := src.Count(folder.Name)
if err := src.Fetch(folder.Name, func(m RawMessage) error { if err != nil {
folderTotal++ return fmt.Errorf("count source %s %s: %w", a.Name, folder.Name, err)
return nil
}); err != nil {
return fmt.Errorf("fetch source %s %s: %w", a.Name, folder.Name, err)
} }
log.Printf("account %s source folder %s messages=%d", a.Name, folder.Name, folderTotal) log.Printf("account %s source folder %s messages=%d", a.Name, folder.Name, folderTotal)
total += folderTotal total += folderTotal

View file

@ -490,23 +490,33 @@ func findTargetIMAPCopy(account, targetAccount string, raw []byte) (forwardedMat
} }
func scanTargetForMessage(dst TargetMailbox, targetUser, messageID string) (forwardedMatch, bool, error) { func scanTargetForMessage(dst TargetMailbox, targetUser, messageID string) (forwardedMatch, bool, error) {
messageID = normalizeMessageID(messageID)
folders, err := dst.Folders() folders, err := dst.Folders()
if err != nil { if err != nil {
return forwardedMatch{}, false, err return forwardedMatch{}, false, err
} }
for _, folder := range folders { for _, folder := range folders {
msgs, err := dst.Fetch(folder.Name) for offset := 0; ; offset += mailboxListLimit {
if err != nil { headers, err := dst.Headers(folder.Name, mailboxListLimit, offset)
continue if err != nil {
} break
for i, msg := range msgs { }
if msg.MessageID == messageID || messageIDHeader(msg.Body) == messageID { if len(headers) == 0 {
return forwardedMatch{ break
Account: "Ziel: " + targetUser, }
Folder: folder.Name, for i, header := range headers {
Index: i, if normalizeMessageID(header.MessageID) == messageID {
Raw: msg.Body, msg, err := dst.FetchOne(folder.Name, header.UID)
}, true, nil if err != nil {
continue
}
return forwardedMatch{
Account: "Ziel: " + targetUser,
Folder: folder.Name,
Index: offset + i,
Raw: msg.Body,
}, true, nil
}
} }
} }
} }
@ -560,7 +570,7 @@ func messageIDHeader(raw []byte) string {
if err != nil { if err != nil {
return "" return ""
} }
return strings.TrimSpace(msg.Header.Get("Message-ID")) return normalizeMessageID(msg.Header.Get("Message-ID"))
} }
func renderTargetAccountSelect(account, folder, selected string, index int) string { func renderTargetAccountSelect(account, folder, selected string, index int) string {

99
ui-fetch-brief.md Normal file
View file

@ -0,0 +1,99 @@
# Codex-Brief — UI-Fetch: Header-only + Pagination
Der Migrationspfad streamt jetzt (bewiesen: 80 MB Ordner durch eine harte
16-MiB-Grenze). **Die UI tut es nicht.** Sie ist damit das letzte OOM-Loch — und
das gefährlichere, weil ein Mensch es mit **einem Klick** auslöst.
## Das Problem
`fetchSourceFolderMessages``collectSourceMessages` ([00-router.go:1548](backend/00-router.go))
sammelt **alle Mails eines Ordners samt vollem Body** in eine Slice — nur um eine
**Liste** zu rendern. Aufgerufen an **sechs** Stellen:
`00-router.go:397, 422, 1286, 1369, 1585, 1602`.
Dasselbe auf der Zielseite: [05-imap-target.go:124](backend/05-imap-target.go) → `fetchAll`.
Rechnung für ein echtes `@dr-gold.de`-Postfach: 20.000 Mails × 200 KB =
**~4 GB RAM für einen Klick auf einen Ordner** → Container wird OOM-gekillt.
**Der Kern der Sache:** Eine Nachrichtenliste braucht **Betreff, Absender, Datum,
Flags** — sie braucht **keine Bodies**. Wir holen also Daten, die wir wegwerfen.
## Der Fix
### 1. Header-only-Fetch (neu)
`ENVELOPE` liefert Betreff/Absender/Datum/Message-ID — **ohne** `BodySection`:
```go
type MessageHeader struct {
UID uint32
MessageID string
Subject string
From string
Date time.Time
InternalDate time.Time
Flags []string
Size uint32 // RFC822.SIZE, fuer die Anzeige
}
// Fenster: neueste `limit` Mails ab `offset`. NIE unbegrenzt 1:*.
Headers(folder string, limit, offset int) ([]MessageHeader, error)
```
Implementierung (go-imap/v2) — beachte: **kein `BodySection`**:
```go
fo := &imap.FetchOptions{
Envelope: true, Flags: true, InternalDate: true, UID: true, RFC822Size: true,
// BodySection: ABSICHTLICH NICHT
}
```
Damit ist der Speicher proportional zur **Fenstergröße**, nicht zur Ordnergröße.
### 2. Fenster statt „alles"
Immer ein Bereich (z. B. die **neuesten 200**), berechnet aus
`selected.NumMessages`. Blättern über `offset`. Kein `1:*` für Listen — nirgends.
### 3. Voller Body erst beim Öffnen **einer** Mail
```go
FetchOne(folder string, uid uint32) (RawMessage, error) // mit BodySection
```
Der Lesebereich holt genau **eine** Mail. Das ist der einzige Ort, an dem ein
Body in den Speicher gehört.
### 4. Migration bleibt unangetastet
`Fetch(folder string, fn func(RawMessage) error) error` ist der **Migrationspfad**
und bleibt exakt wie er ist (inkl. der verifizierten Reihenfolge
`AlreadyCopied → dst.Append → mbox.Append → MarkCopied`). Nicht anfassen.
## Umzubauen
- Die **sechs** `fetchSourceFolderMessages`-Aufrufer auf `Headers(...)` umstellen
(Listen) bzw. auf `FetchOne(...)` (Lesebereich).
- Zielseite: `05-imap-target.go:124` (`fetchAll`) genauso.
- `collectSourceMessages` und `fetchAll` danach **löschen** — sie sind die Falle;
solange sie existieren, greift sie jemand wieder auf.
- `10-pop3.go` mitziehen (POP3 kennt keine Envelope-Fetches → dort Header aus dem
`TOP`-Kommando bauen oder die Methode als „nicht unterstuetzt" zurueckgeben).
## Kleinigkeit aus dem letzten Review (gern mitnehmen)
In `fetchEach` ([04-imap-source.go](backend/04-imap-source.go)) wurde
`defer cmd.Close()` durch explizite Closes ersetzt. Alle Pfade sind abgedeckt,
aber bei einem Panic im Callback bleibt das FETCH-Kommando offen und die
Verbindung kaputt. `defer` wieder rein (Doppel-Close ist harmlos).
## Abnahme
1. `go test ./...` grün.
2. Funktional unverändert: Listen zeigen weiterhin Betreff/Absender/Datum,
Öffnen einer Mail zeigt weiterhin den vollen Inhalt inkl. Anhängen.
3. **Speicher (fahre ich):** Ordner mit ~40 Mails à 2 MB (80 MB), Web-Container
mit harter 64-MiB-Grenze, dann per HTTP die Ordner-Liste abrufen. Vorher:
OOM-Kill. Nachher: Liste kommt, Speicher bleibt flach. Ich setze das auf und
verifiziere es — du baust nur den Code.
## NICHT in diesem Schritt
- Der lokale **mbox-Viewer** (`ReadMboxList`) liest die ganze Datei, hält aber nur
Header — Speicher ist unkritisch. Pagination dort später, reine Kosmetik.
- POP3 vollständig verdrahten, DB-Namen-Altlast (`emailforwarder.db` vs.
`mail-graveyard.db` + zwei 0-Byte-Leichen), RFC-2047-Betreff im Forward.