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{} },