Files
mailgoserver/internal/jmap/mailbox.go
T
2026-08-22 06:45:05 +01:00

527 lines
18 KiB
Go

package jmap
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"mailgoserver/internal/db"
)
// jmapMailbox is RFC 8621 §2's Mailbox object.
type jmapMailbox struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID *string `json:"parentId"`
Role *string `json:"role"`
SortOrder int `json:"sortOrder"`
TotalEmails int `json:"totalEmails"`
UnreadEmails int `json:"unreadEmails"`
TotalThreads int `json:"totalThreads"`
UnreadThreads int `json:"unreadThreads"`
IsSubscribed bool `json:"isSubscribed"`
MyRights struct {
MayReadItems bool `json:"mayReadItems"`
MayAddItems bool `json:"mayAddItems"`
MayRemoveItems bool `json:"mayRemoveItems"`
MaySetSeen bool `json:"maySetSeen"`
MaySetKeywords bool `json:"maySetKeywords"`
MayCreateChild bool `json:"mayCreateChild"`
MayRename bool `json:"mayRename"`
MayDelete bool `json:"mayDelete"`
MaySubmit bool `json:"maySubmit"`
} `json:"myRights"`
}
// standardRoles maps this app's fixed standard-folder names to JMAP's role vocabulary
// (RFC 8621 §2, the subset that applies here).
var standardRoles = map[string]string{
"INBOX": "inbox",
"Sent": "sent",
"Drafts": "drafts",
"Trash": "trash",
"Junk": "junk",
}
func strPtr(s string) *string { return &s }
// resolvedMailbox pairs a folder name with everything needed to build a jmapMailbox
// or resolve a mailboxId back to a folder name.
type resolvedMailbox struct {
id, name, parentName string
}
// resolveMailboxes materializes a stable esrv_mailbox_folders row for every folder
// mbox has (standard or custom — see db.EnsureFolderRow's doc comment on why the 5
// standard folders need this) and returns them alongside id<->name lookup maps.
func resolveMailboxes(b *Backend, mbox *db.Mailbox) ([]resolvedMailbox, map[string]string, map[string]int64, error) {
names, err := b.DB.AllFoldersForMailbox(mbox.ID)
if err != nil {
return nil, nil, nil, err
}
parents, err := b.DB.FolderParentMap(mbox.ID)
if err != nil {
return nil, nil, nil, err
}
idByName := make(map[string]int64, len(names))
for _, name := range names {
id, err := b.DB.EnsureFolderRow(mbox.ID, name)
if err != nil {
return nil, nil, nil, err
}
idByName[name] = id
}
idStrByName := make(map[string]string, len(idByName))
nameByIDStr := make(map[string]string, len(idByName))
for name, id := range idByName {
s := strconv.FormatInt(id, 10)
idStrByName[name] = s
nameByIDStr[s] = name
}
out := make([]resolvedMailbox, 0, len(names))
for _, name := range names {
parentName := ""
if !isStandardFolder(name) {
parentName = parents[name]
}
out = append(out, resolvedMailbox{id: idStrByName[name], name: name, parentName: parentName})
}
return out, nameByIDStr, idByName, nil
}
func isStandardFolder(name string) bool {
_, ok := standardRoles[name]
return ok
}
func buildMailboxObject(b *Backend, mbox *db.Mailbox, rm resolvedMailbox, idByName map[string]int64, positions map[string]int, totals, unread map[string]int) jmapMailbox {
m := jmapMailbox{
ID: rm.id,
Name: rm.name,
SortOrder: unpositionedSortOrder,
TotalEmails: totals[rm.name],
UnreadEmails: unread[rm.name],
IsSubscribed: true,
}
if pos, ok := positions[rm.name]; ok {
m.SortOrder = pos
}
if role, ok := standardRoles[rm.name]; ok {
m.Role = strPtr(role)
} else if rm.parentName != "" {
if pid, ok := idByName[rm.parentName]; ok {
m.ParentID = strPtr(strconv.FormatInt(pid, 10))
}
}
m.MyRights.MayReadItems = true
m.MyRights.MayAddItems = true
m.MyRights.MayRemoveItems = true
m.MyRights.MaySetSeen = true
m.MyRights.MaySetKeywords = true
m.MyRights.MayCreateChild = true
m.MyRights.MayRename = !isStandardFolder(rm.name)
m.MyRights.MayDelete = !isStandardFolder(rm.name)
m.MyRights.MaySubmit = rm.name == "Sent"
return m
}
const unpositionedSortOrder = 0
type mailboxGetArgs struct {
IDs *[]string `json:"ids"`
Properties *[]string `json:"properties"`
}
type mailboxGetResult struct {
AccountID string `json:"accountId"`
State string `json:"state"`
List []jmapMailbox `json:"list"`
NotFound []string `json:"notFound"`
}
func mailboxGet(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
var args mailboxGetArgs
if err := json.Unmarshal(rawArgs, &args); err != nil {
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
}
resolved, _, idByName, err := resolveMailboxes(b, mbox)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
positions, err := b.DB.FolderPositions(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
totals, err := b.DB.CountMessagesByFolder(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
unread, err := b.DB.CountUnreadByFolder(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
state, err := b.DB.FoldersState(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
result := mailboxGetResult{AccountID: strconv.FormatInt(mbox.ID, 10), State: state, List: []jmapMailbox{}, NotFound: []string{}}
if args.IDs == nil {
for _, rm := range resolved {
result.List = append(result.List, buildMailboxObject(b, mbox, rm, idByName, positions, totals, unread))
}
return result, nil
}
byID := make(map[string]resolvedMailbox, len(resolved))
for _, rm := range resolved {
byID[rm.id] = rm
}
for _, id := range *args.IDs {
rm, ok := byID[id]
if !ok {
result.NotFound = append(result.NotFound, id)
continue
}
result.List = append(result.List, buildMailboxObject(b, mbox, rm, idByName, positions, totals, unread))
}
return result, nil
}
type mailboxQueryFilter struct {
ParentID *string `json:"parentId"`
Name string `json:"name"`
}
type mailboxQueryArgs struct {
Filter *mailboxQueryFilter `json:"filter"`
}
type mailboxQueryResult struct {
AccountID string `json:"accountId"`
QueryState string `json:"queryState"`
CanCalculateChanges bool `json:"canCalculateChanges"`
Position int `json:"position"`
IDs []string `json:"ids"`
Total int `json:"total"`
}
func mailboxQuery(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
var args mailboxQueryArgs
if err := json.Unmarshal(rawArgs, &args); err != nil {
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
}
resolved, _, idByName, err := resolveMailboxes(b, mbox)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
positions, err := b.DB.FolderPositions(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
state, err := b.DB.FoldersState(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
matches := make([]resolvedMailbox, 0, len(resolved))
for _, rm := range resolved {
if args.Filter != nil {
if args.Filter.Name != "" && rm.name != args.Filter.Name {
continue
}
if args.Filter.ParentID != nil {
var parentIDStr string
if pid, ok := idByName[rm.parentName]; ok {
parentIDStr = strconv.FormatInt(pid, 10)
}
if parentIDStr != *args.Filter.ParentID {
continue
}
}
}
matches = append(matches, rm)
}
sort.SliceStable(matches, func(i, j int) bool {
pi, pj := positions[matches[i].name], positions[matches[j].name]
return pi < pj
})
ids := make([]string, len(matches))
for i, rm := range matches {
ids[i] = rm.id
}
return mailboxQueryResult{
AccountID: strconv.FormatInt(mbox.ID, 10),
QueryState: state,
CanCalculateChanges: false,
Position: 0,
IDs: ids,
Total: len(ids),
}, nil
}
type mailboxChangesArgs struct {
SinceState string `json:"sinceState"`
}
type mailboxChangesResult struct {
AccountID string `json:"accountId"`
OldState string `json:"oldState"`
NewState string `json:"newState"`
HasMoreChanges bool `json:"hasMoreChanges"`
Created []string `json:"created"`
Updated []string `json:"updated"`
Destroyed []string `json:"destroyed"`
}
func mailboxChanges(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
var args mailboxChangesArgs
if err := json.Unmarshal(rawArgs, &args); err != nil {
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
}
since, err := strconv.ParseInt(args.SinceState, 10, 64)
if err != nil {
return nil, &methodError{Type: "invalidArguments", Description: "sinceState must be a modseq integer string"}
}
changes, err := b.DB.FolderChangesSince(mbox.ID, since)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
return mailboxChangesResult{
AccountID: strconv.FormatInt(mbox.ID, 10),
OldState: args.SinceState,
NewState: changes.NewState,
HasMoreChanges: changes.HasMore,
Created: int64sToStrings(changes.Created),
Updated: int64sToStrings(changes.Updated),
Destroyed: []string{},
}, nil
}
// maxJMAPFolderNameLen mirrors internal/webui's own maxFolderNameLen — kept as a
// separate constant rather than importing internal/webui (jmap must not depend on the
// webui package) since it's a trivial, stable value both surfaces validate against.
const maxJMAPFolderNameLen = 60
func validFolderName(name string) error {
switch {
case name == "":
return fmt.Errorf("name is required")
case len(name) > maxJMAPFolderNameLen:
return fmt.Errorf("name is too long")
case strings.Contains(name, "/"):
return fmt.Errorf(`name can't contain "/"`)
case isStandardFolder(name):
return fmt.Errorf("%s already exists", name)
}
return nil
}
type mailboxCreateRequest struct {
Name string `json:"name"`
ParentID *string `json:"parentId"`
}
type mailboxSetArgs struct {
IfInState *string `json:"ifInState"`
Create map[string]mailboxCreateRequest `json:"create"`
Update map[string]json.RawMessage `json:"update"`
Destroy []string `json:"destroy"`
}
type mailboxSetResult struct {
AccountID string `json:"accountId"`
OldState string `json:"oldState"`
NewState string `json:"newState"`
Created map[string]jmapMailbox `json:"created"`
Updated map[string]any `json:"updated"`
Destroyed []string `json:"destroyed"`
NotCreated map[string]*methodError `json:"notCreated"`
NotUpdated map[string]*methodError `json:"notUpdated"`
NotDestroyed map[string]*methodError `json:"notDestroyed"`
}
// mailboxRenameFromPatch applies an Mailbox/set update patch, returning the resulting
// name. Only "name" is a supported patch key — this app has no existing primitive to
// re-parent an already-created custom folder to an arbitrary new parent (only
// CreateMailboxFolderUnder's one-time initial placement and MoveFolderToTrash's
// specific re-parent-to-Trash), and no per-folder subscription/sortOrder concept
// beyond the sidebar drag position SetFolderOrder already covers elsewhere — adding
// those is a real feature, out of scope here, so a patch touching them is rejected
// rather than silently ignored.
func mailboxRenameFromPatch(oldName string, patch map[string]json.RawMessage) (string, error) {
newName := oldName
for key, raw := range patch {
switch key {
case "name":
var n string
if err := json.Unmarshal(raw, &n); err != nil {
return "", fmt.Errorf("name: %w", err)
}
newName = strings.TrimSpace(n)
case "parentId", "sortOrder", "isSubscribed", "role":
return "", fmt.Errorf("%s updates are not supported", key)
default:
return "", fmt.Errorf("unsupported property %q", key)
}
}
return newName, nil
}
// mailboxSet is Mailbox/set (RFC 8621 §2.5). destroy reparents under Trash
// (db.MoveFolderToTrash) — the same soft-delete this app's webmail already uses for
// folder deletion, a deliberate choice over a strict spec-literal hard delete so one
// consistent delete model covers webmail/IMAP/JMAP for the same underlying data.
func mailboxSet(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
var args mailboxSetArgs
if err := json.Unmarshal(rawArgs, &args); err != nil {
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
}
oldState, err := b.DB.FoldersState(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
if args.IfInState != nil && *args.IfInState != oldState {
return nil, &methodError{Type: "stateMismatch", Description: "ifInState does not match current state"}
}
resolved, nameByID, idByName, err := resolveMailboxes(b, mbox)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
nameExists := func(name string) bool {
for _, rm := range resolved {
if strings.EqualFold(rm.name, name) {
return true
}
}
return false
}
result := mailboxSetResult{
AccountID: strconv.FormatInt(mbox.ID, 10), OldState: oldState,
Created: map[string]jmapMailbox{}, Updated: map[string]any{}, Destroyed: []string{},
NotCreated: map[string]*methodError{}, NotUpdated: map[string]*methodError{}, NotDestroyed: map[string]*methodError{},
}
for clientID, req := range args.Create {
name := strings.TrimSpace(req.Name)
if err := validFolderName(name); err != nil {
result.NotCreated[clientID] = &methodError{Type: "invalidProperties", Description: err.Error()}
continue
}
if nameExists(name) {
result.NotCreated[clientID] = &methodError{Type: "invalidProperties", Description: "a folder named " + name + " already exists"}
continue
}
parentName := "INBOX"
if req.ParentID != nil {
pn, ok := nameByID[*req.ParentID]
if !ok {
result.NotCreated[clientID] = &methodError{Type: "invalidProperties", Description: "unknown parentId"}
continue
}
parentName = pn
}
if root, err := b.DB.FolderRoot(mbox.ID, parentName); err != nil || root != "INBOX" {
result.NotCreated[clientID] = &methodError{Type: "invalidProperties", Description: "new folders can only go under Inbox"}
continue
}
if err := b.DB.CreateMailboxFolderUnder(mbox.ID, name, parentName); err != nil {
result.NotCreated[clientID] = &methodError{Type: "serverFail", Description: err.Error()}
continue
}
newID, err := b.DB.EnsureFolderRow(mbox.ID, name)
if err != nil {
result.NotCreated[clientID] = &methodError{Type: "serverFail", Description: err.Error()}
continue
}
m := jmapMailbox{ID: strconv.FormatInt(newID, 10), Name: name, SortOrder: unpositionedSortOrder, IsSubscribed: true}
if pid, ok := idByName[parentName]; ok {
m.ParentID = strPtr(strconv.FormatInt(pid, 10))
}
m.MyRights.MayReadItems, m.MyRights.MayAddItems, m.MyRights.MayRemoveItems = true, true, true
m.MyRights.MaySetSeen, m.MyRights.MaySetKeywords, m.MyRights.MayCreateChild = true, true, true
m.MyRights.MayRename, m.MyRights.MayDelete = true, true
result.Created[clientID] = m
}
for idStr, rawPatch := range args.Update {
oldName, ok := nameByID[idStr]
if !ok {
result.NotUpdated[idStr] = &methodError{Type: "notFound"}
continue
}
if isStandardFolder(oldName) {
result.NotUpdated[idStr] = &methodError{Type: "invalidProperties", Description: "standard folders can't be renamed"}
continue
}
if root, err := b.DB.FolderRoot(mbox.ID, oldName); err != nil || root != "INBOX" {
result.NotUpdated[idStr] = &methodError{Type: "invalidProperties", Description: "only a folder still under Inbox can be renamed"}
continue
}
var patch map[string]json.RawMessage
if err := json.Unmarshal(rawPatch, &patch); err != nil {
result.NotUpdated[idStr] = &methodError{Type: "invalidPatch", Description: err.Error()}
continue
}
newName, err := mailboxRenameFromPatch(oldName, patch)
if err != nil {
result.NotUpdated[idStr] = &methodError{Type: "invalidProperties", Description: err.Error()}
continue
}
if newName != oldName {
if err := validFolderName(newName); err != nil {
result.NotUpdated[idStr] = &methodError{Type: "invalidProperties", Description: err.Error()}
continue
}
if nameExists(newName) {
result.NotUpdated[idStr] = &methodError{Type: "invalidProperties", Description: "a folder named " + newName + " already exists"}
continue
}
if err := b.DB.RenameMailboxFolder(mbox.ID, oldName, newName); err != nil {
result.NotUpdated[idStr] = &methodError{Type: "serverFail", Description: err.Error()}
continue
}
}
result.Updated[idStr] = nil
}
for _, idStr := range args.Destroy {
name, ok := nameByID[idStr]
if !ok {
result.NotDestroyed[idStr] = &methodError{Type: "notFound"}
continue
}
if isStandardFolder(name) {
result.NotDestroyed[idStr] = &methodError{Type: "invalidProperties", Description: name + " is a standard folder and can't be removed"}
continue
}
if err := b.DB.MoveFolderToTrash(mbox.ID, name); err != nil {
result.NotDestroyed[idStr] = &methodError{Type: "serverFail", Description: err.Error()}
continue
}
result.Destroyed = append(result.Destroyed, idStr)
}
if len(result.Created) > 0 || len(result.Updated) > 0 || len(result.Destroyed) > 0 {
b.Notify.PublishAccountWide(mbox.ID)
}
newState, err := b.DB.FoldersState(mbox.ID)
if err != nil {
return nil, &methodError{Type: "serverFail", Description: err.Error()}
}
result.NewState = newState
return result, nil
}
func int64sToStrings(ids []int64) []string {
out := make([]string, len(ids))
for i, id := range ids {
out[i] = strconv.FormatInt(id, 10)
}
return out
}