Fix mailbox UI sorting and target layout

This commit is contained in:
DonVoo 2026-07-15 00:14:54 +02:00
parent 8e8c651eaa
commit e63a513cff
6 changed files with 196 additions and 46 deletions

View file

@ -1414,14 +1414,14 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane email-box-view email-box-target-view"> <div class="three-pane email-box-view email-box-target-view">
<section class="pane pane-list" id="list">%s</section>
<div class="pane-resizer" data-resize-index="0"></div>
<section class="pane pane-read" id="read">%s</section> <section class="pane pane-read" id="read">%s</section>
<div class="pane-resizer" data-resize-index="0"></div>
<section class="pane pane-list" id="list">%s</section>
<div class="pane-resizer" data-resize-index="1"></div> <div class="pane-resizer" data-resize-index="1"></div>
<aside class="pane pane-tree" id="target-tree">%s</aside> <aside class="pane pane-tree" id="target-tree">%s</aside>
</div> </div>
<footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`, <footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`,
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 { func renderEmailBoxSourcePane(accounts []Account, selected string) string {

View file

@ -51,6 +51,7 @@ type SourceMailbox interface {
Folders() ([]Folder, error) // rekursiver Ordnerbaum Folders() ([]Folder, error) // rekursiver Ordnerbaum
Count(folder string) (int, error) Count(folder string) (int, error)
Headers(folder string, limit, offset int) ([]MessageHeader, error) Headers(folder string, limit, offset int) ([]MessageHeader, error)
AllHeaders(folder string) ([]MessageHeader, error)
FetchOne(folder string, uid uint32) (RawMessage, 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
@ -102,6 +103,10 @@ func (s *imapSource) Headers(folder string, limit, offset int) ([]MessageHeader,
return s.mailbox.fetchHeaders(folder, limit, offset) 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) { func (s *imapSource) FetchOne(folder string, uid uint32) (RawMessage, error) {
return s.mailbox.fetchOne(folder, uid) 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) { 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 { if limit <= 0 || limit > 200 {
limit = 200 limit = 200
} }
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
stop := int(selected.NumMessages) - offset headers, err := m.fetchAllHeadersByUID(folder)
if stop <= 0 { if err != nil {
return nil, err
}
sortMessageHeadersByDateDesc(headers)
if offset >= len(headers) {
return nil, nil return nil, nil
} }
start := stop - limit + 1 end := offset + limit
if start < 1 { if end > len(headers) {
start = 1 end = len(headers)
} }
return headers[offset:end], nil
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) fetchAllHeadersByUID(folder string) ([]MessageHeader, error) { 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 { 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 normalizeMessageID(buf.Envelope.MessageID) return normalizeMessageID(buf.Envelope.MessageID)

View file

@ -12,6 +12,7 @@ import (
"net/mail" "net/mail"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@ -166,6 +167,7 @@ func ReadMboxList(path string) ([]MboxEntry, error) {
Date: decodeHeader(msg.Header.Get("Date")), Date: decodeHeader(msg.Header.Get("Date")),
}) })
} }
sortMboxEntriesByDateDesc(out)
return out, nil return out, nil
} }
@ -467,6 +469,14 @@ func readMboxListFromIndex(path string) ([]MboxEntry, bool) {
if err != nil || len(index) == 0 { if err != nil || len(index) == 0 {
return nil, false 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)) out := make([]MboxEntry, 0, len(index))
for _, entry := range index { for _, entry := range index {
out = append(out, MboxEntry{ out = append(out, MboxEntry{
@ -479,6 +489,25 @@ func readMboxListFromIndex(path string) ([]MboxEntry, bool) {
return out, true 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) { func readMboxMessageFromIndex(path string, index int) ([]byte, bool, error) {
accountID, folder, ok := mboxIndexContext(path) accountID, folder, ok := mboxIndexContext(path)
if !ok { if !ok {

View file

@ -198,7 +198,7 @@ func TestPlainMboxPartialIndexIsRebuiltBeforeList(t *testing.T) {
if len(entries) != 2 { if len(entries) != 2 {
t.Fatalf("expected rebuilt full list with 2 entries, got %#v", entries) 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) t.Fatalf("unexpected entries after reindex: %#v", entries)
} }
} }

View file

@ -223,6 +223,12 @@ func archiveFolderInfos(paths []string) []archiveFolderInfo {
func archiveFolderLabel(name string) string { func archiveFolderLabel(name string) string {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if label := folderRoleLabel(RoleFromName(name, ".")); label != "" {
return label
}
if label := folderRoleLabel(RoleFromName(name, "/")); label != "" {
return label
}
switch strings.ToLower(name) { switch strings.ToLower(name) {
case "", "inbox": case "", "inbox":
return "Posteingang" return "Posteingang"
@ -238,7 +244,7 @@ func archiveFolderLabel(name string) string {
return "Spam" return "Spam"
} }
parts := strings.FieldsFunc(name, func(r rune) bool { parts := strings.FieldsFunc(name, func(r rune) bool {
return r == '/' || r == '\\' return r == '/' || r == '\\' || r == '.'
}) })
if len(parts) == 0 { if len(parts) == 0 {
parts = strings.Split(name, "_") parts = strings.Split(name, "_")
@ -250,8 +256,43 @@ func archiveFolderLabel(name string) string {
return label 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 { func archiveFolderSortKey(name string) string {
lc := strings.ToLower(strings.TrimSpace(name)) 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 { switch lc {
case "inbox": case "inbox":
return "00:" + lc return "00:" + lc

88
ui-fixes-brief.md Normal file
View file

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