diff --git a/backend/00-router.go b/backend/00-router.go index 14f7ab4..31f805f 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -14,8 +14,11 @@ import ( "path/filepath" "strconv" "strings" + "time" ) +const mailboxListLimit = 200 + func RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/", homeHandler) mux.HandleFunc("/login", loginHandler) @@ -394,13 +397,13 @@ func sourceMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) return } - messages, err := fetchSourceFolderMessages(account, folder) + headers, err := fetchSourceFolderHeaders(account, folder) if err != nil { fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) return } - renderSourceMailboxMessageList(w, account, folder, messages) - fmt.Fprintf(w, `
%s
`, renderSourceMailboxRead(account, folder, 0, messages)) + renderSourceMailboxMessageList(w, account, folder, headers) + fmt.Fprintf(w, `
%s
`, renderSourceMailboxRead(account, folder, firstHeaderUID(headers))) } 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")) folder := strings.TrimSpace(r.URL.Query().Get("folder")) - index, err := strconv.Atoi(r.URL.Query().Get("index")) - if err != nil { - http.Error(w, "bad index", http.StatusBadRequest) - return - } + uid := parseUIDParam(r) account, err := GetAccount(accountName) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - messages, err := fetchSourceFolderMessages(account, folder) + raw, err := fetchSourceMailboxMessage(account, folder, uid) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } 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) { @@ -444,13 +443,13 @@ func targetMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) return } - messages, err := fetchTargetFolderMessages(account, folder) + headers, err := fetchTargetFolderHeaders(account, folder) if err != nil { fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) return } - renderTargetMailboxMessageList(w, account, folder, messages) - fmt.Fprintf(w, `
%s
`, renderTargetMailboxRead(account, folder, 0, messages)) + renderTargetMailboxMessageList(w, account, folder, headers) + fmt.Fprintf(w, `
%s
`, renderTargetMailboxRead(account, folder, firstHeaderUID(headers))) } 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")) folder := strings.TrimSpace(r.URL.Query().Get("folder")) - index, err := strconv.Atoi(r.URL.Query().Get("index")) - if err != nil { - http.Error(w, "bad index", http.StatusBadRequest) - return - } + uid := parseUIDParam(r) account, err := GetAccount(accountName) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - messages, err := fetchTargetFolderMessages(account, folder) + raw, err := fetchTargetMailboxMessage(account, folder, uid) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } 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) { @@ -999,7 +994,7 @@ func transferMessagesHandler(w http.ResponseWriter, r *http.Request) { folder := strings.TrimSpace(r.URL.Query().Get("folder")) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, renderTransferMessageList(side, kind, value, folder)) - fmt.Fprintf(w, `
%s
`, renderTransferPreview(kind, value, folder, 0)) + fmt.Fprintf(w, `
%s
`, renderTransferPreview(kind, value, folder, 0, 0)) } 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")) value := strings.TrimSpace(r.URL.Query().Get("value")) folder := strings.TrimSpace(r.URL.Query().Get("folder")) - index, err := strconv.Atoi(r.URL.Query().Get("index")) - if err != nil { - http.Error(w, "bad index", http.StatusBadRequest) - return - } + index, _ := strconv.Atoi(r.URL.Query().Get("index")) + uid := parseUIDParam(r) 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 { @@ -1183,12 +1175,12 @@ func renderInitialTransferList(side, kind string, accounts []Account, archives [ func renderInitialTransferPreview(leftKind, leftValue, rightKind, rightValue string) string { if leftValue != "" { if folder, err := firstTransferFolder(leftKind, leftValue); err == nil { - return renderTransferPreview(leftKind, leftValue, folder, 0) + return renderTransferPreview(leftKind, leftValue, folder, 0, 0) } } if rightValue != "" { 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.") @@ -1271,56 +1263,45 @@ func renderTransferMessageList(side, kind, value, folder string) string { fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) return b.String() } - messages, err := fetchTargetFolderMessages(account, folder) + messages, err := fetchTargetFolderHeaders(account, folder) if err != nil { fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) return b.String() } - renderTransferRawMessages(&b, side, kind, value, folder, messages) + renderTransferHeaders(&b, side, kind, value, folder, messages) default: account, err := GetAccount(value) if err != nil { fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) return b.String() } - messages, err := fetchSourceFolderMessages(account, folder) + messages, err := fetchSourceFolderHeaders(account, folder) if err != nil { fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) return b.String() } - renderTransferRawMessages(&b, side, kind, value, folder, messages) + renderTransferHeaders(&b, side, kind, value, folder, messages) } return b.String() } -func renderTransferRawMessages(b *strings.Builder, side, kind, value, folder string, messages []RawMessage) { - for i, raw := range messages { - subject := "(ohne Betreff)" - 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")) - } +func renderTransferHeaders(b *strings.Builder, side, kind, value, folder string, messages []MessageHeader) { + for i, header := range messages { + subject, from, date := headerListFields(header) active := "" if i == 0 { active = " active" } - fmt.Fprintf(b, `%s%s%s`, - active, side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), i, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) + fmt.Fprintf(b, `%s%s%s`, + active, side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), header.UID, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) } if len(messages) == 0 { b.WriteString(`
Keine Nachrichten.
`) } } -func renderTransferPreview(kind, value, folder string, index int) string { - raw, label, err := transferRawMessage(kind, value, folder, index) +func renderTransferPreview(kind, value, folder string, index int, uid uint32) string { + raw, label, err := transferRawMessage(kind, value, folder, index, uid) if err != nil { return renderEmptyPreviewPane(err.Error()) } @@ -1339,7 +1320,7 @@ func renderTransferPreview(kind, value, folder string, index int) 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 { case "archive": path, err := mboxPath(value, folder) @@ -1353,27 +1334,15 @@ func transferRawMessage(kind, value, folder string, index int) ([]byte, string, if err != nil { return nil, value, err } - messages, err := fetchTargetFolderMessages(account, folder) - if err != nil { - 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 + msg, err := fetchTargetMailboxMessage(account, folder, uid) + return msg.Body, account.DstUser, err default: account, err := GetAccount(value) if err != nil { return nil, value, err } - messages, err := fetchSourceFolderMessages(account, folder) - if err != nil { - 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 + msg, err := fetchSourceMailboxMessage(account, folder, uid) + return msg.Body, account.SrcUser, err } } @@ -1545,33 +1514,35 @@ func sourceFolderRole(folder Folder) string { return "" } -func fetchSourceFolderMessages(account Account, folder string) ([]RawMessage, error) { +func fetchSourceFolderHeaders(account Account, folder string) ([]MessageHeader, error) { if accountProto(account) == "pop3" { - src, err := OpenPOP3Source(account) - if err != nil { - return nil, err - } - defer src.Close() - return collectSourceMessages(src, folder) + return nil, fmt.Errorf("POP3-Headerlisten sind noch nicht unterstuetzt.") } src, err := OpenIMAPSource(account) if err != nil { return nil, err } defer src.Close() - return collectSourceMessages(src, folder) + return src.Headers(folder, mailboxListLimit, 0) } -func collectSourceMessages(src SourceMailbox, folder string) ([]RawMessage, error) { - var messages []RawMessage - err := src.Fetch(folder, func(m RawMessage) error { - messages = append(messages, m) - return nil - }) - if err != nil { - return messages, err +func fetchSourceMailboxMessage(account Account, folder string, uid uint32) (RawMessage, error) { + if accountProto(account) == "pop3" { + return RawMessage{}, fmt.Errorf("POP3-Nachrichtenansicht ist noch nicht unterstuetzt.") } - 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 { @@ -1582,12 +1553,12 @@ func renderInitialSourceMailboxList(account Account) string { if err != nil { return fmt.Sprintf(`
Nachrichten
%s
`, html.EscapeString(err.Error())) } - messages, err := fetchSourceFolderMessages(account, folder) + headers, err := fetchSourceFolderHeaders(account, folder) if err != nil { return fmt.Sprintf(`
Nachrichten
%s
`, html.EscapeString(err.Error())) } var b strings.Builder - renderSourceMailboxMessageList(&b, account, folder, messages) + renderSourceMailboxMessageList(&b, account, folder, headers) return b.String() } @@ -1599,11 +1570,11 @@ func renderInitialSourceMailboxRead(account Account) string { if err != nil { return `
Nachricht waehlen.
` } - messages, err := fetchSourceFolderMessages(account, folder) + headers, err := fetchSourceFolderHeaders(account, folder) if err != nil { return `
Nachricht waehlen.
` } - return renderSourceMailboxRead(account, folder, 0, messages) + return renderSourceMailboxRead(account, folder, firstHeaderUID(headers)) } func firstSourceFolder(account Account) (string, error) { @@ -1630,29 +1601,18 @@ func firstSourceFolder(account Account) (string, error) { 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, `
Nachrichten
`) - for i, raw := range messages { - subject := "(ohne Betreff)" - 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")) - } + for i, header := range messages { + subject, from, date := headerListFields(header) active := "" if i == 0 { active = " active" } - fmt.Fprintf(w, `%s%s%s`, + fmt.Fprintf(w, `%s%s%s`, active, - html.EscapeString(account.Name), html.EscapeString(folder), i, - urlEsc(account.Name), urlEsc(folder), i, + html.EscapeString(account.Name), html.EscapeString(folder), header.UID, + urlEsc(account.Name), urlEsc(folder), header.UID, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) } 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 { - if index < 0 || index >= len(messages) { +func renderSourceMailboxRead(account Account, folder string, uid uint32) string { + if uid == 0 { return `
Nachricht waehlen.
` } - raw := messages[index].Body - msg, err := mail.ReadMessage(bytes.NewReader(raw)) + raw, err := fetchSourceMailboxMessage(account, folder, uid) + if err != nil { + return `
Nachricht waehlen.
` + } + 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 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() } - renderMailboxReadPane(&b, "Quelle", account.Name, folder, index, + renderMailboxReadPane(&b, "Quelle", account.Name, folder, 0, decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("Date")), - messageBody(raw), + messageBody(raw.Body), ) return b.String() } @@ -1755,13 +1722,29 @@ func targetFolderRole(folder TargetFolder) string { return "" } -func fetchTargetFolderMessages(account Account, folder string) ([]RawMessage, error) { +func fetchTargetFolderHeaders(account Account, folder string) ([]MessageHeader, error) { dst, err := OpenIMAPTarget(account) if err != nil { return nil, err } 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 { @@ -1772,12 +1755,12 @@ func renderInitialTargetMailboxList(account Account) string { if err != nil { return fmt.Sprintf(`
Nachrichten
%s
`, html.EscapeString(err.Error())) } - messages, err := fetchTargetFolderMessages(account, folder) + headers, err := fetchTargetFolderHeaders(account, folder) if err != nil { return fmt.Sprintf(`
Nachrichten
%s
`, html.EscapeString(err.Error())) } var b strings.Builder - renderTargetMailboxMessageList(&b, account, folder, messages) + renderTargetMailboxMessageList(&b, account, folder, headers) return b.String() } @@ -1789,11 +1772,11 @@ func renderInitialTargetMailboxRead(account Account) string { if err != nil { return `
Nachricht waehlen.
` } - messages, err := fetchTargetFolderMessages(account, folder) + headers, err := fetchTargetFolderHeaders(account, folder) if err != nil { return `
Nachricht waehlen.
` } - return renderTargetMailboxRead(account, folder, 0, messages) + return renderTargetMailboxRead(account, folder, firstHeaderUID(headers)) } func firstTargetFolder(account Account) (string, error) { @@ -1817,29 +1800,18 @@ func firstTargetFolder(account Account) (string, error) { 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, `
Nachrichten
`) - for i, raw := range messages { - subject := "(ohne Betreff)" - 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")) - } + for i, header := range messages { + subject, from, date := headerListFields(header) active := "" if i == 0 { active = " active" } - fmt.Fprintf(w, `%s%s%s`, + fmt.Fprintf(w, `%s%s%s`, active, - html.EscapeString(account.Name), html.EscapeString(folder), i, - urlEsc(account.Name), urlEsc(folder), i, + html.EscapeString(account.Name), html.EscapeString(folder), header.UID, + urlEsc(account.Name), urlEsc(folder), header.UID, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) } 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 { - if index < 0 || index >= len(messages) { +func renderTargetMailboxRead(account Account, folder string, uid uint32) string { + if uid == 0 { return `
Nachricht waehlen.
` } - raw := messages[index].Body - msg, err := mail.ReadMessage(bytes.NewReader(raw)) + raw, err := fetchTargetMailboxMessage(account, folder, uid) + if err != nil { + return `
Nachricht waehlen.
` + } + 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 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() } - renderMailboxReadPane(&b, "Ziel", account.Name, folder, index, + renderMailboxReadPane(&b, "Ziel", account.Name, folder, 0, decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("Date")), - messageBody(raw), + messageBody(raw.Body), ) 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) { if subject == "" { subject = "(ohne Betreff)" diff --git a/backend/04-imap-source.go b/backend/04-imap-source.go index 100a5d3..0a48b99 100644 --- a/backend/04-imap-source.go +++ b/backend/04-imap-source.go @@ -24,6 +24,17 @@ type RawMessage struct { 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 // Rollen-Zuordnung braucht: dekodierter Name, SPECIAL-USE-Attribute und der // Hierarchie-Trenner. @@ -36,7 +47,10 @@ type Folder struct { // SourceMailbox abstrahiert Quelle (IMAP primaer, POP3 Fallback), damit // 07-migrate.go gegen ein Interface arbeitet. 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 Close() error } @@ -79,6 +93,18 @@ func (s *imapSource) Fetch(folder string, fn func(RawMessage) error) error { 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 { return s.mailbox.Close() } @@ -154,6 +180,7 @@ func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error) InternalDate: true, BodySection: []*imap.FetchItemBodySection{section}, }) + defer cmd.Close() for { data := cmd.Next() @@ -162,38 +189,111 @@ func (m *imapClientMailbox) fetchEach(folder string, fn func(RawMessage) error) } buf, err := data.Collect() if err != nil { - _ = cmd.Close() return err } - body := buf.FindBodySection(section) - 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() + if err := fn(rawMessageFromFetch(buf, section)); err != nil { return err } } return cmd.Close() } -func (m *imapClientMailbox) fetchAll(folder string) ([]RawMessage, error) { - var out []RawMessage - err := m.fetchEach(folder, func(m RawMessage) error { - out = append(out, m) - return nil - }) +func (m *imapClientMailbox) count(folder string) (int, error) { + selected, err := m.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait() 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 } + reverseHeaders(out) 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 { if m == nil || m.c == 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 { 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) } @@ -273,12 +432,16 @@ func messageID(body []byte) string { msg, err := mail.ReadMessage(strings.NewReader(string(body))) if err == nil { if id := strings.TrimSpace(msg.Header.Get("Message-ID")); id != "" { - return id + return normalizeMessageID(id) } } return fmt.Sprintf("sha256:%x", sha256.Sum256(body)) } +func normalizeMessageID(id string) string { + return strings.Trim(strings.TrimSpace(id), "<>") +} + func isAlreadyExistsError(err error) bool { var imapErr *imap.Error if errors.As(err, &imapErr) { diff --git a/backend/05-imap-target.go b/backend/05-imap-target.go index 1c04173..f62aa95 100644 --- a/backend/05-imap-target.go +++ b/backend/05-imap-target.go @@ -12,11 +12,12 @@ import ( // Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum // zustellen und die Ordnerstruktur zerstoeren. type TargetMailbox interface { - Folders() ([]TargetFolder, error) // vorhandene Ziel-Ordner (fuer Rollen-Zuordnung) - Delim() string // Hierarchie-Trenner des Ziels - EnsureFolder(name string) error // anlegen + subscriben, falls fehlt - Append(folder string, m RawMessage) error // mit m.Flags und m.InternalDate - Fetch(folder string) ([]RawMessage, error) // Ziel-Audit: geschriebene Mail wiederfinden + Folders() ([]TargetFolder, error) // vorhandene Ziel-Ordner (fuer Rollen-Zuordnung) + Delim() string // Hierarchie-Trenner des Ziels + EnsureFolder(name string) error // anlegen + subscriben, falls fehlt + Append(folder string, m RawMessage) error // mit m.Flags und m.InternalDate + Headers(folder string, limit, offset int) ([]MessageHeader, error) + FetchOne(folder string, uid uint32) (RawMessage, error) Close() error } @@ -120,8 +121,12 @@ func (t *imapTarget) Append(folder string, m RawMessage) error { return err } -func (t *imapTarget) Fetch(folder string) ([]RawMessage, error) { - return t.mailbox.fetchAll(folder) +func (t *imapTarget) Headers(folder string, limit, offset int) ([]MessageHeader, error) { + 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 { diff --git a/backend/07-migrate.go b/backend/07-migrate.go index 8190ac2..4bc7e01 100644 --- a/backend/07-migrate.go +++ b/backend/07-migrate.go @@ -210,12 +210,9 @@ func CheckAccount(name string) error { } total := 0 for _, folder := range folders { - folderTotal := 0 - if err := src.Fetch(folder.Name, func(m RawMessage) error { - folderTotal++ - return nil - }); err != nil { - return fmt.Errorf("fetch source %s %s: %w", a.Name, folder.Name, err) + folderTotal, err := src.Count(folder.Name) + if err != nil { + return fmt.Errorf("count source %s %s: %w", a.Name, folder.Name, err) } log.Printf("account %s source folder %s messages=%d", a.Name, folder.Name, folderTotal) total += folderTotal diff --git a/backend/08-viewer.go b/backend/08-viewer.go index bbda7d1..aa3dfad 100644 --- a/backend/08-viewer.go +++ b/backend/08-viewer.go @@ -490,23 +490,33 @@ func findTargetIMAPCopy(account, targetAccount string, raw []byte) (forwardedMat } func scanTargetForMessage(dst TargetMailbox, targetUser, messageID string) (forwardedMatch, bool, error) { + messageID = normalizeMessageID(messageID) folders, err := dst.Folders() if err != nil { return forwardedMatch{}, false, err } for _, folder := range folders { - msgs, err := dst.Fetch(folder.Name) - if err != nil { - continue - } - for i, msg := range msgs { - if msg.MessageID == messageID || messageIDHeader(msg.Body) == messageID { - return forwardedMatch{ - Account: "Ziel: " + targetUser, - Folder: folder.Name, - Index: i, - Raw: msg.Body, - }, true, nil + for offset := 0; ; offset += mailboxListLimit { + headers, err := dst.Headers(folder.Name, mailboxListLimit, offset) + if err != nil { + break + } + if len(headers) == 0 { + break + } + for i, header := range headers { + if normalizeMessageID(header.MessageID) == messageID { + msg, err := dst.FetchOne(folder.Name, header.UID) + 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 { 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 { diff --git a/ui-fetch-brief.md b/ui-fetch-brief.md new file mode 100644 index 0000000..f1c8786 --- /dev/null +++ b/ui-fetch-brief.md @@ -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.