From e63a513cffec14d0aa949da25f882173d0b1c5c3 Mon Sep 17 00:00:00 2001 From: DonVoo Date: Wed, 15 Jul 2026 00:14:54 +0200 Subject: [PATCH] Fix mailbox UI sorting and target layout --- backend/00-router.go | 6 +-- backend/04-imap-source.go | 74 +++++++++++++++----------------- backend/06-mbox.go | 29 +++++++++++++ backend/06-mbox_test.go | 2 +- backend/08-viewer.go | 43 ++++++++++++++++++- ui-fixes-brief.md | 88 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 46 deletions(-) create mode 100644 ui-fixes-brief.md diff --git a/backend/00-router.go b/backend/00-router.go index 07f006e..2ab5beb 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -1414,14 +1414,14 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
-
%s
-
%s
+
+
%s
`, - renderRibbonEmailBoxMenu("target"), renderRibbonTransferMenu(""), renderRibbonActions(false, user), renderInitialTargetMailboxList(selectedAccount), renderInitialTargetMailboxRead(selectedAccount), renderEmailBoxTargetPane(accounts, selectedAccount.Name)) + renderRibbonEmailBoxMenu("target"), renderRibbonTransferMenu(""), renderRibbonActions(false, user), renderInitialTargetMailboxRead(selectedAccount), renderInitialTargetMailboxList(selectedAccount), renderEmailBoxTargetPane(accounts, selectedAccount.Name)) } func renderEmailBoxSourcePane(accounts []Account, selected string) string { diff --git a/backend/04-imap-source.go b/backend/04-imap-source.go index 88184fd..5b710a4 100644 --- a/backend/04-imap-source.go +++ b/backend/04-imap-source.go @@ -51,6 +51,7 @@ type SourceMailbox interface { Folders() ([]Folder, error) // rekursiver Ordnerbaum Count(folder string) (int, error) Headers(folder string, limit, offset int) ([]MessageHeader, error) + AllHeaders(folder string) ([]MessageHeader, error) FetchOne(folder string, uid uint32) (RawMessage, error) Fetch(folder string, fn func(RawMessage) error) error // BODY[] FLAGS INTERNALDATE Close() error @@ -102,6 +103,10 @@ func (s *imapSource) Headers(folder string, limit, offset int) ([]MessageHeader, return s.mailbox.fetchHeaders(folder, limit, offset) } +func (s *imapSource) AllHeaders(folder string) ([]MessageHeader, error) { + return s.mailbox.fetchAllHeadersByUID(folder) +} + func (s *imapSource) FetchOne(folder string, uid uint32) (RawMessage, error) { return s.mailbox.fetchOne(folder, uid) } @@ -208,56 +213,25 @@ func (m *imapClientMailbox) count(folder string) (int, error) { } 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 { + headers, err := m.fetchAllHeadersByUID(folder) + if err != nil { + return nil, err + } + sortMessageHeadersByDateDesc(headers) + if offset >= len(headers) { return nil, nil } - start := stop - limit + 1 - if start < 1 { - start = 1 + end := offset + limit + if end > len(headers) { + end = len(headers) } - - 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 + return headers[offset:end], nil } func (m *imapClientMailbox) fetchAllHeadersByUID(folder string) ([]MessageHeader, error) { @@ -463,6 +437,24 @@ func sortMessageHeadersByUID(headers []MessageHeader) { }) } +func sortMessageHeadersByDateDesc(headers []MessageHeader) { + sort.SliceStable(headers, func(i, j int) bool { + di := headerSortDate(headers[i]) + dj := headerSortDate(headers[j]) + if !di.Equal(dj) { + return di.After(dj) + } + return headers[i].UID > headers[j].UID + }) +} + +func headerSortDate(h MessageHeader) time.Time { + if !h.Date.IsZero() { + return h.Date + } + return h.InternalDate +} + func messageIDFromFetch(buf *imapclient.FetchMessageBuffer, body []byte) string { if buf != nil && buf.Envelope != nil && strings.TrimSpace(buf.Envelope.MessageID) != "" { return normalizeMessageID(buf.Envelope.MessageID) diff --git a/backend/06-mbox.go b/backend/06-mbox.go index 863bf74..73558bb 100644 --- a/backend/06-mbox.go +++ b/backend/06-mbox.go @@ -12,6 +12,7 @@ import ( "net/mail" "os" "path/filepath" + "sort" "strings" "time" @@ -166,6 +167,7 @@ func ReadMboxList(path string) ([]MboxEntry, error) { Date: decodeHeader(msg.Header.Get("Date")), }) } + sortMboxEntriesByDateDesc(out) return out, nil } @@ -467,6 +469,14 @@ func readMboxListFromIndex(path string) ([]MboxEntry, bool) { if err != nil || len(index) == 0 { return nil, false } + sort.SliceStable(index, func(i, j int) bool { + di := parseMboxIndexDate(index[i].Date) + dj := parseMboxIndexDate(index[j].Date) + if !di.Equal(dj) { + return di.After(dj) + } + return index[i].Seq > index[j].Seq + }) out := make([]MboxEntry, 0, len(index)) for _, entry := range index { out = append(out, MboxEntry{ @@ -479,6 +489,25 @@ func readMboxListFromIndex(path string) ([]MboxEntry, bool) { return out, true } +func sortMboxEntriesByDateDesc(entries []MboxEntry) { + sort.SliceStable(entries, func(i, j int) bool { + di := parseMboxIndexDate(entries[i].Date) + dj := parseMboxIndexDate(entries[j].Date) + if !di.Equal(dj) { + return di.After(dj) + } + return entries[i].Index > entries[j].Index + }) +} + +func parseMboxIndexDate(value string) time.Time { + t, err := mail.ParseDate(strings.TrimSpace(value)) + if err == nil { + return t + } + return time.Time{} +} + func readMboxMessageFromIndex(path string, index int) ([]byte, bool, error) { accountID, folder, ok := mboxIndexContext(path) if !ok { diff --git a/backend/06-mbox_test.go b/backend/06-mbox_test.go index b7d0d23..5bf7884 100644 --- a/backend/06-mbox_test.go +++ b/backend/06-mbox_test.go @@ -198,7 +198,7 @@ func TestPlainMboxPartialIndexIsRebuiltBeforeList(t *testing.T) { if len(entries) != 2 { t.Fatalf("expected rebuilt full list with 2 entries, got %#v", entries) } - if entries[0].Subject != "First" || entries[1].Subject != "Second" { + if entries[0].Subject != "Second" || entries[1].Subject != "First" { t.Fatalf("unexpected entries after reindex: %#v", entries) } } diff --git a/backend/08-viewer.go b/backend/08-viewer.go index 03ad495..e3fd2b4 100644 --- a/backend/08-viewer.go +++ b/backend/08-viewer.go @@ -223,6 +223,12 @@ func archiveFolderInfos(paths []string) []archiveFolderInfo { func archiveFolderLabel(name string) string { name = strings.TrimSpace(name) + if label := folderRoleLabel(RoleFromName(name, ".")); label != "" { + return label + } + if label := folderRoleLabel(RoleFromName(name, "/")); label != "" { + return label + } switch strings.ToLower(name) { case "", "inbox": return "Posteingang" @@ -238,7 +244,7 @@ func archiveFolderLabel(name string) string { return "Spam" } parts := strings.FieldsFunc(name, func(r rune) bool { - return r == '/' || r == '\\' + return r == '/' || r == '\\' || r == '.' }) if len(parts) == 0 { parts = strings.Split(name, "_") @@ -250,8 +256,43 @@ func archiveFolderLabel(name string) string { return label } +func folderRoleLabel(role FolderRole) string { + switch role { + case RoleInbox: + return "Posteingang" + case RoleSent: + return "Gesendet" + case RoleDrafts: + return "Entwürfe" + case RoleTrash: + return "Papierkorb" + case RoleJunk: + return "Spam" + case RoleArchive: + return "Archiv" + case RoleOutbox: + return "Postausgang" + default: + return "" + } +} + func archiveFolderSortKey(name string) string { lc := strings.ToLower(strings.TrimSpace(name)) + switch RoleFromName(name, ".") { + case RoleInbox: + return "00:" + lc + case RoleDrafts: + return "01:" + lc + case RoleSent: + return "02:" + lc + case RoleJunk: + return "03:" + lc + case RoleTrash: + return "04:" + lc + case RoleArchive: + return "05:" + lc + } switch lc { case "inbox": return "00:" + lc diff --git a/ui-fixes-brief.md b/ui-fixes-brief.md new file mode 100644 index 0000000..de48ee5 --- /dev/null +++ b/ui-fixes-brief.md @@ -0,0 +1,88 @@ +# Codex-Brief — UI-Fixes (aus der Sichtprüfung nach dem Rettungslauf) + +Vier Befunde aus dem echten Betrieb mit den migrierten dr-gold-Postfaechern. + +--- + +## 1. 🔴 Nachrichtenliste nach DATUM sortieren (neueste zuerst) + +**Befund:** Im Ziel-Postfach purzeln die Daten durcheinander: +`13 Jul 2026`, `14 Jul 2026`, `13 Jul 2026`, `29 Jan 2020`, `24 Jan 2020`, … + +**Ursache — und die ist wichtig zu verstehen:** Die Liste sortiert nach +**UID / Sequenznummer** (= Ankunftsreihenfolge). `fetchHeaders` holt das Fenster +`start..stop` aus `NumMessages` und dreht es mit `reverseHeaders` um. + +Bei einem normal gewachsenen Postfach ist Ankunftsreihenfolge ≈ Datum. Bei einem +**migrierten** Postfach **nicht**: Wir haben per `APPEND` eingespielt und dabei +das **Original-`INTERNALDATE` erhalten** — die Reihenfolge des Einspielens hat +also nichts mehr mit dem Datum der Mail zu tun. Genau das sieht der Nutzer. + +**Fix:** +- **Nach Datum sortieren, neueste zuerst.** Schluessel: `Envelope.Date`, und wenn + leer, `InternalDate` (die Logik in `messageHeaderFromFetch` gibt es schon). +- Das Fenster (200) muss die **neuesten 200 nach Datum** sein, nicht die + hoechsten 200 UIDs. Zwei Wege: + 1. **IMAP `SORT` (RFC 5256)**, wenn der Server es kann: + `UID SORT (REVERSE DATE) UTF-8 ALL` → sortierte UID-Liste, davon die ersten + 200 holen. Sauber und serverseitig. + 2. **Fallback:** `AllHeaders(folder)` (existiert schon fuers Dedup, holt nur + Envelopes, keine Bodies), clientseitig nach Datum sortieren, dann Fenster + schneiden. +- **Archiv-Listen (mbox) genauso:** aus `mbox_index` nach Datum sortiert lesen + (`ORDER BY date DESC`), nicht in Dateireihenfolge. +- Schoen waere: Klick auf die Spalte dreht die Sortierung um (Datum auf/ab). + +--- + +## 2. 🟠 Ziel-Postfach: Liste und Vorschau sind vertauscht + +**Ist:** `[ Nachrichten-Liste | Vorschau | Ordnerbaum ]` +**Soll:** `[ Vorschau | Nachrichten-Liste | Ordnerbaum ]` + +Der Ordnerbaum steht bereits richtig rechts (spiegelbildlich zur Quell-Ansicht, +wo er links steht). Nur **Liste und Vorschau muessen die Plaetze tauschen** — +dann stehen sich Quelle und Ziel sauber gegenueber: + +``` +QUELLE: [ Ordnerbaum | Liste | Vorschau ] +ZIEL: [ Vorschau | Liste | Ordnerbaum ] <- gespiegelt +``` + +--- + +## 3. 🟡 Falscher Platzhaltertext in der Vorschau + +Die Vorschau zeigt **„Backup-Postfach links waehlen."**, obwohl links laengst +eines gewaehlt ist und 678 Mails gelistet werden. Der Text gehoert nur in den +Zustand „noch keine Mailbox gewaehlt". + +**Fix:** Ist eine Mailbox + Ordner gewaehlt, aber keine Nachricht → +**„Nachricht waehlen."** + +--- + +## 4. 🟡 Ordnernamen im Archiv-Baum sind inkonsistent + +Im Baum steht **„Posteingang" (678)** direkt neben **„INBOX.Gesendet" (2)** — +einmal uebersetzt, einmal der rohe Quell-Ordnername. + +Die mbox-Dateien heissen nach dem **Quell**-Ordner (`INBOX.mbox`, +`INBOX.Gesendet.mbox`). Die Anzeige uebersetzt offenbar nur `INBOX` → +„Posteingang" und laesst den Rest roh. + +**Fix:** Einheitlich. Am besten die **Rollen-Erkennung aus `11-folders.go`** +wiederverwenden: `INBOX`→Posteingang, `*.Gesendet`/`*.Sent`→Gesendet, +`*.Drafts`/`*.Entwuerfe`→Entwuerfe, `*.Trash`→Papierkorb usw., und den +Quell-Praefix (`INBOX.`) abschneiden. Sonst konsequent **gar nicht** uebersetzen +und ueberall den Quellnamen zeigen — Hauptsache, es ist nicht gemischt. + +--- + +## Abnahme +1. Ziel-Postfach `admin@archiv.dr-gold.com` oeffnen → Liste steht **nach Datum, + neueste oben** (nicht mehr 2026/2020/2019 durcheinander). +2. Archiv `colak@dr-gold.de` → ebenfalls nach Datum, neueste oben. +3. Ziel-Ansicht ist gespiegelt: Vorschau links, Liste mittig, Baum rechts. +4. Vorschau-Platzhalter sagt „Nachricht waehlen.", sobald ein Ordner gewaehlt ist. +5. Baum zeigt einheitliche Ordnernamen.