add JMAP functionality
This commit is contained in:
@@ -0,0 +1,784 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailview"
|
||||
)
|
||||
|
||||
type jmapEmailAddress struct {
|
||||
Name *string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// parseAddressList turns a raw "Name <a@b>, c@d" header value into JMAP
|
||||
// EmailAddress[] — best-effort: an unparseable value degrades to a single entry with
|
||||
// the raw text as the address rather than dropping it, since cached_from/cached_to
|
||||
// are display-only fields anyway (never used for delivery decisions).
|
||||
func parseAddressList(raw string) []jmapEmailAddress {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
addrs, err := mail.ParseAddressList(raw)
|
||||
if err != nil {
|
||||
return []jmapEmailAddress{{Email: raw}}
|
||||
}
|
||||
out := make([]jmapEmailAddress, len(addrs))
|
||||
for i, a := range addrs {
|
||||
e := jmapEmailAddress{Email: a.Address}
|
||||
if a.Name != "" {
|
||||
name := a.Name
|
||||
e.Name = &name
|
||||
}
|
||||
out[i] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type jmapBodyPart struct {
|
||||
PartID string `json:"partId,omitempty"`
|
||||
BlobID string `json:"blobId,omitempty"`
|
||||
Size int `json:"size"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
}
|
||||
|
||||
type jmapBodyValue struct {
|
||||
Value string `json:"value"`
|
||||
IsEncodingProblem bool `json:"isEncodingProblem"`
|
||||
IsTruncated bool `json:"isTruncated"`
|
||||
}
|
||||
|
||||
type jmapEmail struct {
|
||||
ID string `json:"id"`
|
||||
ThreadID string `json:"threadId"`
|
||||
MailboxIDs map[string]bool `json:"mailboxIds"`
|
||||
Keywords map[string]bool `json:"keywords"`
|
||||
Size int64 `json:"size"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
Subject *string `json:"subject"`
|
||||
From []jmapEmailAddress `json:"from,omitempty"`
|
||||
To []jmapEmailAddress `json:"to,omitempty"`
|
||||
Preview string `json:"preview"`
|
||||
HasAttachment bool `json:"hasAttachment"`
|
||||
TextBody []jmapBodyPart `json:"textBody,omitempty"`
|
||||
HTMLBody []jmapBodyPart `json:"htmlBody,omitempty"`
|
||||
Attachments []jmapBodyPart `json:"attachments,omitempty"`
|
||||
BodyValues map[string]jmapBodyValue `json:"bodyValues,omitempty"`
|
||||
}
|
||||
|
||||
// flagKeywords maps this app's stored IMAP flags to JMAP's "$"-prefixed keyword
|
||||
// vocabulary (RFC 8621 §4.1.1) — the two systems share the same underlying concept
|
||||
// (a message's own status markers), just different string spellings.
|
||||
var flagToKeyword = map[string]string{
|
||||
`\Seen`: "$seen",
|
||||
`\Flagged`: "$flagged",
|
||||
`\Answered`: "$answered",
|
||||
`\Draft`: "$draft",
|
||||
}
|
||||
var keywordToFlag = map[string]string{
|
||||
"$seen": `\Seen`, "$flagged": `\Flagged`, "$answered": `\Answered`, "$draft": `\Draft`,
|
||||
}
|
||||
|
||||
func keywordsFromFlags(flags string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, f := range strings.Fields(flags) {
|
||||
if kw, ok := flagToKeyword[f]; ok {
|
||||
out[kw] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildEmailObject converts one stored message into a JMAP Email object. mailboxIDs
|
||||
// maps this message's folder name to its JMAP mailboxId (see resolveMailboxes).
|
||||
// wantBody triggers a full decrypt+MIME parse (mailstore.FetchMessage +
|
||||
// mailview.Parse) for textBody/htmlBody/bodyValues/attachments/hasAttachment — the
|
||||
// cheap path (list/query views) skips this entirely, same "cached columns avoid
|
||||
// decryption" design IMAP/webmail already use.
|
||||
func buildEmailObject(b *Backend, mbox *db.Mailbox, m db.MailboxMessage, mailboxIDByFolder map[string]string, wantBody bool) jmapEmail {
|
||||
e := jmapEmail{
|
||||
ID: strconv.FormatInt(m.ID, 10),
|
||||
ThreadID: strconv.FormatInt(m.ThreadID, 10),
|
||||
MailboxIDs: map[string]bool{},
|
||||
Keywords: keywordsFromFlags(m.Flags),
|
||||
Size: m.SizeBytes,
|
||||
ReceivedAt: m.InternalDate.UTC().Format(time.RFC3339),
|
||||
Preview: m.CachedPreview,
|
||||
}
|
||||
if mid, ok := mailboxIDByFolder[m.Folder]; ok {
|
||||
e.MailboxIDs[mid] = true
|
||||
}
|
||||
if m.CachedSubject != "" {
|
||||
s := m.CachedSubject
|
||||
e.Subject = &s
|
||||
}
|
||||
e.From = parseAddressList(m.CachedFrom)
|
||||
e.To = parseAddressList(m.CachedTo)
|
||||
|
||||
if !wantBody {
|
||||
return e
|
||||
}
|
||||
raw, err := b.Mailstore.FetchMessage(mbox.ID, m.ID)
|
||||
if err != nil {
|
||||
return e
|
||||
}
|
||||
parsed, err := mailview.Parse(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return e
|
||||
}
|
||||
if parsed.TextBody != "" {
|
||||
partID := strconv.FormatInt(m.ID, 10) + "-text"
|
||||
e.TextBody = []jmapBodyPart{{PartID: partID, Type: "text/plain", Size: len(parsed.TextBody)}}
|
||||
if e.BodyValues == nil {
|
||||
e.BodyValues = map[string]jmapBodyValue{}
|
||||
}
|
||||
e.BodyValues[partID] = jmapBodyValue{Value: parsed.TextBody}
|
||||
}
|
||||
if parsed.HTMLBody != "" {
|
||||
partID := strconv.FormatInt(m.ID, 10) + "-html"
|
||||
e.HTMLBody = []jmapBodyPart{{PartID: partID, Type: "text/html", Size: len(parsed.HTMLBody)}}
|
||||
if e.BodyValues == nil {
|
||||
e.BodyValues = map[string]jmapBodyValue{}
|
||||
}
|
||||
e.BodyValues[partID] = jmapBodyValue{Value: parsed.HTMLBody}
|
||||
}
|
||||
for i, a := range parsed.Attachments {
|
||||
e.Attachments = append(e.Attachments, jmapBodyPart{
|
||||
BlobID: strconv.FormatInt(m.ID, 10) + "-" + strconv.Itoa(i),
|
||||
Size: len(a.Data),
|
||||
Type: a.ContentType,
|
||||
Name: a.Filename,
|
||||
CID: a.ContentID,
|
||||
})
|
||||
}
|
||||
e.HasAttachment = len(e.Attachments) > 0
|
||||
return e
|
||||
}
|
||||
|
||||
// wantsBody reports whether the requested property set needs a full decrypt+parse —
|
||||
// either explicitly via the fetch* flags, or implicitly because properties names one
|
||||
// of the body-derived fields.
|
||||
func wantsBody(args emailGetArgs) bool {
|
||||
if args.FetchTextBodyValues || args.FetchHTMLBodyValues || args.FetchAllBodyValues {
|
||||
return true
|
||||
}
|
||||
if args.Properties == nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range *args.Properties {
|
||||
switch p {
|
||||
case "textBody", "htmlBody", "bodyValues", "attachments", "hasAttachment":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type emailGetArgs struct {
|
||||
IDs *[]string `json:"ids"`
|
||||
Properties *[]string `json:"properties"`
|
||||
FetchTextBodyValues bool `json:"fetchTextBodyValues"`
|
||||
FetchHTMLBodyValues bool `json:"fetchHTMLBodyValues"`
|
||||
FetchAllBodyValues bool `json:"fetchAllBodyValues"`
|
||||
}
|
||||
|
||||
type emailGetResult struct {
|
||||
AccountID string `json:"accountId"`
|
||||
State string `json:"state"`
|
||||
List []jmapEmail `json:"list"`
|
||||
NotFound []string `json:"notFound"`
|
||||
}
|
||||
|
||||
func emailGet(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
|
||||
var args emailGetArgs
|
||||
if err := json.Unmarshal(rawArgs, &args); err != nil {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
}
|
||||
_, _, idByName, err := resolveMailboxes(b, mbox)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
mailboxIDByFolder := make(map[string]string, len(idByName))
|
||||
for name, id := range idByName {
|
||||
mailboxIDByFolder[name] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
state, err := b.DB.MessagesState(mbox.ID)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
wantBody := wantsBody(args)
|
||||
|
||||
result := emailGetResult{AccountID: strconv.FormatInt(mbox.ID, 10), State: state, List: []jmapEmail{}, NotFound: []string{}}
|
||||
if args.IDs == nil {
|
||||
return nil, &methodError{Type: "requestTooLarge", Description: "ids is required (fetching every Email in an account is not supported)"}
|
||||
}
|
||||
for _, idStr := range *args.IDs {
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
result.NotFound = append(result.NotFound, idStr)
|
||||
continue
|
||||
}
|
||||
m, err := b.DB.GetMessageByUID(mbox.ID, id)
|
||||
if err != nil || m == nil {
|
||||
result.NotFound = append(result.NotFound, idStr)
|
||||
continue
|
||||
}
|
||||
result.List = append(result.List, buildEmailObject(b, mbox, *m, mailboxIDByFolder, wantBody))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// emailQueryFilter is the bounded, flat FilterCondition subset this server supports
|
||||
// in v1 — see the JMAP plan's disclosed scope cut on nested FilterOperator (AND/OR/
|
||||
// NOT) support.
|
||||
type emailQueryFilter struct {
|
||||
InMailbox *string `json:"inMailbox"`
|
||||
Text *string `json:"text"`
|
||||
Subject *string `json:"subject"`
|
||||
From *string `json:"from"`
|
||||
To *string `json:"to"`
|
||||
HasKeyword *string `json:"hasKeyword"`
|
||||
// Presence of "operator" means the client sent a nested FilterOperator — rejected
|
||||
// below with unsupportedFilter rather than silently mis-evaluated.
|
||||
Operator *string `json:"operator"`
|
||||
}
|
||||
|
||||
type emailQueryArgs struct {
|
||||
Filter *emailQueryFilter `json:"filter"`
|
||||
Limit *int `json:"limit"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type emailQueryResult 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"`
|
||||
}
|
||||
|
||||
const defaultQueryLimit = 50
|
||||
|
||||
func emailQuery(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
|
||||
var args emailQueryArgs
|
||||
if err := json.Unmarshal(rawArgs, &args); err != nil {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
}
|
||||
if args.Filter != nil && args.Filter.Operator != nil {
|
||||
return nil, &methodError{Type: "unsupportedFilter", Description: "nested FilterOperator (AND/OR/NOT) is not supported — use a single flat FilterCondition"}
|
||||
}
|
||||
|
||||
limit := defaultQueryLimit
|
||||
if args.Limit != nil && *args.Limit > 0 {
|
||||
limit = *args.Limit
|
||||
}
|
||||
folder := ""
|
||||
if args.Filter != nil && args.Filter.InMailbox != nil {
|
||||
_, nameByID, _, err := resolveMailboxes(b, mbox)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
name, ok := nameByID[*args.Filter.InMailbox]
|
||||
if !ok {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: "unknown inMailbox id"}
|
||||
}
|
||||
folder = name
|
||||
}
|
||||
|
||||
var (
|
||||
messages []db.MailboxMessage
|
||||
total int
|
||||
err error
|
||||
)
|
||||
searchText := ""
|
||||
if args.Filter != nil {
|
||||
switch {
|
||||
case args.Filter.Text != nil:
|
||||
searchText = *args.Filter.Text
|
||||
case args.Filter.Subject != nil:
|
||||
searchText = *args.Filter.Subject
|
||||
case args.Filter.From != nil:
|
||||
searchText = *args.Filter.From
|
||||
case args.Filter.To != nil:
|
||||
searchText = *args.Filter.To
|
||||
}
|
||||
}
|
||||
if searchText != "" {
|
||||
messages, err = b.DB.SearchMessagesInFolder(mbox.ID, folder, searchText, args.Position, limit)
|
||||
if err == nil {
|
||||
total, err = b.DB.CountSearchMessagesInFolder(mbox.ID, folder, searchText)
|
||||
}
|
||||
} else if folder != "" {
|
||||
// Only hasKeyword=="$flagged" maps cleanly onto an existing WHERE clause
|
||||
// (ListMessagesInFolderPage's starredOnly) — anything else (an unsupported
|
||||
// keyword, or notKeyword, not modeled in emailQueryFilter at all) is rejected
|
||||
// rather than silently ignored, same "never silently wrong" posture as the
|
||||
// AND/OR/NOT rejection above.
|
||||
starredOnly := false
|
||||
if args.Filter != nil && args.Filter.HasKeyword != nil {
|
||||
if *args.Filter.HasKeyword != "$flagged" {
|
||||
return nil, &methodError{Type: "unsupportedFilter", Description: "hasKeyword is only supported for \"$flagged\" in v1"}
|
||||
}
|
||||
starredOnly = true
|
||||
}
|
||||
messages, err = b.DB.ListMessagesInFolderPage(mbox.ID, folder, false, starredOnly, "date", "desc", args.Position, limit)
|
||||
if err == nil {
|
||||
total, err = b.DB.CountMessagesInFolder(mbox.ID, folder, false, starredOnly)
|
||||
}
|
||||
} else {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: "filter.inMailbox or filter.text/subject/from/to is required"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
|
||||
state, err := b.DB.MessagesState(mbox.ID)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
ids := make([]string, len(messages))
|
||||
for i, m := range messages {
|
||||
ids[i] = strconv.FormatInt(m.ID, 10)
|
||||
}
|
||||
return emailQueryResult{
|
||||
AccountID: strconv.FormatInt(mbox.ID, 10),
|
||||
QueryState: state,
|
||||
CanCalculateChanges: false,
|
||||
Position: args.Position,
|
||||
IDs: ids,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type emailChangesArgs struct {
|
||||
SinceState string `json:"sinceState"`
|
||||
}
|
||||
|
||||
type emailChangesResult 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 emailChanges(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
|
||||
var args emailChangesArgs
|
||||
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.MessageChangesSince(mbox.ID, since)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
return emailChangesResult{
|
||||
AccountID: strconv.FormatInt(mbox.ID, 10),
|
||||
OldState: args.SinceState,
|
||||
NewState: changes.NewState,
|
||||
HasMoreChanges: changes.HasMore,
|
||||
Created: int64sToStrings(changes.Created),
|
||||
Updated: int64sToStrings(changes.Updated),
|
||||
Destroyed: int64sToStrings(changes.Destroyed),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// singleMailboxFolder resolves a JMAP mailboxIds set (RFC 8621 §4.1.1: {mailboxId:
|
||||
// true, ...}) to the one folder name it represents — this server stores each message
|
||||
// in exactly one folder, so mailboxIds must name exactly one mailbox with value true;
|
||||
// anything else (zero, or more than one) is rejected rather than silently picking one.
|
||||
func singleMailboxFolder(ids map[string]bool, nameByID map[string]string) (string, error) {
|
||||
var chosen string
|
||||
n := 0
|
||||
for id, v := range ids {
|
||||
if !v {
|
||||
continue
|
||||
}
|
||||
name, ok := nameByID[id]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown mailbox id %q", id)
|
||||
}
|
||||
chosen, n = name, n+1
|
||||
}
|
||||
if n != 1 {
|
||||
return "", fmt.Errorf("mailboxIds must name exactly one mailbox (multiple mailbox membership per message isn't supported)")
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
// flagsFromKeywords renders a JMAP keywords set back to this app's space-separated
|
||||
// IMAP flags string — the inverse of keywordsFromFlags. Sorted only for a
|
||||
// deterministic/diffable stored value, not load-bearing.
|
||||
func flagsFromKeywords(kw map[string]bool) string {
|
||||
var parts []string
|
||||
for k, v := range kw {
|
||||
if !v {
|
||||
continue
|
||||
}
|
||||
if flag, ok := keywordToFlag[k]; ok {
|
||||
parts = append(parts, flag)
|
||||
}
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// applyEmailUpdate handles one Email/set update patch (RFC 8620 §5.3): either a whole-
|
||||
// property replacement ("keywords"/"mailboxIds") or the individual-property patch
|
||||
// shorthand ("keywords/$seen", "mailboxIds/<id>") real JMAP clients commonly send for
|
||||
// a single flag toggle or move instead of resending the whole set. A "mailboxIds/<id>"
|
||||
// patch only acts on a "true" value — a bare removal (null/false) with no
|
||||
// accompanying "true" elsewhere is only meaningful in a multi-mailbox model this
|
||||
// server doesn't support, so it's a silent no-op rather than an error (the same
|
||||
// message simply keeps its current single folder).
|
||||
func applyEmailUpdate(b *Backend, mbox *db.Mailbox, m db.MailboxMessage, patch map[string]json.RawMessage, nameByID map[string]string) error {
|
||||
keywords := keywordsFromFlags(m.Flags)
|
||||
keywordsChanged := false
|
||||
newFolder := m.Folder
|
||||
|
||||
for key, raw := range patch {
|
||||
switch {
|
||||
case key == "keywords":
|
||||
var kw map[string]bool
|
||||
if err := json.Unmarshal(raw, &kw); err != nil {
|
||||
return fmt.Errorf("keywords: %w", err)
|
||||
}
|
||||
keywords = kw
|
||||
keywordsChanged = true
|
||||
case key == "mailboxIds":
|
||||
var ids map[string]bool
|
||||
if err := json.Unmarshal(raw, &ids); err != nil {
|
||||
return fmt.Errorf("mailboxIds: %w", err)
|
||||
}
|
||||
folder, err := singleMailboxFolder(ids, nameByID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newFolder = folder
|
||||
case strings.HasPrefix(key, "keywords/"):
|
||||
kw := strings.TrimPrefix(key, "keywords/")
|
||||
var val *bool
|
||||
if err := json.Unmarshal(raw, &val); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if val == nil || !*val {
|
||||
delete(keywords, kw)
|
||||
} else {
|
||||
keywords[kw] = true
|
||||
}
|
||||
keywordsChanged = true
|
||||
case strings.HasPrefix(key, "mailboxIds/"):
|
||||
id := strings.TrimPrefix(key, "mailboxIds/")
|
||||
var val *bool
|
||||
if err := json.Unmarshal(raw, &val); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if val != nil && *val {
|
||||
name, ok := nameByID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("mailboxIds/%s: unknown mailbox", id)
|
||||
}
|
||||
newFolder = name
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported property %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
if keywordsChanged {
|
||||
if err := b.DB.SetMessageFlags(mbox.ID, m.ID, flagsFromKeywords(keywords)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if newFolder != m.Folder {
|
||||
if err := b.DB.MoveMessage(mbox.ID, m.ID, newFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type emailSetArgs struct {
|
||||
IfInState *string `json:"ifInState"`
|
||||
Create map[string]json.RawMessage `json:"create"`
|
||||
Update map[string]json.RawMessage `json:"update"`
|
||||
Destroy []string `json:"destroy"`
|
||||
}
|
||||
|
||||
type emailSetResult struct {
|
||||
AccountID string `json:"accountId"`
|
||||
OldState string `json:"oldState"`
|
||||
NewState string `json:"newState"`
|
||||
Created map[string]jmapEmail `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"`
|
||||
}
|
||||
|
||||
// emailSet is Email/set (RFC 8621 §4.6). "create" isn't supported — building a
|
||||
// full MIME message from JMAP properties is a materially different job from mutating
|
||||
// one; Email/import (raw RFC822 via a previously uploaded blob) is this server's
|
||||
// supported creation path, and every "create" entry is reported notCreated pointing
|
||||
// there rather than silently ignored.
|
||||
func emailSet(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
|
||||
var args emailSetArgs
|
||||
if err := json.Unmarshal(rawArgs, &args); err != nil {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
}
|
||||
oldState, err := b.DB.MessagesState(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"}
|
||||
}
|
||||
|
||||
result := emailSetResult{
|
||||
AccountID: strconv.FormatInt(mbox.ID, 10), OldState: oldState,
|
||||
Created: map[string]jmapEmail{}, Updated: map[string]any{}, Destroyed: []string{},
|
||||
NotCreated: map[string]*methodError{}, NotUpdated: map[string]*methodError{}, NotDestroyed: map[string]*methodError{},
|
||||
}
|
||||
for clientID := range args.Create {
|
||||
result.NotCreated[clientID] = &methodError{Type: "invalidArguments", Description: "Email/set create is not supported — use Email/import for raw RFC822 content"}
|
||||
}
|
||||
|
||||
_, nameByID, _, err := resolveMailboxes(b, mbox)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
|
||||
for idStr, rawPatch := range args.Update {
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
result.NotUpdated[idStr] = &methodError{Type: "notFound"}
|
||||
continue
|
||||
}
|
||||
m, err := b.DB.GetMessageByUID(mbox.ID, id)
|
||||
if err != nil || m == nil {
|
||||
result.NotUpdated[idStr] = &methodError{Type: "notFound"}
|
||||
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
|
||||
}
|
||||
if err := applyEmailUpdate(b, mbox, *m, patch, nameByID); err != nil {
|
||||
result.NotUpdated[idStr] = &methodError{Type: "invalidPatch", Description: err.Error()}
|
||||
continue
|
||||
}
|
||||
result.Updated[idStr] = nil
|
||||
}
|
||||
|
||||
for _, idStr := range args.Destroy {
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
result.NotDestroyed[idStr] = &methodError{Type: "notFound"}
|
||||
continue
|
||||
}
|
||||
m, err := b.DB.GetMessageByUID(mbox.ID, id)
|
||||
if err != nil || m == nil {
|
||||
result.NotDestroyed[idStr] = &methodError{Type: "notFound"}
|
||||
continue
|
||||
}
|
||||
if err := b.Mailstore.DeleteMessage(mbox.ID, id); err != nil {
|
||||
result.NotDestroyed[idStr] = &methodError{Type: "serverFail", Description: err.Error()}
|
||||
continue
|
||||
}
|
||||
result.Destroyed = append(result.Destroyed, idStr)
|
||||
}
|
||||
|
||||
if len(result.Updated) > 0 || len(result.Destroyed) > 0 {
|
||||
// Wakes any connected EventSource (internal/jmap/eventsource.go) or IMAP IDLE
|
||||
// session on this mailbox — same signal IMAP APPEND/SMTP delivery already
|
||||
// publish, just triggered by a JMAP mutation instead.
|
||||
b.Notify.PublishAccountWide(mbox.ID)
|
||||
}
|
||||
|
||||
newState, err := b.DB.MessagesState(mbox.ID)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
result.NewState = newState
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type emailCopyCreate struct {
|
||||
ID string `json:"id"`
|
||||
MailboxIDs map[string]bool `json:"mailboxIds"`
|
||||
}
|
||||
|
||||
type emailCopyArgs struct {
|
||||
FromAccountID string `json:"fromAccountId"`
|
||||
Create map[string]emailCopyCreate `json:"create"`
|
||||
OnSuccessDestroyOriginal bool `json:"onSuccessDestroyOriginal"`
|
||||
}
|
||||
|
||||
type emailCopyResult struct {
|
||||
FromAccountID string `json:"fromAccountId"`
|
||||
AccountID string `json:"accountId"`
|
||||
Created map[string]jmapEmail `json:"created"`
|
||||
NotCreated map[string]*methodError `json:"notCreated"`
|
||||
}
|
||||
|
||||
// emailCopy is Email/copy (RFC 8621 §4.8) — always same-account here (this server has
|
||||
// one JMAP account per mailbox, so cross-account copy has no meaning).
|
||||
func emailCopy(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
|
||||
var args emailCopyArgs
|
||||
if err := json.Unmarshal(rawArgs, &args); err != nil {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
}
|
||||
accountID := strconv.FormatInt(mbox.ID, 10)
|
||||
if args.FromAccountID != "" && args.FromAccountID != accountID {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: "cross-account copy is not supported — this server has one account per mailbox"}
|
||||
}
|
||||
|
||||
_, nameByID, idByName, err := resolveMailboxes(b, mbox)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
mailboxIDByFolder := make(map[string]string, len(idByName))
|
||||
for name, id := range idByName {
|
||||
mailboxIDByFolder[name] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
result := emailCopyResult{FromAccountID: accountID, AccountID: accountID, Created: map[string]jmapEmail{}, NotCreated: map[string]*methodError{}}
|
||||
for clientID, c := range args.Create {
|
||||
srcID, err := strconv.ParseInt(c.ID, 10, 64)
|
||||
if err != nil {
|
||||
result.NotCreated[clientID] = &methodError{Type: "notFound"}
|
||||
continue
|
||||
}
|
||||
destFolder, err := singleMailboxFolder(c.MailboxIDs, nameByID)
|
||||
if err != nil {
|
||||
result.NotCreated[clientID] = &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
continue
|
||||
}
|
||||
newUID, err := b.Mailstore.CopyMessage(mbox.ID, srcID, destFolder)
|
||||
if err != nil {
|
||||
result.NotCreated[clientID] = &methodError{Type: "serverFail", Description: err.Error()}
|
||||
continue
|
||||
}
|
||||
if args.OnSuccessDestroyOriginal {
|
||||
b.Mailstore.DeleteMessage(mbox.ID, srcID)
|
||||
}
|
||||
m, err := b.DB.GetMessageByUID(mbox.ID, newUID)
|
||||
if err != nil || m == nil {
|
||||
result.NotCreated[clientID] = &methodError{Type: "serverFail"}
|
||||
continue
|
||||
}
|
||||
result.Created[clientID] = buildEmailObject(b, mbox, *m, mailboxIDByFolder, false)
|
||||
}
|
||||
if len(result.Created) > 0 {
|
||||
b.Notify.PublishAccountWide(mbox.ID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type emailImportEntry struct {
|
||||
BlobID string `json:"blobId"`
|
||||
MailboxIDs map[string]bool `json:"mailboxIds"`
|
||||
Keywords map[string]bool `json:"keywords"`
|
||||
}
|
||||
|
||||
type emailImportArgs struct {
|
||||
Emails map[string]emailImportEntry `json:"emails"`
|
||||
}
|
||||
|
||||
type emailImportResult struct {
|
||||
AccountID string `json:"accountId"`
|
||||
OldState string `json:"oldState"`
|
||||
NewState string `json:"newState"`
|
||||
Created map[string]jmapEmail `json:"created"`
|
||||
NotCreated map[string]*methodError `json:"notCreated"`
|
||||
}
|
||||
|
||||
// extractRawHeader reads a single header out of raw without a full MIME walk — enough
|
||||
// for Email/import to seed message_id_header/cached_from/cached_subject the same way
|
||||
// SMTP delivery and IMAP APPEND already do for a freshly arriving message.
|
||||
func extractRawHeader(raw []byte, name string) string {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return msg.Header.Get(name)
|
||||
}
|
||||
|
||||
// emailImport is Email/import (RFC 8621 §4.7) — the supported way to create a new
|
||||
// Email in this server (see emailSet's doc comment on why Email/set itself doesn't).
|
||||
// blobId must reference a blob already uploaded via POST /jmap/upload (see blob.go).
|
||||
func emailImport(b *Backend, mbox *db.Mailbox, rawArgs json.RawMessage) (any, *methodError) {
|
||||
var args emailImportArgs
|
||||
if err := json.Unmarshal(rawArgs, &args); err != nil {
|
||||
return nil, &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
}
|
||||
oldState, err := b.DB.MessagesState(mbox.ID)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
_, nameByID, idByName, err := resolveMailboxes(b, mbox)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
mailboxIDByFolder := make(map[string]string, len(idByName))
|
||||
for name, id := range idByName {
|
||||
mailboxIDByFolder[name] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
result := emailImportResult{AccountID: strconv.FormatInt(mbox.ID, 10), OldState: oldState, Created: map[string]jmapEmail{}, NotCreated: map[string]*methodError{}}
|
||||
for clientID, entry := range args.Emails {
|
||||
raw, ok := blobBytes(b, mbox, entry.BlobID)
|
||||
if !ok {
|
||||
result.NotCreated[clientID] = &methodError{Type: "notFound", Description: "unknown blobId"}
|
||||
continue
|
||||
}
|
||||
folder, err := singleMailboxFolder(entry.MailboxIDs, nameByID)
|
||||
if err != nil {
|
||||
result.NotCreated[clientID] = &methodError{Type: "invalidArguments", Description: err.Error()}
|
||||
continue
|
||||
}
|
||||
messageID := extractRawHeader(raw, "Message-Id")
|
||||
fromHeader := extractRawHeader(raw, "From")
|
||||
subject := extractRawHeader(raw, "Subject")
|
||||
uid, err := b.Mailstore.StoreMessage(mbox.ID, folder, raw, messageID, fromHeader, subject)
|
||||
if err != nil {
|
||||
result.NotCreated[clientID] = &methodError{Type: "serverFail", Description: err.Error()}
|
||||
continue
|
||||
}
|
||||
if len(entry.Keywords) > 0 {
|
||||
b.DB.SetMessageFlags(mbox.ID, uid, flagsFromKeywords(entry.Keywords))
|
||||
}
|
||||
m, err := b.DB.GetMessageByUID(mbox.ID, uid)
|
||||
if err != nil || m == nil {
|
||||
continue
|
||||
}
|
||||
result.Created[clientID] = buildEmailObject(b, mbox, *m, mailboxIDByFolder, false)
|
||||
}
|
||||
if len(result.Created) > 0 {
|
||||
b.Notify.PublishAccountWide(mbox.ID)
|
||||
}
|
||||
newState, err := b.DB.MessagesState(mbox.ID)
|
||||
if err != nil {
|
||||
return nil, &methodError{Type: "serverFail", Description: err.Error()}
|
||||
}
|
||||
result.NewState = newState
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user