785 lines
27 KiB
Go
785 lines
27 KiB
Go
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
|
|
}
|