groupware: add Mailbox sorting

This commit is contained in:
Pascal Bleser
2025-10-24 19:22:30 +02:00
parent b065725310
commit 1d998ec58d
3 changed files with 102 additions and 21 deletions

View File

@@ -40,18 +40,13 @@ func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Cont
})
}
type AllMailboxesResponse struct {
Mailboxes []Mailbox `json:"mailboxes"`
State State `json:"state"`
}
func (j *Client) GetAllMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]AllMailboxesResponse, SessionState, Language, Error) {
func (j *Client) GetAllMailboxes(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]Mailboxes, SessionState, Language, Error) {
logger = j.logger("GetAllMailboxes", session, logger)
uniqueAccountIds := structs.Uniq(accountIds)
n := len(uniqueAccountIds)
if n < 1 {
return map[string]AllMailboxesResponse{}, "", "", nil
return nil, "", "", nil
}
invocations := make([]Invocation, n)
@@ -61,19 +56,19 @@ func (j *Client) GetAllMailboxes(accountIds []string, session *Session, ctx cont
cmd, err := j.request(session, logger, invocations...)
if err != nil {
return map[string]AllMailboxesResponse{}, "", "", err
return nil, "", "", err
}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]AllMailboxesResponse, Error) {
resp := map[string]AllMailboxesResponse{}
return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]Mailboxes, Error) {
resp := map[string]Mailboxes{}
for _, accountId := range uniqueAccountIds {
var response MailboxGetResponse
err = retrieveResponseMatchParameters(logger, body, CommandMailboxGet, mcid(accountId, "0"), &response)
if err != nil {
return map[string]AllMailboxesResponse{}, err
return nil, err
}
resp[accountId] = AllMailboxesResponse{
resp[accountId] = Mailboxes{
Mailboxes: response.List,
State: response.State,
}

View File

@@ -2,6 +2,8 @@ package groupware
import (
"net/http"
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
@@ -128,9 +130,8 @@ func (g *Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
return req.errorResponseFromJmap(err)
}
mailboxes, ok := mailboxesByAccountId[accountId]
if ok {
return etagResponse(mailboxes.Mailboxes, sessionState, mailboxes.State, lang)
if mailboxes, ok := mailboxesByAccountId[accountId]; ok {
return etagResponse(sortMailboxSlice(mailboxes.Mailboxes), sessionState, mailboxes.State, lang)
} else {
return notFoundResponse(sessionState)
}
@@ -139,9 +140,8 @@ func (g *Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
if err != nil {
return req.errorResponseFromJmap(err)
}
mailboxes, ok := mailboxesByAccountId[accountId]
if ok {
return etagResponse(mailboxes.Mailboxes, sessionState, mailboxes.State, lang)
if mailboxes, ok := mailboxesByAccountId[accountId]; ok {
return etagResponse(sortMailboxSlice(mailboxes.Mailboxes), sessionState, mailboxes.State, lang)
} else {
return notFoundResponse(sessionState)
}
@@ -197,13 +197,13 @@ func (g *Groupware) GetMailboxesForAllAccounts(w http.ResponseWriter, r *http.Re
if err != nil {
return req.errorResponseFromJmap(err)
}
return response(mailboxesByAccountId, sessionState, lang)
return response(sortMailboxesMap(mailboxesByAccountId), sessionState, lang)
} else {
mailboxesByAccountId, sessionState, lang, err := g.jmap.GetAllMailboxes(accountIds, req.session, req.ctx, logger, req.language())
if err != nil {
return req.errorResponseFromJmap(err)
}
return response(mailboxesByAccountId, sessionState, lang)
return response(sortMailboxesMap(mailboxesByAccountId), sessionState, lang)
}
})
}
@@ -225,7 +225,7 @@ func (g *Groupware) GetMailboxByRoleForAllAccounts(w http.ResponseWriter, r *htt
if err != nil {
return req.errorResponseFromJmap(err)
}
return response(mailboxesByAccountId, sessionState, lang)
return response(sortMailboxesMap(mailboxesByAccountId), sessionState, lang)
})
}
@@ -344,3 +344,72 @@ func (g *Groupware) GetMailboxRoles(w http.ResponseWriter, r *http.Request) {
return response(rolesByAccountId, sessionState, lang)
})
}
var mailboxRoleSortOrderScore = map[string]int{
jmap.JmapMailboxRoleInbox: 100,
jmap.JmapMailboxRoleDrafts: 200,
jmap.JmapMailboxRoleSent: 300,
jmap.JmapMailboxRoleJunk: 400,
jmap.JmapMailboxRoleTrash: 500,
}
func scoreMailbox(m jmap.Mailbox) int {
if score, ok := mailboxRoleSortOrderScore[m.Role]; ok {
return score
}
return 1000
}
func sortMailboxesMap[K comparable](mailboxesByAccountId map[K]jmap.Mailboxes) map[K]jmap.Mailboxes {
sortedByAccountId := make(map[K]jmap.Mailboxes, len(mailboxesByAccountId))
for accountId, unsorted := range mailboxesByAccountId {
mailboxes := make([]jmap.Mailbox, len(unsorted.Mailboxes))
copy(mailboxes, unsorted.Mailboxes)
slices.SortFunc(mailboxes, compareMailboxes)
sortedByAccountId[accountId] = jmap.Mailboxes{Mailboxes: mailboxes, State: unsorted.State}
}
return sortedByAccountId
}
func sortMailboxSlice(s []jmap.Mailbox) []jmap.Mailbox {
r := make([]jmap.Mailbox, len(s))
copy(r, s)
slices.SortFunc(r, compareMailboxes)
return r
}
func compareMailboxes(a, b jmap.Mailbox) int {
// first, use the defined order:
// Defines the sort order of Mailboxes when presented in the clients UI, so it is consistent between devices.
// Default value: 0
// The number MUST be an integer in the range 0 <= sortOrder < 2^31.
// A Mailbox with a lower order should be displayed before a Mailbox with a higher order
// (that has the same parent) in any Mailbox listing in the clients UI.
sa := a.SortOrder
sb := b.SortOrder
r := sa - sb
if r != 0 {
return r
}
// the JMAP specification says this:
// > Mailboxes with equal order SHOULD be sorted in alphabetical order by name.
// > The sorting should take into account locale-specific character order convention.
// but we feel like users would rather expect standard folders to come first,
// in an order that is common across MUAs:
// - inbox
// - drafts
// - sent
// - junk
// - trash
// - *everything else*
sa = scoreMailbox(a)
sb = scoreMailbox(b)
r = sa - sb
if r != 0 {
return r
}
// now we have "everything else", let's use alphabetical order here:
return strings.Compare(a.Name, b.Name)
}

View File

@@ -1,9 +1,11 @@
package groupware
import (
"slices"
"testing"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/stretchr/testify/require"
)
@@ -44,3 +46,18 @@ func TestSanitizeEmail(t *testing.T) {
require.Equal(`Hello. Click here for AI slop.`, safe.BodyValues["zee7urae"].Value)
require.Equal(30, safe.HtmlBody[1].Size)
}
func TestSortMailboxes(t *testing.T) {
mailboxes := []jmap.Mailbox{
{Id: "a", Name: "Other"},
{Id: "b", Role: jmap.JmapMailboxRoleSent, Name: "Sent"},
{Id: "c", Name: "Zebras"},
{Id: "d", Role: jmap.JmapMailboxRoleInbox, Name: "Inbox"},
{Id: "e", Name: "Appraisal"},
{Id: "f", Name: "Zealots", SortOrder: -10},
}
slices.SortFunc(mailboxes, compareMailboxes)
names := structs.Map(mailboxes, func(m jmap.Mailbox) string { return m.Name })
require := require.New(t)
require.Equal([]string{"Zealots", "Inbox", "Sent", "Appraisal", "Other", "Zebras"}, names)
}