updated layout for webmail

This commit is contained in:
2026-08-15 16:31:27 +01:00
parent f283c90f11
commit 6f0c305367
35 changed files with 2660 additions and 219 deletions
+44 -10
View File
@@ -174,7 +174,7 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: normal\r\n\r\nhi"), "<a@example.com>", "a@example.com", "normal"); err != nil {
t.Fatal(err)
}
if _, err := store.StoreMessage(mailboxID, "Spam", []byte("Subject: junk\r\n\r\nspam"), "<b@example.com>", "b@example.com", "junk"); err != nil {
if _, err := store.StoreMessage(mailboxID, "Junk", []byte("Subject: junk\r\n\r\nspam"), "<b@example.com>", "b@example.com", "junk"); err != nil {
t.Fatal(err)
}
@@ -201,12 +201,46 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
if err != nil {
t.Fatalf("list: %v", err)
}
var names []string
// LIST must report every standard folder (INBOX, Junk, Sent, Drafts, Trash) even
// though only INBOX and Junk actually hold a message here — a desktop client that
// only sees folders with existing mail never learns Trash/Drafts/Sent exist. See
// db.AllFoldersForMailbox / imapserver.Session.List.
byName := map[string]*imap.ListData{}
for _, m := range mailboxes {
names = append(names, m.Mailbox)
byName[m.Mailbox] = m
}
if len(names) != 2 {
t.Fatalf("expected 2 folders (INBOX, Spam), got %v", names)
wantFolders := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"}
if len(mailboxes) != len(wantFolders) {
names := make([]string, 0, len(mailboxes))
for _, m := range mailboxes {
names = append(names, m.Mailbox)
}
t.Fatalf("expected folders %v, got %v", wantFolders, names)
}
for _, name := range wantFolders {
if _, ok := byName[name]; !ok {
t.Errorf("LIST is missing folder %q", name)
}
}
// Trash/Junk/Sent/Drafts should each carry their RFC 6154 SPECIAL-USE attribute
// so a desktop client (Thunderbird, Apple Mail, etc.) recognizes them regardless
// of the exact folder name.
wantAttrs := map[string]imap.MailboxAttr{
"Trash": imap.MailboxAttrTrash,
"Junk": imap.MailboxAttrJunk,
"Sent": imap.MailboxAttrSent,
"Drafts": imap.MailboxAttrDrafts,
}
for name, attr := range wantAttrs {
found := false
for _, a := range byName[name].Attrs {
if a == attr {
found = true
}
}
if !found {
t.Errorf("folder %q missing SPECIAL-USE attr %q, got %v", name, attr, byName[name].Attrs)
}
}
inboxData, err := client.Select("INBOX", nil).Wait()
@@ -217,20 +251,20 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
t.Fatalf("INBOX NumMessages = %d, want 1", inboxData.NumMessages)
}
spamData, err := client.Select("Spam", nil).Wait()
spamData, err := client.Select("Junk", nil).Wait()
if err != nil {
t.Fatalf("select Spam: %v", err)
t.Fatalf("select Junk: %v", err)
}
if spamData.NumMessages != 1 {
t.Fatalf("Spam NumMessages = %d, want 1", spamData.NumMessages)
t.Fatalf("Junk NumMessages = %d, want 1", spamData.NumMessages)
}
msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{Envelope: true}).Collect()
if err != nil {
t.Fatalf("fetch in Spam: %v", err)
t.Fatalf("fetch in Junk: %v", err)
}
if len(msgs) != 1 || msgs[0].Envelope.Subject != "junk" {
t.Fatalf("expected the Spam-folder message (subject %q), got %+v", "junk", msgs)
t.Fatalf("expected the Junk-folder message (subject %q), got %+v", "junk", msgs)
}
}
+28 -5
View File
@@ -177,14 +177,37 @@ func (s *Session) Unsubscribe(mailbox string) error {
return nil
}
// List reports every folder that actually has mail (plus INBOX, always) — a filter
// rule's move_to_folder action is what creates a second folder; there's no IMAP
// CREATE/manual folder management.
// specialUseAttrs tags a folder with its RFC 6154 SPECIAL-USE attribute, if any —
// desktop IMAP clients (Thunderbird, Apple Mail, K-9, etc.) use this to recognize
// Trash/Junk/Sent/Drafts regardless of the exact folder name, though in practice not
// every client honors SPECIAL-USE reliably, which is why the folder itself is named
// "Junk" (see db.StandardMailboxFolders) rather than relying on this attribute alone.
func specialUseAttrs(folder string) []imap.MailboxAttr {
switch folder {
case "Trash":
return []imap.MailboxAttr{imap.MailboxAttrTrash}
case "Junk":
return []imap.MailboxAttr{imap.MailboxAttrJunk}
case "Sent":
return []imap.MailboxAttr{imap.MailboxAttrSent}
case "Drafts":
return []imap.MailboxAttr{imap.MailboxAttrDrafts}
default:
return nil
}
}
// List reports every folder the mailbox has: the standard folders (INBOX, Junk, Sent,
// Drafts, Trash — always, even empty) plus any custom folder a filter rule's
// move_to_folder action or explicit webmail folder creation has produced — the same
// full list db.AllFoldersForMailbox gives the webmail UI, so a desktop IMAP client
// sees exactly the same folders webmail does instead of only ones that happen to
// already hold a message (DistinctFoldersForMailbox alone).
func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
if err := s.requireAuth(); err != nil {
return err
}
folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
folders, err := s.backend.DB.AllFoldersForMailbox(s.mailbox.ID)
if err != nil {
return err
}
@@ -199,7 +222,7 @@ func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string
if !goimapserver.MatchList(folder, '/', ref, pattern) {
continue
}
if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/'}); err != nil {
if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/', Attrs: specialUseAttrs(folder)}); err != nil {
return err
}
}