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

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
}