Fix mailbox UI sorting and target layout
This commit is contained in:
parent
8e8c651eaa
commit
e63a513cff
6 changed files with 196 additions and 46 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
88
ui-fixes-brief.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue