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 }