From 933688db830db52d2fd5c6df0c1cf9c982e438a7 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Thu, 9 Oct 2025 15:08:12 +0200
Subject: [PATCH] jmap: fix Email/set
---
pkg/jmap/jmap_api_email.go | 20 +++++-----
pkg/jmap/jmap_http.go | 12 +++---
pkg/jmap/jmap_integration_test.go | 4 +-
pkg/jmap/jmap_model.go | 65 ++++++++++++++++++++++++++-----
4 files changed, 74 insertions(+), 27 deletions(-)
diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go
index 1ece9997c2..6f9006fe69 100644
--- a/pkg/jmap/jmap_api_email.go
+++ b/pkg/jmap/jmap_api_email.go
@@ -522,8 +522,8 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con
}
type CreatedEmail struct {
- Email Email `json:"email"`
- State State `json:"state"`
+ Email *Email `json:"email"`
+ State State `json:"state"`
}
func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (CreatedEmail, SessionState, Language, Error) {
@@ -572,8 +572,8 @@ func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Sessi
}
type UpdatedEmails struct {
- Updated map[string]Email `json:"email"`
- State State `json:"state"`
+ Updated map[string]*Email `json:"email"`
+ State State `json:"state"`
}
// The Email/set method encompasses:
@@ -613,7 +613,8 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate,
}
type DeletedEmails struct {
- State State `json:"state"`
+ State State `json:"state"`
+ NotDestroyed map[string]SetError `json:"notDestroyed"`
}
func (j *Client) DeleteEmails(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (DeletedEmails, SessionState, Language, Error) {
@@ -633,11 +634,10 @@ func (j *Client) DeleteEmails(accountId string, destroy []string, session *Sessi
if err != nil {
return DeletedEmails{}, err
}
- if len(setResponse.NotDestroyed) != len(destroy) {
- // error occured
- // TODO(pbleser-oc) handle submission errors
- }
- return DeletedEmails{State: setResponse.NewState}, nil
+ return DeletedEmails{
+ State: setResponse.NewState,
+ NotDestroyed: setResponse.NotDestroyed,
+ }, nil
})
}
diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go
index ff62eaedb6..7f75e578dd 100644
--- a/pkg/jmap/jmap_http.go
+++ b/pkg/jmap/jmap_http.go
@@ -144,7 +144,7 @@ func (h *HttpJmapClient) GetSession(sessionUrl *url.URL, username string, logger
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
- logger.Error().Str(logHttpStatus, res.Status).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200")
+ logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200")
return SessionResponse{}, SimpleError{code: JmapErrorServerResponse, err: fmt.Errorf("JMAP API response status is %v", res.Status)}
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
@@ -168,7 +168,7 @@ func (h *HttpJmapClient) GetSession(sessionUrl *url.URL, username string, logger
var data SessionResponse
err = json.Unmarshal(body, &data)
if err != nil {
- logger.Error().Str(logHttpUrl, sessionUrlStr).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
+ logger.Error().Str(logHttpUrl, log.SafeString(sessionUrlStr)).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
return SessionResponse{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
@@ -212,7 +212,7 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
language := Language(res.Header.Get("Content-Language"))
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
- logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, res.Status).Msg("HTTP response status code is not 2xx")
+ logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx")
return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
if res.Body != nil {
@@ -259,7 +259,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
language := Language(res.Header.Get("Content-Language"))
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
- logger.Error().Str(logHttpStatus, res.Status).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
+ logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
return UploadedBlob{}, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
if res.Body != nil {
@@ -282,7 +282,7 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
var result UploadedBlob
err = json.Unmarshal(responseBody, &result)
if err != nil {
- logger.Error().Str(logHttpUrl, uploadUrl).Err(err).Msg("failed to decode JSON payload from the upload response")
+ logger.Error().Str(logHttpUrl, log.SafeString(uploadUrl)).Err(err).Msg("failed to decode JSON payload from the upload response")
h.listener.OnResponseBodyUnmarshallingError(endpoint, err)
return UploadedBlob{}, language, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
}
@@ -316,7 +316,7 @@ func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger,
}
if res.StatusCode < 200 || res.StatusCode > 299 {
h.listener.OnFailedRequestWithStatus(endpoint, res.StatusCode)
- logger.Error().Str(logHttpStatus, res.Status).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
+ logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
return nil, language, SimpleError{code: JmapErrorServerResponse, err: err}
}
h.listener.OnSuccessfulRequest(endpoint, res.StatusCode)
diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go
index 0ec93b7d12..ecafcf1d75 100644
--- a/pkg/jmap/jmap_integration_test.go
+++ b/pkg/jmap/jmap_integration_test.go
@@ -152,7 +152,7 @@ type senderGenerator struct {
senders []sender
}
-func newSenderGenerator(domain string, numSenders int) senderGenerator {
+func newSenderGenerator(numSenders int) senderGenerator {
senders := make([]sender, numSenders)
for i := range numSenders {
person := gofakeit.Person()
@@ -494,7 +494,7 @@ func (s *StalwartTest) fill(folder string, count int) ([]filledMail, int, error)
bccName := "HR"
bccAddress := fmt.Sprintf("corporate@%s", domain)
- sg := newSenderGenerator(domain, senders)
+ sg := newSenderGenerator(senders)
thread := 0
mails := make([]filledMail, count)
for i := 0; i < count; thread++ {
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 60aec96bba..5eeeefef1d 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -2067,14 +2067,14 @@ type Email struct {
// (as referenced by the blobId, i.e., the number of octets in the file the user would download).
//
// [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
- Size int `json:"size"`
+ Size int `json:"size,omitzero"`
// The date the Email was received by the message store.
//
// This is the internal date in IMAP [RFC3501].
//
// [RFC3501]: https://www.rfc-editor.org/rfc/rfc3501.html
- ReceivedAt time.Time `json:"receivedAt,omitempty"`
+ ReceivedAt time.Time `json:"receivedAt,omitzero"`
// This is a list of all header fields [RFC5322], in the same order they appear in the message.
//
@@ -2116,7 +2116,7 @@ type Email struct {
Subject string `json:"subject,omitempty"`
// The value is identical to the value of header:Date:asDate.
- SentAt time.Time `json:"sentAt,omitempty"`
+ SentAt time.Time `json:"sentAt,omitzero"`
// This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts.
//
@@ -2817,10 +2817,56 @@ type EmailCreate struct {
type EmailUpdate map[string]any
type EmailSetCommand struct {
- AccountId string `json:"accountId"`
- Create map[string]EmailCreate `json:"create,omitempty"`
- Update map[string]EmailUpdate `json:"update,omitempty"`
- Destroy []string `json:"destroy,omitempty"`
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // This is a state string as returned by the `Email/get` method.
+ //
+ // If supplied, the string must match the current state; otherwise, the method will be aborted and a
+ // `stateMismatch` error returned.
+ //
+ // If null, any changes will be applied to the current state.
+ IfInState string `json:"ifInState,omitempty"`
+
+ // A map of a creation id (a temporary id set by the client) to Email objects,
+ // or null if no objects are to be created.
+ //
+ // The Email object type definition may define default values for properties.
+ //
+ // Any such property may be omitted by the client.
+ //
+ // The client MUST omit any properties that may only be set by the server.
+ Create map[string]EmailCreate `json:"create,omitempty"`
+
+ // A map of an id to a `Patch` object to apply to the current Email object with that id,
+ // or null if no objects are to be updated.
+ //
+ // A `PatchObject` is of type `String[*]` and represents an unordered set of patches.
+ //
+ // The keys are a path in JSON Pointer Format [@!RFC6901], with an implicit leading `/` (i.e., prefix each key
+ // with `/` before applying the JSON Pointer evaluation algorithm).
+ //
+ // All paths MUST also conform to the following restrictions; if there is any violation, the update
+ // MUST be rejected with an `invalidPatch` error:
+ // !- The pointer MUST NOT reference inside an array (i.e., you MUST NOT insert/delete from an array; the array MUST be replaced in its entirety instead).
+ // !- All parts prior to the last (i.e., the value after the final slash) MUST already exist on the object being patched.
+ // !- There MUST NOT be two patches in the `PatchObject` where the pointer of one is the prefix of the pointer of the other, e.g., `"alerts/1/offset"` and `"alerts"`.
+ //
+ // The value associated with each pointer determines how to apply that patch:
+ // !- If null, set to the default value if specified for this property; otherwise, remove the property from the patched object. If the key is not present in the parent, this a no-op.
+ // รง- Anything else: The value to set for this property (this may be a replacement or addition to the object being patched).
+ //
+ // Any server-set properties MAY be included in the patch if their value is identical to the current server value
+ // (before applying the patches to the object). Otherwise, the update MUST be rejected with an `invalidProperties` `SetError`.
+ //
+ // This patch definition is designed such that an entire Email object is also a valid `PatchObject`.
+ //
+ // The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
+ // processes it the same either way.
+ Update map[string]EmailUpdate `json:"update,omitempty"`
+
+ // A list of ids for Email objects to permanently delete, or null if no objects are to be destroyed.
+ Destroy []string `json:"destroy,omitempty"`
}
type EmailSetResponse struct {
@@ -2842,7 +2888,7 @@ type EmailSetResponse struct {
// that were omitted by the client and thus set to a default by the server.
//
// This argument is null if no Email objects were successfully created.
- Created map[string]Email `json:"created,omitempty"`
+ Created map[string]*Email `json:"created,omitempty"`
// The keys in this map are the ids of all Emails that were successfully updated.
//
@@ -2852,7 +2898,7 @@ type EmailSetResponse struct {
// This lets the client know of any changes to server-set or computed properties.
//
// This argument is null if no Email objects were successfully updated.
- Updated map[string]Email `json:"updated,omitempty"`
+ Updated map[string]*Email `json:"updated,omitempty"`
// A list of Email ids for records that were successfully destroyed, or null if none.
Destroyed []string `json:"destroyed,omitempty"`
@@ -4609,6 +4655,7 @@ var CommandResponseTypeMap = map[Command]func() any{
CommandEmailQuery: func() any { return EmailQueryResponse{} },
CommandEmailChanges: func() any { return EmailChangesResponse{} },
CommandEmailGet: func() any { return EmailGetResponse{} },
+ CommandEmailSet: func() any { return EmailSetResponse{} },
CommandEmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} },
CommandEmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} },
CommandThreadGet: func() any { return ThreadGetResponse{} },