527 lines
18 KiB
Go
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
|
|
}
|