diff --git a/pkg/jmap/jmap_api_blob.go b/pkg/jmap/jmap_api_blob.go index dc5203da14..415a3c7040 100644 --- a/pkg/jmap/jmap_api_blob.go +++ b/pkg/jmap/jmap_api_blob.go @@ -19,7 +19,7 @@ func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context aid := session.BlobAccountId(accountId) cmd, err := request( - invocation(BlobUpload, BlobGetCommand{ + invocation(CommandBlobUpload, BlobGetCommand{ AccountId: aid, Ids: []string{id}, Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize}, @@ -31,7 +31,7 @@ func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (BlobResponse, Error) { var response BlobGetResponse - err = retrieveResponseMatchParameters(body, BlobGet, "0", &response) + err = retrieveResponseMatchParameters(body, CommandBlobGet, "0", &response) if err != nil { return BlobResponse{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -93,15 +93,15 @@ func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Cont AccountId: aid, IdRef: &ResultReference{ ResultOf: "0", - Name: BlobUpload, + Name: CommandBlobUpload, Path: "/ids", }, Properties: []string{BlobPropertyDigestSha512}, } cmd, err := request( - invocation(BlobUpload, upload, "0"), - invocation(BlobGet, getHash, "1"), + invocation(CommandBlobUpload, upload, "0"), + invocation(CommandBlobGet, getHash, "1"), ) if err != nil { return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} @@ -109,13 +109,13 @@ func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Cont return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) { var uploadResponse BlobUploadResponse - err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse) + err = retrieveResponseMatchParameters(body, CommandBlobUpload, "0", &uploadResponse) if err != nil { return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var getResponse BlobGetResponse - err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse) + err = retrieveResponseMatchParameters(body, CommandBlobGet, "1", &getResponse) if err != nil { return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index 88748e560a..b97c9f46c1 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -40,13 +40,13 @@ func (j *Client) GetEmails(accountId string, session *Session, ctx context.Conte get.MaxBodyValueBytes = maxBodyValueBytes } - cmd, err := request(invocation(EmailGet, get, "0")) + cmd, err := request(invocation(CommandEmailGet, get, "0")) if err != nil { return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) { var response EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "0", &response) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "0", &response) if err != nil { return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -63,7 +63,7 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co query := EmailQueryCommand{ AccountId: aid, Filter: &EmailFilterCondition{InMailbox: mailboxId}, - Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, + Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, CalculateTotal: true, } @@ -77,15 +77,15 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co get := EmailGetRefCommand{ AccountId: aid, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"}, + IdRef: &ResultReference{Name: CommandEmailQuery, Path: "/ids/*", ResultOf: "0"}, } if maxBodyValueBytes >= 0 { get.MaxBodyValueBytes = maxBodyValueBytes } cmd, err := request( - invocation(EmailQuery, query, "0"), - invocation(EmailGet, get, "1"), + invocation(CommandEmailQuery, query, "0"), + invocation(CommandEmailGet, get, "1"), ) if err != nil { return Emails{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} @@ -93,12 +93,12 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Emails, Error) { var queryResponse EmailQueryResponse - err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse) + err = retrieveResponseMatchParameters(body, CommandEmailQuery, "0", &queryResponse) if err != nil { return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var getResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "1", &getResponse) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "1", &getResponse) if err != nil { return Emails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -141,7 +141,7 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx getCreated := EmailGetRefCommand{ AccountId: aid, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"}, + IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/created", ResultOf: "0"}, } if maxBodyValueBytes >= 0 { getCreated.MaxBodyValueBytes = maxBodyValueBytes @@ -149,16 +149,16 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx getUpdated := EmailGetRefCommand{ AccountId: aid, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: MailboxChanges, Path: "/updated", ResultOf: "0"}, + IdRef: &ResultReference{Name: CommandMailboxChanges, Path: "/updated", ResultOf: "0"}, } if maxBodyValueBytes >= 0 { getUpdated.MaxBodyValueBytes = maxBodyValueBytes } cmd, err := request( - invocation(MailboxChanges, changes, "0"), - invocation(EmailGet, getCreated, "1"), - invocation(EmailGet, getUpdated, "2"), + invocation(CommandMailboxChanges, changes, "0"), + invocation(CommandEmailGet, getCreated, "1"), + invocation(CommandEmailGet, getUpdated, "2"), ) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} @@ -166,19 +166,19 @@ func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) { var mailboxResponse MailboxChangesResponse - err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse) + err = retrieveResponseMatchParameters(body, CommandMailboxChanges, "0", &mailboxResponse) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var createdResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "1", &createdResponse) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var updatedResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "2", &updatedResponse) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -212,7 +212,7 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. getCreated := EmailGetRefCommand{ AccountId: aid, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"}, + IdRef: &ResultReference{Name: CommandEmailChanges, Path: "/created", ResultOf: "0"}, } if maxBodyValueBytes >= 0 { getCreated.MaxBodyValueBytes = maxBodyValueBytes @@ -220,16 +220,16 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. getUpdated := EmailGetRefCommand{ AccountId: aid, FetchAllBodyValues: fetchBodies, - IdRef: &ResultReference{Name: EmailChanges, Path: "/updated", ResultOf: "0"}, + IdRef: &ResultReference{Name: CommandEmailChanges, Path: "/updated", ResultOf: "0"}, } if maxBodyValueBytes >= 0 { getUpdated.MaxBodyValueBytes = maxBodyValueBytes } cmd, err := request( - invocation(EmailChanges, changes, "0"), - invocation(EmailGet, getCreated, "1"), - invocation(EmailGet, getUpdated, "2"), + invocation(CommandEmailChanges, changes, "0"), + invocation(CommandEmailGet, getCreated, "1"), + invocation(CommandEmailGet, getUpdated, "2"), ) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} @@ -237,19 +237,19 @@ func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context. return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) { var changesResponse EmailChangesResponse - err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse) + err = retrieveResponseMatchParameters(body, CommandEmailChanges, "0", &changesResponse) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var createdResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "1", &createdResponse) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var updatedResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "2", &updatedResponse) if err != nil { return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -284,7 +284,7 @@ func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, query := EmailQueryCommand{ AccountId: aid, Filter: filter, - Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, + Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, CalculateTotal: true, } @@ -295,19 +295,19 @@ func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, query.Limit = limit } - snippet := SearchSnippetRefCommand{ + snippet := SearchSnippetGetRefCommand{ AccountId: aid, Filter: filter, EmailIdRef: &ResultReference{ ResultOf: "0", - Name: EmailQuery, + Name: CommandEmailQuery, Path: "/ids/*", }, } cmd, err := request( - invocation(EmailQuery, query, "0"), - invocation(SearchSnippetGet, snippet, "1"), + invocation(CommandEmailQuery, query, "0"), + invocation(CommandSearchSnippetGet, snippet, "1"), ) if err != nil { @@ -316,13 +316,13 @@ func (j *Client) QueryEmailSnippets(accountId string, filter EmailFilterElement, return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailSnippetQueryResult, Error) { var queryResponse EmailQueryResponse - err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse) + err = retrieveResponseMatchParameters(body, CommandEmailQuery, "0", &queryResponse) if err != nil { return EmailSnippetQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var snippetResponse SearchSnippetGetResponse - err = retrieveResponseMatchParameters(body, SearchSnippetGet, "1", &snippetResponse) + err = retrieveResponseMatchParameters(body, CommandSearchSnippetGet, "1", &snippetResponse) if err != nil { return EmailSnippetQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -362,7 +362,7 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio query := EmailQueryCommand{ AccountId: aid, Filter: filter, - Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, + Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, CalculateTotal: true, } @@ -373,12 +373,12 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio query.Limit = limit } - snippet := SearchSnippetRefCommand{ + snippet := SearchSnippetGetRefCommand{ AccountId: aid, Filter: filter, EmailIdRef: &ResultReference{ ResultOf: "0", - Name: EmailQuery, + Name: CommandEmailQuery, Path: "/ids/*", }, } @@ -387,7 +387,7 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio AccountId: aid, IdRef: &ResultReference{ ResultOf: "0", - Name: EmailQuery, + Name: CommandEmailQuery, Path: "/ids/*", }, FetchAllBodyValues: fetchBodies, @@ -395,9 +395,9 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio } cmd, err := request( - invocation(EmailQuery, query, "0"), - invocation(SearchSnippetGet, snippet, "1"), - invocation(EmailGet, mails, "2"), + invocation(CommandEmailQuery, query, "0"), + invocation(CommandSearchSnippetGet, snippet, "1"), + invocation(CommandEmailGet, mails, "2"), ) if err != nil { @@ -406,19 +406,19 @@ func (j *Client) QueryEmails(accountId string, filter EmailFilterElement, sessio return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailQueryResult, Error) { var queryResponse EmailQueryResponse - err = retrieveResponseMatchParameters(body, EmailQuery, "0", &queryResponse) + err = retrieveResponseMatchParameters(body, CommandEmailQuery, "0", &queryResponse) if err != nil { return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var snippetResponse SearchSnippetGetResponse - err = retrieveResponseMatchParameters(body, SearchSnippetGet, "1", &snippetResponse) + err = retrieveResponseMatchParameters(body, CommandSearchSnippetGet, "1", &snippetResponse) if err != nil { return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var emailsResponse EmailGetResponse - err = retrieveResponseMatchParameters(body, EmailGet, "2", &emailsResponse) + err = retrieveResponseMatchParameters(body, CommandEmailGet, "2", &emailsResponse) if err != nil { return EmailQueryResult{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -485,15 +485,15 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con AccountId: aid, IdRef: &ResultReference{ ResultOf: "0", - Name: BlobUpload, + Name: CommandBlobUpload, Path: "/ids", }, Properties: []string{BlobPropertyDigestSha512}, } cmd, err := request( - invocation(BlobUpload, upload, "0"), - invocation(BlobGet, getHash, "1"), + invocation(CommandBlobUpload, upload, "0"), + invocation(CommandBlobGet, getHash, "1"), ) if err != nil { return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} @@ -501,13 +501,13 @@ func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Con return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedEmail, Error) { var uploadResponse BlobUploadResponse - err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse) + err = retrieveResponseMatchParameters(body, CommandBlobUpload, "0", &uploadResponse) if err != nil { return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var getResponse BlobGetResponse - err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse) + err = retrieveResponseMatchParameters(body, CommandBlobGet, "1", &getResponse) if err != nil { return UploadedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -546,7 +546,7 @@ func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Sessi aid := session.MailAccountId(accountId) cmd, err := request( - invocation(EmailSubmissionSet, EmailSetCommand{ + invocation(CommandEmailSubmissionSet, EmailSetCommand{ AccountId: aid, Create: map[string]EmailCreate{ "c": email, @@ -559,7 +559,7 @@ func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Sessi return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (CreatedEmail, Error) { var setResponse EmailSetResponse - err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + err = retrieveResponseMatchParameters(body, CommandEmailSet, "0", &setResponse) if err != nil { return CreatedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -576,7 +576,7 @@ func (j *Client) CreateEmail(accountId string, email EmailCreate, session *Sessi created, ok := setResponse.Created["c"] if !ok { - err = fmt.Errorf("failed to find %s in %s response", string(EmailType), string(EmailSet)) + err = fmt.Errorf("failed to find %s in %s response", string(EmailType), string(CommandEmailSet)) return CreatedEmail{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } @@ -606,7 +606,7 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, aid := session.MailAccountId(accountId) cmd, err := request( - invocation(EmailSet, EmailSetCommand{ + invocation(CommandEmailSet, EmailSetCommand{ AccountId: aid, Update: updates, }, "0"), @@ -617,7 +617,7 @@ func (j *Client) UpdateEmails(accountId string, updates map[string]EmailUpdate, return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UpdatedEmails, Error) { var setResponse EmailSetResponse - err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + err = retrieveResponseMatchParameters(body, CommandEmailSet, "0", &setResponse) if err != nil { return UpdatedEmails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -642,7 +642,7 @@ func (j *Client) DeleteEmails(accountId string, destroy []string, session *Sessi aid := session.MailAccountId(accountId) cmd, err := request( - invocation(EmailSet, EmailSetCommand{ + invocation(CommandEmailSet, EmailSetCommand{ AccountId: aid, Destroy: destroy, }, "0"), @@ -653,7 +653,7 @@ func (j *Client) DeleteEmails(accountId string, destroy []string, session *Sessi return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (DeletedEmails, Error) { var setResponse EmailSetResponse - err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + err = retrieveResponseMatchParameters(body, CommandEmailSet, "0", &setResponse) if err != nil { return DeletedEmails{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -714,14 +714,14 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string AccountId: aid, IdRef: &ResultReference{ ResultOf: "0", - Name: EmailSubmissionSet, + Name: CommandEmailSubmissionSet, Path: "/created/s0/id", }, } cmd, err := request( - invocation(EmailSubmissionSet, set, "0"), - invocation(EmailSubmissionGet, get, "1"), + invocation(CommandEmailSubmissionSet, set, "0"), + invocation(CommandEmailSubmissionGet, get, "1"), ) if err != nil { return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} @@ -729,7 +729,7 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (SubmittedEmail, Error) { var submissionResponse EmailSubmissionSetResponse - err = retrieveResponseMatchParameters(body, EmailSubmissionSet, "0", &submissionResponse) + err = retrieveResponseMatchParameters(body, CommandEmailSubmissionSet, "0", &submissionResponse) if err != nil { return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } @@ -745,13 +745,13 @@ func (j *Client) SubmitEmail(accountId string, identityId string, emailId string // The response to this MUST be returned after the EmailSubmission/set response." // from an example in the spec, it has the same tag as the EmailSubmission/set command ("0" in this case) var setResponse EmailSetResponse - err = retrieveResponseMatchParameters(body, EmailSet, "0", &setResponse) + err = retrieveResponseMatchParameters(body, CommandEmailSet, "0", &setResponse) if err != nil { return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } var getResponse EmailSubmissionGetResponse - err = retrieveResponseMatchParameters(body, EmailSubmissionGet, "1", &getResponse) + err = retrieveResponseMatchParameters(body, CommandEmailSubmissionGet, "1", &getResponse) if err != nil { return SubmittedEmail{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go index 17ebcf526c..fbc2a5ca46 100644 --- a/pkg/jmap/jmap_api_identity.go +++ b/pkg/jmap/jmap_api_identity.go @@ -19,13 +19,13 @@ type Identities struct { func (j *Client) GetIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger) (Identities, Error) { aid := session.MailAccountId(accountId) logger = j.logger(aid, "GetIdentity", session, logger) - cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: aid}, "0")) + cmd, err := request(invocation(CommandIdentityGet, IdentityGetCommand{AccountId: aid}, "0")) if err != nil { return Identities{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Identities, Error) { var response IdentityGetResponse - err = retrieveResponseMatchParameters(body, IdentityGet, "0", &response) + err = retrieveResponseMatchParameters(body, CommandIdentityGet, "0", &response) return Identities{ Identities: response.List, State: response.State, @@ -50,7 +50,7 @@ func (j *Client) GetIdentities(accountIds []string, session *Session, ctx contex calls := make([]Invocation, len(uniqueAccountIds)) for i, accountId := range uniqueAccountIds { - calls[i] = invocation(IdentityGet, IdentityGetCommand{AccountId: accountId}, strconv.Itoa(i)) + calls[i] = invocation(CommandIdentityGet, IdentityGetCommand{AccountId: accountId}, strconv.Itoa(i)) } cmd, err := request(calls...) @@ -63,7 +63,7 @@ func (j *Client) GetIdentities(accountIds []string, session *Session, ctx contex notFound := []string{} for i, accountId := range uniqueAccountIds { var response IdentityGetResponse - err = retrieveResponseMatchParameters(body, IdentityGet, strconv.Itoa(i), &response) + err = retrieveResponseMatchParameters(body, CommandIdentityGet, strconv.Itoa(i), &response) if err != nil { return IdentitiesGetResponse{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } else { diff --git a/pkg/jmap/jmap_api_mailbox.go b/pkg/jmap/jmap_api_mailbox.go index 58ec89cde8..5adef17aab 100644 --- a/pkg/jmap/jmap_api_mailbox.go +++ b/pkg/jmap/jmap_api_mailbox.go @@ -17,14 +17,14 @@ type MailboxesResponse struct { func (j *Client) GetMailbox(accountId string, session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxesResponse, Error) { aid := session.MailAccountId(accountId) logger = j.logger(aid, "GetMailbox", session, logger) - cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0")) + cmd, err := request(invocation(CommandMailboxGet, MailboxGetCommand{AccountId: aid, Ids: ids}, "0")) if err != nil { return MailboxesResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (MailboxesResponse, Error) { var response MailboxGetResponse - err = retrieveResponseMatchParameters(body, MailboxGet, "0", &response) + err = retrieveResponseMatchParameters(body, CommandMailboxGet, "0", &response) if err != nil { return MailboxesResponse{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } @@ -70,10 +70,10 @@ func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context logger = j.logger(aid, "SearchMailboxes", session, logger) cmd, err := request( - invocation(MailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"), - invocation(MailboxGet, MailboxGetRefCommand{ + invocation(CommandMailboxQuery, MailboxQueryCommand{AccountId: aid, Filter: filter}, "0"), + invocation(CommandMailboxGet, MailboxGetRefCommand{ AccountId: aid, - IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"}, + IdRef: &ResultReference{Name: CommandMailboxQuery, Path: "/ids/*", ResultOf: "0"}, }, "1"), ) if err != nil { @@ -82,7 +82,7 @@ func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (Mailboxes, Error) { var response MailboxGetResponse - err = retrieveResponseMatchParameters(body, MailboxGet, "1", &response) + err = retrieveResponseMatchParameters(body, CommandMailboxGet, "1", &response) if err != nil { return Mailboxes{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err} } diff --git a/pkg/jmap/jmap_api_vacation.go b/pkg/jmap/jmap_api_vacation.go index fe020cfaf6..09c0fe5c17 100644 --- a/pkg/jmap/jmap_api_vacation.go +++ b/pkg/jmap/jmap_api_vacation.go @@ -16,13 +16,13 @@ const ( func (j *Client) GetVacationResponse(accountId string, session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) { aid := session.MailAccountId(accountId) logger = j.logger(aid, "GetVacationResponse", session, logger) - cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0")) + cmd, err := request(invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "0")) if err != nil { return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseGetResponse, Error) { var response VacationResponseGetResponse - err = retrieveResponseMatchParameters(body, VacationResponseGet, "0", &response) + err = retrieveResponseMatchParameters(body, CommandVacationResponseGet, "0", &response) return response, simpleError(err, JmapErrorInvalidJmapResponsePayload) }) } @@ -62,7 +62,7 @@ func (j *Client) SetVacationResponse(accountId string, vacation VacationResponse logger = j.logger(aid, "SetVacationResponse", session, logger) cmd, err := request( - invocation(VacationResponseSet, VacationResponseSetCommand{ + invocation(CommandVacationResponseSet, VacationResponseSetCommand{ AccountId: aid, Create: map[string]VacationResponse{ vacationResponseId: { @@ -77,14 +77,14 @@ func (j *Client) SetVacationResponse(accountId string, vacation VacationResponse }, "0"), // chain a second request to get the current complete VacationResponse object // after performing the changes, as that makes for a better API - invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "1"), + invocation(CommandVacationResponseGet, VacationResponseGetCommand{AccountId: aid}, "1"), ) if err != nil { return VacationResponseChange{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (VacationResponseChange, Error) { var setResponse VacationResponseSetResponse - err = retrieveResponseMatchParameters(body, VacationResponseSet, "0", &setResponse) + err = retrieveResponseMatchParameters(body, CommandVacationResponseSet, "0", &setResponse) if err != nil { return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } @@ -96,13 +96,13 @@ func (j *Client) SetVacationResponse(accountId string, vacation VacationResponse } var getResponse VacationResponseGetResponse - err = retrieveResponseMatchParameters(body, VacationResponseGet, "1", &getResponse) + err = retrieveResponseMatchParameters(body, CommandVacationResponseGet, "1", &getResponse) if err != nil { return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } if len(getResponse.List) != 1 { - err = fmt.Errorf("failed to find %s in %s response", string(VacationResponseType), string(VacationResponseGet)) + err = fmt.Errorf("failed to find %s in %s response", string(VacationResponseType), string(CommandVacationResponseGet)) return VacationResponseChange{}, simpleError(err, JmapErrorInvalidJmapResponsePayload) } diff --git a/pkg/jmap/jmap_client.go b/pkg/jmap/jmap_client.go index 8b8c83c7a0..a7a3cc260b 100644 --- a/pkg/jmap/jmap_client.go +++ b/pkg/jmap/jmap_client.go @@ -60,5 +60,8 @@ func (j *Client) loggerParams(accountId string, operation string, session *Sessi if accountId != "" { l = l.Str(logAccountId, accountId) } + if params != nil { + l = params(l) + } return log.From(l) } diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 8e349fad46..dbadc44356 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -30,36 +30,185 @@ const ( ) type SessionMailAccountCapabilities struct { - MaxMailboxesPerEmail int `json:"maxMailboxesPerEmail"` - MaxMailboxDepth int `json:"maxMailboxDepth"` - MaxSizeMailboxName int `json:"maxSizeMailboxName"` - MaxSizeAttachmentsPerEmail int `json:"maxSizeAttachmentsPerEmail"` - EmailQuerySortOptions []string `json:"emailQuerySortOptions"` - MayCreateTopLevelMailbox bool `json:"mayCreateTopLevelMailbox"` + // The maximum number of Mailboxes that can be can assigned to a single Email object. + // + // This MUST be an integer >= 1, or null for no limit (or rather, the limit is always + // the number of Mailboxes in the account). + MaxMailboxesPerEmail int `json:"maxMailboxesPerEmail"` + + // The maximum depth of the Mailbox hierarchy (i.e., one more than the maximum + // number of ancestors a Mailbox may have), or null for no limit. + MaxMailboxDepth int `json:"maxMailboxDepth"` + + // The maximum length, in (UTF-8) octets, allowed for the name of a Mailbox. + // + // This MUST be at least 100, although it is recommended servers allow more. + MaxSizeMailboxName int `json:"maxSizeMailboxName"` + + // The maximum total size of attachments, in octets, allowed for a single Email object. + // + // A server MAY still reject the import or creation of an Email with a lower attachment size + // total (for example, if the body includes several megabytes of text, causing the size of + // the encoded MIME structure to be over some server-defined limit). + // + // Note that this limit is for the sum of unencoded attachment sizes. Users are generally + // not knowledgeable about encoding overhead, etc., nor should they need to be, so marketing + // and help materials normally tell them the “max size attachments”. + // + // This is the unencoded size they see on their hard drive, so this capability matches that + // and allows the client to consistently enforce what the user understands as the limit. + // + // The server may separately have a limit for the total size of the message [RFC5322], + // created by combining the attachments (often base64 encoded) with the message headers and bodies. + // + // For example, suppose the server advertises maxSizeAttachmentsPerEmail: 50000000 (50 MB). + // The enforced server limit may be for a message size of 70000000 octets. + // Even with base64 encoding and a 2 MB HTML body, 50 MB attachments would fit under this limit. + // + // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + MaxSizeAttachmentsPerEmail int `json:"maxSizeAttachmentsPerEmail"` + + // A list of all the values the server supports for the “property” field of the Comparator + // object in an Email/query sort. + // + // This MAY include properties the client does not recognise (for example, custom properties + // specified in a vendor extension). Clients MUST ignore any unknown properties in the list. + EmailQuerySortOptions []string `json:"emailQuerySortOptions"` + + // If true, the user may create a Mailbox in this account with a null parentId. + // + // (Permission for creating a child of an existing Mailbox is given by the myRights property + // on that Mailbox.) + MayCreateTopLevelMailbox bool `json:"mayCreateTopLevelMailbox"` } type SessionSubmissionAccountCapabilities struct { - MaxDelayedSend int `json:"maxDelayedSend"` + // The number in seconds of the maximum delay the server supports in sending. + // + // This is 0 if the server does not support delayed send. + MaxDelayedSend int `json:"maxDelayedSend"` + + // The set of SMTP submission extensions supported by the server, which the client may use + // when creating an EmailSubmission object. + // + // Each key in the object is the ehlo-name, and the value is a list of ehlo-args. + // + // A JMAP implementation that talks to a submission server [RFC6409] SHOULD have a configuration + // setting that allows an administrator to modify the set of submission EHLO capabilities it may + // expose on this property. + // + // This allows a JMAP server to easily add access to a new submission extension without code changes. + // + // By default, the JMAP server should hide EHLO capabilities that have to do with the transport + // mechanism and thus are only relevant to the JMAP server (for example, PIPELINING, CHUNKING, or STARTTLS). + // + // Examples of Submission extensions to include: + // - FUTURERELEASE [RFC4865] + // - SIZE [RFC1870] + // - DSN [RFC3461] + // - DELIVERYBY [RFC2852] + // - MT-PRIORITY [RFC6710] + // + // A JMAP server MAY advertise an extension and implement the semantics of that extension locally + // on the JMAP server even if a submission server used by JMAP doesn’t implement it. + // + // The full IANA registry of submission extensions can be found at [iana.org]. + // + // [RFC6409]: https://www.rfc-editor.org/rfc/rfc6409.html + // [RFC4865]: https://www.rfc-editor.org/rfc/rfc4865.html + // [RFC1870]: https://www.rfc-editor.org/rfc/rfc1870.html + // [RFC3461]: https://www.rfc-editor.org/rfc/rfc3461.html + // [RFC2852]: https://www.rfc-editor.org/rfc/rfc2852.html + // [RFC6710]: https://www.rfc-editor.org/rfc/rfc6710.html + // [iana.org]: https://www.iana.org/assignments/mail-parameters SubmissionExtensions map[string][]string `json:"submissionExtensions"` } +// This represents support for the VacationResponse data type and associated API methods. +// +// The value of this property is an empty object in both the JMAP session capabilities +// property and an account’s accountCapabilities property. type SessionVacationResponseAccountCapabilities struct { } type SessionSieveAccountCapabilities struct { - MaxSizeScriptName int `json:"maxSizeScriptName"` - MaxSizeScript int `json:"maxSizeScript"` - MaxNumberScripts int `json:"maxNumberScripts"` - MaxNumberRedirects int `json:"maxNumberRedirects"` - SieveExtensions []string `json:"sieveExtensions"` + // The maximum length, in octets, allowed for the name of a SieveScript. + // + // For compatibility with ManageSieve, this MUST be at least 512 (up to 128 Unicode characters). + MaxSizeScriptName int `json:"maxSizeScriptName"` + + // The maximum size (in octets) of a Sieve script the server is willing to store for the user, + // or null for no limit. + MaxSizeScript int `json:"maxSizeScript"` + + // The maximum number of Sieve scripts the server is willing to store for the user, or null for no limit. + MaxNumberScripts int `json:"maxNumberScripts"` + + // The maximum number of Sieve "redirect" actions a script can perform during a single evaluation, + // or null for no limit. + // + // Note that this is different from the total number of "redirect" actions a script can contain. + MaxNumberRedirects int `json:"maxNumberRedirects"` + + // A list of case-sensitive Sieve capability strings (as listed in the Sieve "require" action; + // see [RFC5228, Section 3.2]) indicating the extensions supported by the Sieve engine. + // + // [RFC5228, Section 3.2]: https://www.rfc-editor.org/rfc/rfc5228.html#section-3.2 + SieveExtensions []string `json:"sieveExtensions"` + + // A list of URI scheme parts [RFC3986] for notification methods supported by the Sieve "enotify" + // extension [RFC5435], or null if the extension is not supported by the Sieve engine. + // + // [RFC3986]: https://www.rfc-editor.org/rfc/rfc3986.html + // [RFC5435]: https://www.rfc-editor.org/rfc/rfc5435.html NotificationMethods []string `json:"notificationMethods"` - ExternalLists any `json:"externalLists"` // ? + + // A list of URI scheme parts [RFC3986] for externally stored list types supported by the + // Sieve "extlists" extension [RFC6134], or null if the extension is not supported by the Sieve engine. + // + // [RFC3986]: https://www.rfc-editor.org/rfc/rfc3986.html + // [RFC6134]: https://www.rfc-editor.org/rfc/rfc6134.html + ExternalLists []string `json:"externalLists"` } type SessionBlobAccountCapabilities struct { - MaxSizeBlobSet int `json:"maxSizeBlobSet"` - MaxDataSources int `json:"maxDataSources"` - SupportedTypeNames []string `json:"supportedTypeNames"` + // The maximum size of the blob (in octets) that the server will allow to be created + // (including blobs created by concatenating multiple data sources together). + // + // Clients MUST NOT attempt to create blobs larger than this size. + // + // If this value is null, then clients are not required to limit the size of the blob + // they try to create, though servers can always reject creation of blobs regardless of + // size, e.g., due to lack of disk space or per-user rate limits. + MaxSizeBlobSet int `json:"maxSizeBlobSet"` + + // The maximum number of DataSourceObjects allowed per creation in a Blob/upload. + // + // Servers MUST allow at least 64 DataSourceObjects per creation. + MaxDataSources int `json:"maxDataSources"` + + // An array of data type names that are supported for Blob/lookup. + // + // If the server does not support lookups, then this will be the empty list. + // + // Note that the supportedTypeNames list may include private types that are not in the + // "JMAP Data Types" registry defined by this document. + // + // Clients MUST ignore type names they do not recognise. + SupportedTypeNames []string `json:"supportedTypeNames"` + + // An array of supported digest algorithms that are supported for Blob/get. + // + // If the server does not support calculating blob digests, then this will be the empty + // list. + // + // Algorithms in this list MUST be present in the ["HTTP Digest Algorithm Values" registry] + // defined by [RFC3230]; however, in JMAP, they must be lowercased, e.g., "md5" rather than + // "MD5". + // + // Clients SHOULD prefer algorithms listed earlier in this list. + // + // ["HTTP Digest Algorithm Values" registry]: https://www.iana.org/assignments/http-dig-alg/http-dig-alg.xhtml SupportedDigestAlgorithms []string `json:"supportedDigestAlgorithms"` } @@ -126,8 +275,17 @@ type SessionQuotaCapabilities struct { } type SessionWebsocketCapabilities struct { - Url string `json:"url"` - SupportsPush bool `json:"supportsPush"` + // The wss-URI (see [Section 3 of RFC6455]) to use for initiating a JMAP-over-WebSocket + // handshake (the "WebSocket URL endpoint" colloquially). + // + // [Section 3 of RFC6455]: https://www.rfc-editor.org/rfc/rfc6455.html#section-3 + Url string `json:"url"` + + // This is true if the server supports push notifications over the WebSocket, + // as described in [Section 4.3.5 of RFC 8887]. + // + // [Section 4.3.5 of RFC 8887]: https://www.rfc-editor.org/rfc/rfc8887.html#name-jmap-push-notifications + SupportsPush bool `json:"supportsPush"` } type SessionCapabilities struct { @@ -153,27 +311,35 @@ type SessionPrimaryAccounts struct { } type SessionResponse struct { - Capabilities SessionCapabilities `json:"capabilities"` - Accounts map[string]SessionAccount `json:"accounts,omitempty"` + Capabilities SessionCapabilities `json:"capabilities"` + + Accounts map[string]SessionAccount `json:"accounts,omitempty"` + // A map of capability URIs (as found in accountCapabilities) to the account id that is considered to be the user’s main or default // account for data pertaining to that capability. // If no account being returned belongs to the user, or in any other way there is no appropriate way to determine a default account, // there MAY be no entry for a particular URI, even though that capability is supported by the server (and in the capabilities object). // urn:ietf:params:jmap:core SHOULD NOT be present. PrimaryAccounts SessionPrimaryAccounts `json:"primaryAccounts"` + // The username associated with the given credentials, or the empty string if none. Username string `json:"username,omitempty"` + // The URL to use for JMAP API requests. ApiUrl string `json:"apiUrl,omitempty"` + // The URL endpoint to use when downloading files, in URI Template (level 1) format [@!RFC6570]. // The URL MUST contain variables called accountId, blobId, type, and name. DownloadUrl string `json:"downloadUrl,omitempty"` + // The URL endpoint to use when uploading files, in URI Template (level 1) format [@!RFC6570]. // The URL MUST contain a variable called accountId. UploadUrl string `json:"uploadUrl,omitempty"` + // The URL to connect to for push events, as described in Section 7.3, in URI Template (level 1) format [@!RFC6570]. // The URL MUST contain variables called types, closeafter, and ping. EventSourceUrl string `json:"eventSourceUrl,omitempty"` + // A (preferably short) string representing the state of this object on the server. // If the value of any other property on the Session object changes, this string will change. // The current value is also returned on the API Response object (see Section 3.4), allowing clients to quickly @@ -184,31 +350,45 @@ type SessionResponse struct { // SetError type values. const ( - // (create; update; destroy). The create/update/destroy would violate an ACL or other permissions policy. + // The create/update/destroy would violate an ACL or other permissions policy. + // + // (create; update; destroy). SetErrorTypeForbidden = "forbidden" - // (create; update). The create would exceed a server-defined limit on the number or total size of objects of this type. + // The create would exceed a server-defined limit on the number or total size of objects of this type. + // + // (create; update). SetErrorTypeOverQuota = "overQuota" - // (create; update). The create/update would result in an object that exceeds a server-defined limit for the maximum + // The create/update would result in an object that exceeds a server-defined limit for the maximum // size of a single object of this type. + // + // (create; update). SetErrorTypeTooLarge = "tooLarge" - // (create). Too many objects of this type have been created recently, and a server-defined rate limit has been reached. + // Too many objects of this type have been created recently, and a server-defined rate limit has been reached. // It may work if tried again later. + // + // (create). SetErrorTypeRateLimit = "rateLimit" - // (update; destroy). The id given to update/destroy cannot be found. + // The id given to update/destroy cannot be found. + // + // (update; destroy). SetErrorTypeNotFound = "notFound" - // (update) The PatchObject given to update the record was not a valid patch (see the patch description). + // The PatchObject given to update the record was not a valid patch (see the patch description). + // + // (update). SetErrorTypeInvalidPatch = "invalidPatch" - // (update). The client requested that an object be both updated and destroyed in the same /set request, and the server + // The client requested that an object be both updated and destroyed in the same /set request, and the server // has decided to therefore ignore the update. + // + // (update). SetErrorTypeWillDestroy = "willDestroy" - // (create; update). The record given is invalid in some way. For example: + // The record given is invalid in some way. For example: // // - It contains properties that are invalid according to the type specification of this record type. // - It contains a property that may only be set by the server (e.g., “id”) and is different to the current value. @@ -221,9 +401,13 @@ const ( // // Individual methods MAY specify more specific errors for certain conditions that would otherwise result in an // invalidProperties error. If the condition of one of these is met, it MUST be returned instead of the invalidProperties error. + // + // (create; update). SetErrorTypeInvalidProperties = "invalidProperties" - // (create; destroy). This is a singleton type, so you cannot create another one or destroy the existing one. + // This is a singleton type, so you cannot create another one or destroy the existing one. + // + // (create; destroy). SetErrorTypeSingleton = "singleton" // The total number of objects to create, update, or destroy exceeds the maximum number the server is @@ -400,7 +584,9 @@ type Mailbox struct { // [RFC8457]: https://www.rfc-editor.org/rfc/rfc8457.html Role string `json:"role,omitempty"` - // (default: 0) Defines the sort order of Mailboxes when presented in the client’s UI, so it is consistent between devices. + // Defines the sort order of Mailboxes when presented in the client’s UI, so it is consistent between devices. + // + // Default value: 0 // // The number MUST be an integer in the range 0 <= sortOrder < 2^31. // @@ -462,9 +648,26 @@ type MailboxGetRefCommand struct { } type MailboxChangesCommand struct { - AccountId string `json:"accountId"` + // The id of the account to use. + AccountId string `json:"accountId"` + + // The current state of the client. + // + // This is the string that was returned as the state argument in the Mailbox/get response. + // + // The server will return the changes that have occurred since this state. SinceState string `json:"sinceState,omitempty"` - MaxChanges int `json:"maxChanges,omitzero"` + + // The maximum number of ids to return in the response. + // + // The server MAY choose to return fewer than this value but MUST NOT return more. + // + // If not given by the client, the server may choose how many to return. + // + // If supplied by the client, the value MUST be a positive integer greater than 0. + // + // If a value outside of this range is given, the server MUST reject the call with an invalidArguments error. + MaxChanges int `json:"maxChanges,omitzero"` } type MailboxFilterElement interface { @@ -507,34 +710,172 @@ type MailboxQueryCommand struct { type EmailFilterElement interface { _isAnEmailFilterElement() // marker method + IsNotEmpty() bool } type EmailFilterCondition struct { - InMailbox string `json:"inMailbox,omitempty"` - InMailboxOtherThan []string `json:"inMailboxOtherThan,omitempty"` - Before time.Time `json:"before,omitzero"` // omitzero requires Go 1.24 - After time.Time `json:"after,omitzero"` - MinSize int `json:"minSize,omitempty"` - MaxSize int `json:"maxSize,omitempty"` - AllInThreadHaveKeyword string `json:"allInThreadHaveKeyword,omitempty"` - SomeInThreadHaveKeyword string `json:"someInThreadHaveKeyword,omitempty"` - NoneInThreadHaveKeyword string `json:"noneInThreadHaveKeyword,omitempty"` - HasKeyword string `json:"hasKeyword,omitempty"` - NotKeyword string `json:"notKeyword,omitempty"` - HasAttachment bool `json:"hasAttachment,omitempty"` - Text string `json:"text,omitempty"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Cc string `json:"cc,omitempty"` - Bcc string `json:"bcc,omitempty"` - Subject string `json:"subject,omitempty"` - Body string `json:"body,omitempty"` - Header []string `json:"header,omitempty"` + // A Mailbox id. + // + // An Email must be in this Mailbox to match the condition. + InMailbox string `json:"inMailbox,omitempty"` + + // A list of Mailbox ids. + // + // An Email must be in at least one Mailbox not in this list to match the condition. + // + // This is to allow messages solely in trash/spam to be easily excluded from a search. + InMailboxOtherThan []string `json:"inMailboxOtherThan,omitempty"` + + // The receivedAt date-time of the Email must be before this date-time to match + // the condition. + Before time.Time `json:"before,omitzero"` // omitzero requires Go 1.24 + + // The receivedAt date-time of the Email must be the same or after this date-time + // to match the condition. + After time.Time `json:"after,omitzero"` + + // The size property of the Email must be equal to or greater than this number to match + // the condition. + MinSize int `json:"minSize,omitempty"` + + // The size property of the Email must be less than this number to match the condition. + MaxSize int `json:"maxSize,omitempty"` + + // All Emails (including this one) in the same Thread as this Email must have the given + // keyword to match the condition. + AllInThreadHaveKeyword string `json:"allInThreadHaveKeyword,omitempty"` + + // At least one Email (possibly this one) in the same Thread as this Email must have the + // given keyword to match the condition. + SomeInThreadHaveKeyword string `json:"someInThreadHaveKeyword,omitempty"` + + // All Emails (including this one) in the same Thread as this Email must not have the + // given keyword to match the condition. + NoneInThreadHaveKeyword string `json:"noneInThreadHaveKeyword,omitempty"` + + // This Email must have the given keyword to match the condition. + HasKeyword string `json:"hasKeyword,omitempty"` + + // This Email must not have the given keyword to match the condition. + NotKeyword string `json:"notKeyword,omitempty"` + + // The hasAttachment property of the Email must be identical to the value given to match + // the condition. + HasAttachment bool `json:"hasAttachment,omitempty"` + + // Looks for the text in Emails. + // + // The server MUST look up text in the From, To, Cc, Bcc, and Subject header fields of the + // message and SHOULD look inside any text/* or other body parts that may be converted to + // text by the server. + // + // The server MAY extend the search to any additional textual property. + Text string `json:"text,omitempty"` + + // Looks for the text in the From header field of the message. + From string `json:"from,omitempty"` + + // Looks for the text in the To header field of the message. + To string `json:"to,omitempty"` + + // Looks for the text in the Cc header field of the message. + Cc string `json:"cc,omitempty"` + + // Looks for the text in the Bcc header field of the message. + Bcc string `json:"bcc,omitempty"` + + // Looks for the text in the Subject header field of the message. + Subject string `json:"subject,omitempty"` + + // Looks for the text in one of the body parts of the message. + // + // The server MAY exclude MIME body parts with content media types other than text/* + // and message/* from consideration in search matching. + // + // Care should be taken to match based on the text content actually presented to an end user + // by viewers for that media type or otherwise identified as appropriate for search indexing. + // + // Matching document metadata uninteresting to an end user (e.g., markup tag and attribute + // names) is undesirable. + Body string `json:"body,omitempty"` + + // The array MUST contain either one or two elements. + // + // The first element is the name of the header field to match against. + // + // The second (optional) element is the text to look for in the header field value. + // + // If not supplied, the message matches simply if it has a header field of the given name. + Header []string `json:"header,omitempty"` } func (f EmailFilterCondition) _isAnEmailFilterElement() { } +func (f EmailFilterCondition) IsNotEmpty() bool { + if !f.After.IsZero() { + return true + } + if f.AllInThreadHaveKeyword != "" { + return true + } + if len(f.Bcc) > 0 { + return true + } + if !f.Before.IsZero() { + return true + } + if f.Body != "" { + return true + } + if f.Cc != "" { + return true + } + if f.From != "" { + return true + } + if f.HasAttachment { + return true + } + if f.HasKeyword != "" { + return true + } + if len(f.Header) > 0 { + return true + } + if f.InMailbox != "" { + return true + } + if len(f.InMailboxOtherThan) > 0 { + return true + } + if f.MaxSize != 0 { + return true + } + if f.MinSize != 0 { + return true + } + if f.NoneInThreadHaveKeyword != "" { + return true + } + if f.NotKeyword != "" { + return true + } + if f.SomeInThreadHaveKeyword != "" { + return true + } + if f.Subject != "" { + return true + } + if f.Text != "" { + return true + } + if f.To != "" { + return true + } + return false +} + var _ EmailFilterElement = &EmailFilterCondition{} type EmailFilterOperator struct { @@ -545,23 +886,117 @@ type EmailFilterOperator struct { func (o EmailFilterOperator) _isAnEmailFilterElement() { } -var _ EmailFilterElement = &EmailFilterOperator{} - -type Sort struct { - Property string `json:"property,omitempty"` - IsAscending bool `json:"isAscending,omitempty"` - Keyword string `json:"keyword,omitempty"` - Collation string `json:"collation,omitempty"` +func (o EmailFilterOperator) IsNotEmpty() bool { + return len(o.Conditions) > 0 } +var _ EmailFilterElement = &EmailFilterOperator{} + +type EmailComparator struct { + // The name of the property on the objects to compare. + Property string `json:"property,omitempty"` + + // If true, sort in ascending order. + // + // Optional; default value: true. + // + // If false, reverse the comparator’s results to sort in descending order. + IsAscending bool `json:"isAscending,omitempty"` + + // The identifier, as registered in the collation registry defined in [RFC4790], + // for the algorithm to use when comparing the order of strings. + // + // Optional; default is server dependent. + // + // The algorithms the server supports are advertised in the capabilities object returned + // with the Session object. + // + // [RFC4790]: https://www.rfc-editor.org/rfc/rfc4790.html + Collation string `json:"collation,omitempty"` + + // Email-specific: keyword that must be included in the Email object. + Keyword string `json:"keyword,omitempty"` +} + +// If an anchor argument is given, the anchor is looked for in the results after filtering +// and sorting. +// +// If found, the anchorOffset is then added to its index. If the resulting index is now negative, +// it is clamped to 0. This index is now used exactly as though it were supplied as the position +// argument. If the anchor is not found, the call is rejected with an anchorNotFound error. +// +// If an anchor is specified, any position argument supplied by the client MUST be ignored. +// If no anchor is supplied, any anchorOffset argument MUST be ignored. +// +// A client can use anchor instead of position to find the index of an id within a large set of results. type EmailQueryCommand struct { - AccountId string `json:"accountId"` - Filter EmailFilterElement `json:"filter,omitempty"` - Sort []Sort `json:"sort,omitempty"` - CollapseThreads bool `json:"collapseThreads,omitempty"` - Position int `json:"position,omitempty"` - Limit int `json:"limit,omitempty"` - CalculateTotal bool `json:"calculateTotal,omitempty"` + // The id of the account to use. + AccountId string `json:"accountId"` + + // Determines the set of Emails returned in the results. + // + // If null, all objects in the account of this type are included in the results. + Filter EmailFilterElement `json:"filter,omitempty"` + + // Lists the names of properties to compare between two Email records, and how to compare + // them, to determine which comes first in the sort. + // + // If two Email records have an identical value for the first comparator, the next comparator + // will be considered, and so on. If all comparators are the same (this includes the case + // where an empty array or null is given as the sort argument), the sort order is server + // dependent, but it MUST be stable between calls to Email/query. + Sort []EmailComparator `json:"sort,omitempty"` + + // If true, Emails in the same Thread as a previous Email in the list (given the + // filter and sort order) will be removed from the list. + // + // This means only one Email at most will be included in the list for any given Thread. + CollapseThreads bool `json:"collapseThreads,omitempty"` + + // The zero-based index of the first id in the full list of results to return. + // + // If a negative value is given, it is an offset from the end of the list. + // Specifically, the negative value MUST be added to the total number of results given + // the filter, and if still negative, it’s clamped to 0. This is now the zero-based + // index of the first id to return. + // + // If the index is greater than or equal to the total number of objects in the results + // list, then the ids array in the response will be empty, but this is not an error. + Position int `json:"position,omitempty"` + + // An Email id. + // + // If supplied, the position argument is ignored. + // The index of this id in the results will be used in combination with the anchorOffset + // argument to determine the index of the first result to return. + Anchor string `json:"anchor,omitempty"` + + // The index of the first result to return relative to the index of the anchor, + // if an anchor is given. + // + // Default: 0. + // + // This MAY be negative. + // + // For example, -1 means the Email immediately preceding the anchor is the first result in + // the list returned. + AnchorOffset int `json:"anchorOffset,omitzero"` + + // The maximum number of results to return. + // + // If null, no limit presumed. + // The server MAY choose to enforce a maximum limit argument. + // In this case, if a greater value is given (or if it is null), the limit is clamped + // to the maximum; the new limit is returned with the response so the client is aware. + // + // If a negative value is given, the call MUST be rejected with an invalidArguments error. + Limit int `json:"limit,omitempty"` + + // Does the client wish to know the total number of results in the query? + // + // This may be slow and expensive for servers to calculate, particularly with complex filters, + // so clients should take care to only request the total when needed. + CalculateTotal bool `json:"calculateTotal,omitempty"` } type EmailGetCommand struct { @@ -605,7 +1040,7 @@ type EmailGetCommand struct { // (default: false) If true, the bodyValues property includes any text/* part in the bodyStructure property. FetchAllBodyValues bool `json:"fetchAllBodyValues,omitzero"` - // (default: 0) If greater than zero, the value property of any EmailBodyValue object returned in bodyValues + // If greater than zero, the value property of any EmailBodyValue object returned in bodyValues // MUST be truncated if necessary so it does not exceed this number of octets in size. // // If 0 (the default), no truncation occurs. @@ -711,7 +1146,7 @@ type EmailGetRefCommand struct { // (default: false) If true, the bodyValues property includes any text/* part in the bodyStructure property. FetchAllBodyValues bool `json:"fetchAllBodyValues,omitzero"` - // (default: 0) If greater than zero, the value property of any EmailBodyValue object returned in bodyValues + // If greater than zero, the value property of any EmailBodyValue object returned in bodyValues // MUST be truncated if necessary so it does not exceed this number of octets in size. // // If 0 (the default), no truncation occurs. @@ -899,20 +1334,30 @@ type EmailBodyValue struct { // probably not be exactly the same as the size property on the corresponding EmailBodyPart. Value string `json:"value,omitempty"` - // (default: false) This is true if malformed sections were found while decoding the charset, + // This is true if malformed sections were found while decoding the charset, // or the charset was unknown, or the content-transfer-encoding was unknown. + // + // Default value is false. IsEncodingProblem bool `json:"isEncodingProblem,omitzero"` - // (default: false) This is true if the value has been truncated. + // This is true if the value has been truncated. + // + // Default value is false. IsTruncated bool `json:"isTruncated,omitzero"` } +// An Email. +// +// swagger:model type Email struct { // The id of the Email object. // // Note that this is the JMAP object id, NOT the Message-ID header field value of the message [RFC5322]. // // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + // + // required: true + // example: eaaaaab Id string `json:"id,omitempty"` // The id representing the raw octets of the message [RFC5322] for this Email. @@ -920,9 +1365,13 @@ type Email struct { // This may be used to download the raw original message or to attach it directly to another Email, etc. // // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + // + // example: cbbrzak0jw3gmtovgtwd1nd1p7p0czjlxx0ejgqws9oucgpuyr9fsayaae BlobId string `json:"blobId,omitempty"` // The id of the Thread to which this Email belongs. + // + // example: b ThreadId string `json:"threadId,omitempty"` // The set of Mailbox ids this Email belongs to. @@ -931,6 +1380,8 @@ type Email struct { // The set is represented as an object, with each key being a Mailbox id. // // The value for each key in the object MUST be true. + // + // example: {"a": true} MailboxIds map[string]bool `json:"mailboxIds,omitempty"` // A set of keywords that apply to the Email. @@ -1128,25 +1579,52 @@ type Envelope struct { type EmailSubmissionUndoStatus string const ( - UndoStatusPending EmailSubmissionUndoStatus = "pending" - UndoStatusFinal EmailSubmissionUndoStatus = "final" + // It may be possible to cancel this submission. + UndoStatusPending EmailSubmissionUndoStatus = "pending" + + // The message has been relayed to at least one recipient in a manner that cannot be recalled. + // It is no longer possible to cancel this submission. + UndoStatusFinal EmailSubmissionUndoStatus = "final" + + // The submission was canceled and will not be delivered to any recipient. UndoStatusCanceled EmailSubmissionUndoStatus = "canceled" ) type DeliveryStatusDelivered string const ( - DeliveredQueued DeliveryStatusDelivered = "queued" - DeliveredYes DeliveryStatusDelivered = "yes" - DeliveredNo DeliveryStatusDelivered = "no" + // The message is in a local mail queue and status will change once it exits the local mail + // queues. + // The smtpReply property may still change. + DeliveredQueued DeliveryStatusDelivered = "queued" + + // The message was successfully delivered to the mail store of the recipient. + // The smtpReply property is final. + DeliveredYes DeliveryStatusDelivered = "yes" + + // Delivery to the recipient permanently failed. + // The smtpReply property is final. + DeliveredNo DeliveryStatusDelivered = "no" + + // The final delivery status is unknown, (e.g., it was relayed to an external machine + // and no further information is available). + // + // The smtpReply property may still change if a DSN arrives. DeliveredUnknown DeliveryStatusDelivered = "unknown" ) type DeliveryStatusDisplayed string const ( + // The display status is unknown. + // + // This is the initial value. DisplayedUnknown DeliveryStatusDisplayed = "unknown" - DisplayedYes DeliveryStatusDisplayed = "yes" + + // The recipient’s system claims the message content has been displayed to the recipient. + // + // Note that there is no guarantee that the recipient has noticed, read, or understood the content. + DisplayedYes DeliveryStatusDisplayed = "yes" ) type DeliveryStatus struct { @@ -1184,7 +1662,7 @@ type DeliveryStatus struct { } type EmailSubmission struct { - // (server-set) The id of the EmailSubmission. + // The id of the EmailSubmission (server-set). Id string `json:"id"` // The id of the Identity to associate with this submission. @@ -1196,7 +1674,7 @@ type EmailSubmission struct { // to a different address. EmailId string `json:"emailId"` - // (server-set) The Thread id of the Email to send. + // The Thread id of the Email to send (server-set). // // This is set by the server to the threadId property of the Email referenced by the emailId. ThreadId string `json:"threadId"` @@ -1213,10 +1691,10 @@ type EmailSubmission struct { // if present, with no parameters for any of them. Envelope *Envelope `json:"envelope,omitempty"` - // (server-set) The date the submission was/will be released for delivery. + // The date the submission was/will be released for delivery (server-set). SendAt time.Time `json:"sendAt,omitzero"` - // (server-set) This represents whether the submission may be canceled. + // This represents whether the submission may be canceled (server-set). // // This is server set on create and MUST be one of the following values: // @@ -1226,7 +1704,7 @@ type EmailSubmission struct { // - canceled: The submission was canceled and will not be delivered to any recipient. UndoStatus EmailSubmissionUndoStatus `json:"undoStatus"` - // (server-set) This represents the delivery status for each of the submission’s recipients, if known. + // This represents the delivery status for each of the submission’s recipients, if known (server-set). // // This property MAY not be supported by all servers, in which case it will remain null. // @@ -1236,16 +1714,16 @@ type EmailSubmission struct { // This value is a map from the email address of each recipient to a DeliveryStatus object. DeliveryStatus map[string]DeliveryStatus `json:"deliveryStatus"` - // (server-set) A list of blob ids for DSNs [RFC3464] received for this submission, - // in order of receipt, oldest first. + // A list of blob ids for DSNs [RFC3464] received for this submission, + // in order of receipt, oldest first (server-set) . // // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received. // // [RFC3464]: https://datatracker.ietf.org/doc/html/rfc3464 DsnBlobIds []string `json:"dsnBlobIds,omitempty"` - // (server-set) A list of blob ids for MDNs [RFC8098] received for this submission, - // in order of receipt, oldest first. + // A list of blob ids for MDNs [RFC8098] received for this submission, + // in order of receipt, oldest first (server-set). // // The blob is the whole MIME message (with a top-level content-type of multipart/report), as received. // @@ -1257,16 +1735,50 @@ type EmailSubmissionGetRefCommand struct { // The id of the account to use. AccountId string `json:"accountId"` + // The ids of the EmailSubmission objects to return. + // + // If null, then all records of the data type are returned, if this is supported for that data + // type and the number of records does not exceed the maxObjectsInGet limit. IdRef *ResultReference `json:"#ids,omitempty"` + // If supplied, only the properties listed in the array are returned for each EmailSubmission object. + // + // If null, all properties of the object are returned. The id property of the object is always returned, + // even if not explicitly requested. If an invalid property is requested, the call MUST be rejected + // with an invalidArguments error. Properties []string `json:"properties,omitempty"` } type EmailSubmissionGetResponse struct { - AccountId string `json:"accountId"` - State string `json:"state"` - List []EmailSubmission `json:"list,omitempty"` - NotFound []string `json:"notFound,omitempty"` + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // A (preferably short) string representing the state on the server for all the data + // of this type in the account (not just the objects returned in this call). + // + // If the data changes, this string MUST change. If the EmailSubmission data is unchanged, + // servers SHOULD return the same state string on subsequent requests for this data type. + // + // When a client receives a response with a different state string to a previous call, + // it MUST either throw away all currently cached objects for the type or call + // EmailSubmission/changes to get the exact changes. + State string `json:"state"` + + // An array of the EmailSubmission objects requested. + // + // This is the empty array if no objects were found or if the ids argument passed in + // was also an empty array. + // + // The results MAY be in a different order to the ids in the request arguments. + // If an identical id is included more than once in the request, the server MUST only + // include it once in either the list or the notFound argument of the response. + List []EmailSubmission `json:"list,omitempty"` + + // This array contains the ids passed to the method for records that do not exist. + // + // The array is empty if all requested ids were found or if the ids argument passed in was + // either null or an empty array. + NotFound []string `json:"notFound,omitempty"` } // Patch Object. @@ -1289,6 +1801,7 @@ type PatchObject map[string]any type EmailSubmissionCreate struct { // The id of the Identity to associate with this submission. IdentityId string `json:"identityId"` + // The id of the Email to send. // // The Email being sent does not have to be a draft, for example, when “redirecting” an existing @@ -1325,11 +1838,28 @@ type CreatedEmailSubmission struct { } type EmailSubmissionSetResponse struct { - AccountId string `json:"accountId"` - OldState string `json:"oldState"` - NewState string `json:"newState"` - Created map[string]CreatedEmailSubmission `json:"created,omitempty"` - NotCreated map[string]SetError `json:"notCreated,omitempty"` + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // This is the sinceState argument echoed back; it’s the state from which the server is returning changes. + OldState string `json:"oldState"` + + // This is the state the client will be in after applying the set of changes to the old state. + NewState string `json:"newState"` + + // If true, the client may call EmailSubmission/changes again with the newState returned to get further + // updates. + // + // If false, newState is the current server state. + HasMoreChanges bool `json:"hasMoreChanges"` + + // An array of ids for records that have been created since the old state. + Created map[string]CreatedEmailSubmission `json:"created,omitempty"` + + // A map of the creation id to a SetError object for each record that failed to be created, or + // null if all successful. + NotCreated map[string]SetError `json:"notCreated,omitempty"` + // TODO(pbleser-oc) add updated and destroyed when they are needed } @@ -1358,15 +1888,20 @@ func invocation(command Command, parameters any, tag string) Invocation { type Request struct { // The set of capabilities the client wishes to use. + // // The client MAY include capability identifiers even if the method calls it makes do not utilise those capabilities. - // The server advertises the set of specifications it supports in the Session object (see [Section 2]), as keys on the capabilities property. + // The server advertises the set of specifications it supports in the Session object (see [Section 2]), as keys on + // the capabilities property. // // [Section 2]: https://jmap.io/spec-core.html#the-jmap-session-resource Using []string `json:"using"` + // An array of method calls to process on the server. + // // The method calls MUST be processed sequentially, in order. MethodCalls []Invocation `json:"methodCalls"` - // (optional) A map of a (client-specified) creation id to the id the server assigned when a record was successfully created. + + // A map of a (client-specified) creation id to the id the server assigned when a record was successfully created (optional). CreatedIds map[string]string `json:"createdIds,omitempty"` } @@ -1382,11 +1917,15 @@ type Response struct { // An array of responses, in the same format as the methodCalls on the Request object. // The output of the methods MUST be added to the methodResponses array in the same order that the methods are processed. MethodResponses []Invocation `json:"methodResponses"` - // (optional; only returned if given in the request) A map of a (client-specified) creation id to the id the server - // assigned when a record was successfully created. + + // A map of a (client-specified) creation id to the id the server assigned when a record was successfully created. + // + // Optional; only returned if given in the request. + // // This MUST include all creation ids passed in the original createdIds parameter of the Request object, as well as any // additional ones added for newly created records. CreatedIds map[string]string `json:"createdIds,omitempty"` + // The current value of the “state” string on the Session object, as described in [Section 2]. // Clients may use this to detect if this object has changed and needs to be refetched. // @@ -1397,33 +1936,49 @@ type Response struct { type EmailQueryResponse struct { // The id of the account used for the call. AccountId string `json:"accountId"` + // A string encoding the current state of the query on the server. + // // This string MUST change if the results of the query (i.e., the matching ids and their sort order) have changed. // The queryState string MAY change if something has changed on the server, which means the results may have changed // but the server doesn’t know for sure. + // // The queryState string only represents the ordered list of ids that match the particular query (including its sort/filter). // There is no requirement for it to change if a property on an object matching the query changes but the query results are unaffected // (indeed, it is more efficient if the queryState string does not change in this case). + // // The queryState string only has meaning when compared to future responses to a query with the same type/sort/filter or when used with // /queryChanges to fetch changes. + // // Should a client receive back a response with a different queryState string to a previous call, it MUST either throw away the currently // cached query and fetch it again (note, this does not require fetching the records again, just the list of ids) or call // Email/queryChanges to get the difference. QueryState string `json:"queryState"` + // This is true if the server supports calling Email/queryChanges with these filter/sort parameters. + // // Note, this does not guarantee that the Email/queryChanges call will succeed, as it may only be possible for a limited time // afterwards due to server internal implementation details. CanCalculateChanges bool `json:"canCalculateChanges"` + // The zero-based index of the first result in the ids array within the complete list of query results. Position int `json:"position"` + // The list of ids for each Email in the query results, starting at the index given by the position argument of this // response and continuing until it hits the end of the results or reaches the limit number of ids. + // // If position is >= total, this MUST be the empty list. Ids []string `json:"ids"` - // (only if requested) The total number of Emails in the results (given the filter). + + // The total number of Emails in the results (given the filter). + // + // Only if requested. + // // This argument MUST be omitted if the calculateTotal request argument is not true. Total int `json:"total,omitempty,omitzero"` - // (if set by the server) The limit enforced by the server on the maximum number of results to return. + + // The limit enforced by the server on the maximum number of results to return (if set by the server). + // // This is only returned if the server set a limit or used a different limit than that given in the request. Limit int `json:"limit,omitempty,omitzero"` } @@ -1431,18 +1986,26 @@ type EmailQueryResponse struct { type EmailGetResponse struct { // The id of the account used for the call. AccountId string `json:"accountId"` + // A (preferably short) string representing the state on the server for all the data of this type // in the account (not just the objects returned in this call). + // // If the data changes, this string MUST change. // If the Email data is unchanged, servers SHOULD return the same state string on subsequent requests for this data type. State string `json:"state"` + // An array of the Email objects requested. + // // This is the empty array if no objects were found or if the ids argument passed in was also an empty array. + // // The results MAY be in a different order to the ids in the request arguments. + // // If an identical id is included more than once in the request, the server MUST only include it once in either // the list or the notFound argument of the response. List []Email `json:"list"` + // This array contains the ids passed to the method for records that do not exist. + // // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array. NotFound []any `json:"notFound"` } @@ -1450,17 +2013,23 @@ type EmailGetResponse struct { type EmailChangesResponse struct { // The id of the account used for the call. AccountId string `json:"accountId"` + // This is the sinceState argument echoed back; it’s the state from which the server is returning changes. OldState string `json:"oldState"` + // This is the state the client will be in after applying the set of changes to the old state. NewState string `json:"newState"` + // If true, the client may call Email/changes again with the newState returned to get further updates. // If false, newState is the current server state. HasMoreChanges bool `json:"hasMoreChanges"` + // An array of ids for records that have been created since the old state. Created []string `json:"created,omitempty"` + // An array of ids for records that have been updated since the old state. Updated []string `json:"updated,omitempty"` + // An array of ids for records that have been destroyed since the old state. Destroyed []string `json:"destroyed,omitempty"` } @@ -1468,6 +2037,7 @@ type EmailChangesResponse struct { type MailboxGetResponse struct { // The id of the account used for the call. AccountId string `json:"accountId"` + // A (preferably short) string representing the state on the server for all the data of this type in the account // (not just the objects returned in this call). // If the data changes, this string MUST change. @@ -1475,12 +2045,14 @@ type MailboxGetResponse struct { // When a client receives a response with a different state string to a previous call, it MUST either throw away all currently // cached objects for the type or call Foo/changes to get the exact changes. State string `json:"state"` + // An array of the Mailbox objects requested. // This is the empty array if no objects were found or if the ids argument passed in was also an empty array. // The results MAY be in a different order to the ids in the request arguments. // If an identical id is included more than once in the request, the server MUST only include it once in either // the list or the notFound argument of the response. List []Mailbox `json:"list"` + // This array contains the ids passed to the method for records that do not exist. // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array. NotFound []any `json:"notFound"` @@ -1553,12 +2125,12 @@ type MailboxQueryResponse struct { // If position is >= total, this MUST be the empty list. Ids []string `json:"ids"` - // (only if requested) The total number of Mailbox in the results (given the filter). + // The total number of Mailbox in the results (given the filter) (only if requested). // // This argument MUST be omitted if the calculateTotal request argument is not true. Total int `json:"total,omitzero"` - // (if set by the server) The limit enforced by the server on the maximum number of results to return. + // The limit enforced by the server on the maximum number of results to return (if set by the server). // // This is only returned if the server set a limit or used a different limit than that given in the request. Limit int `json:"limit,omitzero"` @@ -1634,14 +2206,49 @@ type EmailSetCommand struct { } type EmailSetResponse struct { - AccountId string `json:"accountId"` - OldState string `json:"oldState,omitempty"` - NewState string `json:"newState"` - Created map[string]Email `json:"created,omitempty"` - Updated map[string]Email `json:"updated,omitempty"` - Destroyed []string `json:"destroyed,omitempty"` - NotCreated map[string]SetError `json:"notCreated,omitempty"` - NotUpdated map[string]SetError `json:"notUpdated,omitempty"` + // The id of the account used for the call. + AccountId string `json:"accountId"` + + // The state string that would have been returned by Email/get before making the + // requested changes, or null if the server doesn’t know what the previous state + // string was. + OldState string `json:"oldState,omitempty"` + + // The state string that will now be returned by Email/get. + NewState string `json:"newState"` + + // A map of the creation id to an object containing any properties of the created Email object + // that were not sent by the client. + // + // This includes all server-set properties (such as the id in most object types) and any properties + // 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"` + + // The keys in this map are the ids of all Emails that were successfully updated. + // + // The value for each id is an Email object containing any property that changed in a way not + // explicitly requested by the PatchObject sent to the server, or null if none. + // + // 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"` + + // A list of Email ids for records that were successfully destroyed, or null if none. + Destroyed []string `json:"destroyed,omitempty"` + + // A map of the creation id to a SetError object for each record that failed to be created, + // or null if all successful. + NotCreated map[string]SetError `json:"notCreated,omitempty"` + + // A map of the Email id to a SetError object for each record that failed to be updated, + // or null if all successful. + NotUpdated map[string]SetError `json:"notUpdated,omitempty"` + + // A map of the Email id to a SetError object for each record that failed to be destroyed, + // or null if all successful. NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"` } @@ -1649,36 +2256,91 @@ const ( EmailMimeType = "message/rfc822" ) -type EmailToImport struct { - BlobId string `json:"blobId"` +type EmailImport struct { + // The id of the blob containing the raw message [RFC5322]. + // + // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html + BlobId string `json:"blobId"` + + // The ids of the Mailboxes to assign this Email to. + // + // At least one Mailbox MUST be given. MailboxIds map[string]bool `json:"mailboxIds"` - Keywords map[string]bool `json:"keywords"` - ReceivedAt time.Time `json:"receivedAt"` + + // The keywords to apply to the Email. + Keywords map[string]bool `json:"keywords"` + + // (default: time of most recent Received header, or time of import + // on server if none) The receivedAt date to set on the Email. + ReceivedAt time.Time `json:"receivedAt"` } type EmailImportCommand struct { - AccountId string `json:"accountId"` - IfInState string `json:"ifInState,omitempty"` - Emails map[string]EmailToImport `json:"emails"` + 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 of the account referenced + // by the accountId; 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 creation id (client specified) to EmailImport objects. + Emails map[string]EmailImport `json:"emails"` } +// Successfully imported Email. type ImportedEmail struct { - Id string `json:"id"` - BlobId string `json:"blobId"` + // Id of the successfully imported Email. + Id string `json:"id"` + + // Blob id of the successfully imported Email. + BlobId string `json:"blobId"` + + // Thread id of the successfully imported Email. ThreadId string `json:"threadId"` - Size int `json:"size"` + + // Size of the successfully imported Email. + Size int `json:"size"` } type EmailImportResponse struct { - AccountId string `json:"accountId"` - OldState string `json:"oldState"` - NewState string `json:"newState"` - Created map[string]ImportedEmail `json:"created"` - NotCreated map[string]SetError `json:"notCreated"` + // The id of the account used for this call. + AccountId string `json:"accountId"` + + // The state string that would have been returned by Email/get on this account + // before making the requested changes, or null if the server doesn’t know + // what the previous state string was. + OldState string `json:"oldState"` + + // The state string that will now be returned by Email/get on this account. + NewState string `json:"newState"` + + // A map of the creation id to an object containing the id, blobId, threadId, + // and size properties for each successfully imported Email, or null if none. + Created map[string]ImportedEmail `json:"created"` + + // A map of the creation id to a SetError object for each Email that failed to + // be created, or null if all successful. + NotCreated map[string]SetError `json:"notCreated"` } +// Replies are grouped together with the original message to form a Thread. +// +// In JMAP, a Thread is simply a flat list of Emails, ordered by date. +// +// Every Email MUST belong to a Thread, even if it is the only Email in the Thread. type Thread struct { - Id string + // The id of the Thread. + Id string + + // The ids of the Emails in the Thread, sorted by the receivedAt date of the Email, + // oldest first. + // + // If two Emails have an identical date, the sort is server dependent but MUST be + // stable (sorting by id is recommended). EmailIds []string } @@ -1695,14 +2357,46 @@ type IdentityGetCommand struct { } type Identity struct { - Id string `json:"id"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - ReplyTo string `json:"replyTo:omitempty"` - Bcc []EmailAddress `json:"bcc,omitempty"` - TextSignature string `json:"textSignature,omitempty"` - HtmlSignature string `json:"htmlSignature,omitempty"` - MayDelete bool `json:"mayDelete"` + // The id of the Identity. + Id string `json:"id"` + + // The “From” name the client SHOULD use when creating a new Email from this Identity. + Name string `json:"name,omitempty"` + + // The “From” email address the client MUST use when creating a new Email from this Identity. + // + // If the mailbox part of the address (the section before the “@”) is the single character + // * (e.g., *@example.com) then the client may use any valid address ending in that domain + // (e.g., foo@example.com). + Email string `json:"email,omitempty"` + + // The Reply-To value the client SHOULD set when creating a new Email from this Identity. + ReplyTo string `json:"replyTo:omitempty"` + + // The Bcc value the client SHOULD set when creating a new Email from this Identity. + Bcc []EmailAddress `json:"bcc,omitempty"` + + // A signature the client SHOULD insert into new plaintext messages that will be sent from + // this Identity. + // + // Clients MAY ignore this and/or combine this with a client-specific signature preference. + TextSignature string `json:"textSignature,omitempty"` + + // A signature the client SHOULD insert into new HTML messages that will be sent from this + // Identity. + // + // This text MUST be an HTML snippet to be inserted into the
section of the HTML. + // + // Clients MAY ignore this and/or combine this with a client-specific signature preference. + HtmlSignature string `json:"htmlSignature,omitempty"` + + // Is the user allowed to delete this Identity? + // + // Servers may wish to set this to false for the user’s username or other default address. + // + // Attempts to destroy an Identity with mayDelete: false will be rejected with a standard + // forbidden SetError. + MayDelete bool `json:"mayDelete"` } type IdentityGetResponse struct { @@ -1716,27 +2410,43 @@ type VacationResponseGetCommand struct { AccountId string `json:"accountId"` } -// https://datatracker.ietf.org/doc/html/rfc8621#section-8 +// Vacation Response +// +// A vacation response sends an automatic reply when a message is delivered to the mail store, +// informing the original sender that their message may not be read for some time. +// +// Automated message sending can produce undesirable behaviour. +// To avoid this, implementors MUST follow the recommendations set forth in [RFC3834]. +// +// The VacationResponse object represents the state of vacation-response-related settings for an account. +// +// [RFC3834]: https://www.rfc-editor.org/rfc/rfc3834.html type VacationResponse struct { // The id of the object. // There is only ever one VacationResponse object, and its id is "singleton" Id string `json:"id,omitempty"` + // Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"? IsEnabled bool `json:"isEnabled"` + // If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the // user's vacation response. If null, the vacation response is effective immediately. FromDate time.Time `json:"fromDate,omitzero"` + // If "isEnabled" is true, messages that arrive before this date-time but on or after the "fromDate" if defined) should receive the // user's vacation response. If null, the vacation response is effective indefinitely. ToDate time.Time `json:"toDate,omitzero"` + // The subject that will be used by the message sent in response to messages when the vacation response is enabled. // If null, an appropriate subject SHOULD be set by the server. Subject string `json:"subject,omitempty"` + // The plaintext body to send in response to messages when the vacation response is enabled. // If this is null, the server SHOULD generate a plaintext body part from the "htmlBody" when sending vacation responses // but MAY choose to send the response as HTML only. If both "textBody" and "htmlBody" are null, an appropriate default // body SHOULD be generated for responses by the server. TextBody string `json:"textBody,omitempty"` + // The HTML body to send in response to messages when the vacation response is enabled. // If this is null, the server MAY choose to generate an HTML body part from the "textBody" when sending vacation responses // or MAY choose to send the response as plaintext only. @@ -1746,13 +2456,17 @@ type VacationResponse struct { type VacationResponseGetResponse struct { // The identifier of the account this response pertains to. AccountId string `json:"accountId"` + // A string representing the state on the server for all the data of this type in the account // (not just the objects returned in this call). + // // If the data changes, this string MUST change. If the data is unchanged, servers SHOULD return the same state string // on subsequent requests for this data type. State string `json:"state,omitempty"` + // An array of VacationResponse objects. List []VacationResponse `json:"list,omitempty"` + // Contains identifiers of requested objects that were not found. NotFound []any `json:"notFound,omitempty"` } @@ -1833,14 +2547,32 @@ type BlobGetRefCommand struct { } type Blob struct { - Id string `json:"id"` - DataAsText string `json:"data:asText,omitempty"` - DataAsBase64 string `json:"data:asBase64,omitempty"` - DigestSha256 string `json:"digest:sha256,omitempty"` - DigestSha512 string `json:"digest:sha512,omitempty"` - IsEncodingProblem bool `json:"isEncodingProblem,omitzero"` - IsTruncated bool `json:"isTruncated,omitzero"` - Size int `json:"size"` + // The unique identifier of the blob. + Id string `json:"id"` + + // (raw octets, must be UTF-8) + DataAsText string `json:"data:asText,omitempty"` + + // (base64 representation of octets) + DataAsBase64 string `json:"data:asBase64,omitempty"` + + // The base64 encoding of the digest of the octets in the selected range, + // calculated using the SHA-256 algorithm. + DigestSha256 string `json:"digest:sha256,omitempty"` + + // The base64 encoding of the digest of the octets in the selected range, + // calculated using the SHA-512 algorithm. + DigestSha512 string `json:"digest:sha512,omitempty"` + + // If an encoding problem occured. + IsEncodingProblem bool `json:"isEncodingProblem,omitzero"` + + // When requesting a range: the isTruncated property in the result MUST be + // set to true to tell the client that the requested range could not be fully satisfied. + IsTruncated bool `json:"isTruncated,omitzero"` + + // The number of octets in the entire blob. + Size int `json:"size"` } // Picks the best digest if available, or "" @@ -1869,16 +2601,51 @@ type BlobDownload struct { CacheControl string } +// When doing a search on a String property, the client may wish to show the relevant +// section of the body that matches the search as a preview and to highlight any +// matching terms in both this and the subject of the Email. +// +// Search snippets represent this data. +// +// What is a relevant section of the body for preview is server defined. If the server is +// unable to determine search snippets, it MUST return null for both the subject and preview +// properties. +// +// Note that unlike most data types, a SearchSnippet DOES NOT have a property called id. type SearchSnippet struct { + // The Email id the snippet applies to. EmailId string `json:"emailId"` + + // If text from the filter matches the subject, this is the subject of the Email + // with the following transformations: + // + // 1. Any instance of the following three characters MUST be replaced by an + // appropriate HTML entity: & (ampersand), < (less-than sign), and > (greater-than sign) + // HTML. Other characters MAY also be replaced with an HTML entity form. + // 2. The matching words/phrases from the filter are wrapped in HTML tags. + // + // If the subject does not match text from the filter, this property is null. Subject string `json:"subject,omitempty"` + + // If text from the filter matches the plaintext or HTML body, this is the + // relevant section of the body (converted to plaintext if originally HTML), + // with the same transformations as the subject property. + // + // It MUST NOT be bigger than 255 octets in size. + // + // If the body does not contain a match for the text from the filter, this property is null. Preview string `json:"preview,omitempty"` } -type SearchSnippetRefCommand struct { - AccountId string `json:"accountId"` - Filter EmailFilterElement `json:"filter,omitempty"` - EmailIdRef *ResultReference `json:"#emailIds,omitempty"` +type SearchSnippetGetRefCommand struct { + // The id of the account to use. + AccountId string `json:"accountId"` + + // The same filter as passed to Email/query. + Filter EmailFilterElement `json:"filter,omitempty"` + + // The ids of the Emails to fetch snippets for. + EmailIdRef *ResultReference `json:"#emailIds,omitempty"` } type SearchSnippetGetResponse struct { @@ -1888,39 +2655,39 @@ type SearchSnippetGetResponse struct { } const ( - BlobGet Command = "Blob/get" - BlobUpload Command = "Blob/upload" - EmailGet Command = "Email/get" - EmailQuery Command = "Email/query" - EmailChanges Command = "Email/changes" - EmailSet Command = "Email/set" - EmailImport Command = "Email/import" - EmailSubmissionGet Command = "EmailSubmission/get" - EmailSubmissionSet Command = "EmailSubmission/set" - ThreadGet Command = "Thread/get" - MailboxGet Command = "Mailbox/get" - MailboxQuery Command = "Mailbox/query" - MailboxChanges Command = "Mailbox/changes" - IdentityGet Command = "Identity/get" - VacationResponseGet Command = "VacationResponse/get" - VacationResponseSet Command = "VacationResponse/set" - SearchSnippetGet Command = "SearchSnippet/get" + CommandBlobGet Command = "Blob/get" + CommandBlobUpload Command = "Blob/upload" + CommandEmailGet Command = "Email/get" + CommandEmailQuery Command = "Email/query" + CommandEmailChanges Command = "Email/changes" + CommandEmailSet Command = "Email/set" + CommandEmailImport Command = "Email/import" + CommandEmailSubmissionGet Command = "EmailSubmission/get" + CommandEmailSubmissionSet Command = "EmailSubmission/set" + CommandThreadGet Command = "Thread/get" + CommandMailboxGet Command = "Mailbox/get" + CommandMailboxQuery Command = "Mailbox/query" + CommandMailboxChanges Command = "Mailbox/changes" + CommandIdentityGet Command = "Identity/get" + CommandVacationResponseGet Command = "VacationResponse/get" + CommandVacationResponseSet Command = "VacationResponse/set" + CommandSearchSnippetGet Command = "SearchSnippet/get" ) var CommandResponseTypeMap = map[Command]func() any{ - BlobGet: func() any { return BlobGetResponse{} }, - BlobUpload: func() any { return BlobUploadResponse{} }, - MailboxQuery: func() any { return MailboxQueryResponse{} }, - MailboxGet: func() any { return MailboxGetResponse{} }, - MailboxChanges: func() any { return MailboxChangesResponse{} }, - EmailQuery: func() any { return EmailQueryResponse{} }, - EmailChanges: func() any { return EmailChangesResponse{} }, - EmailGet: func() any { return EmailGetResponse{} }, - EmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} }, - EmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} }, - ThreadGet: func() any { return ThreadGetResponse{} }, - IdentityGet: func() any { return IdentityGetResponse{} }, - VacationResponseGet: func() any { return VacationResponseGetResponse{} }, - VacationResponseSet: func() any { return VacationResponseSetResponse{} }, - SearchSnippetGet: func() any { return SearchSnippetGetResponse{} }, + CommandBlobGet: func() any { return BlobGetResponse{} }, + CommandBlobUpload: func() any { return BlobUploadResponse{} }, + CommandMailboxQuery: func() any { return MailboxQueryResponse{} }, + CommandMailboxGet: func() any { return MailboxGetResponse{} }, + CommandMailboxChanges: func() any { return MailboxChangesResponse{} }, + CommandEmailQuery: func() any { return EmailQueryResponse{} }, + CommandEmailChanges: func() any { return EmailChangesResponse{} }, + CommandEmailGet: func() any { return EmailGetResponse{} }, + CommandEmailSubmissionGet: func() any { return EmailSubmissionGetResponse{} }, + CommandEmailSubmissionSet: func() any { return EmailSubmissionSetResponse{} }, + CommandThreadGet: func() any { return ThreadGetResponse{} }, + CommandIdentityGet: func() any { return IdentityGetResponse{} }, + CommandVacationResponseGet: func() any { return VacationResponseGetResponse{} }, + CommandVacationResponseSet: func() any { return VacationResponseSetResponse{} }, + CommandSearchSnippetGet: func() any { return SearchSnippetGetResponse{} }, } diff --git a/pkg/jmap/jmap_session.go b/pkg/jmap/jmap_session.go index 605a696ff3..0525aa1ad1 100644 --- a/pkg/jmap/jmap_session.go +++ b/pkg/jmap/jmap_session.go @@ -1,7 +1,7 @@ package jmap import ( - "fmt" + "errors" "net/url" "github.com/opencloud-eu/opencloud/pkg/log" @@ -43,46 +43,46 @@ type Session struct { // The upload URL template DownloadUrlTemplate string - // TODO - DefaultMailAccountId string - SessionResponse } +var ( + invalidSessionResponseErrorMissingUsername = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide a username")} + invalidSessionResponseErrorMissingApiUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide an API URL")} + invalidSessionResponseErrorInvalidApiUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response provides an invalid API URL")} + invalidSessionResponseErrorMissingUploadUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide an upload URL")} + invalidSessionResponseErrorMissingDownloadUrl = SimpleError{code: JmapErrorInvalidSessionResponse, err: errors.New("JMAP session response does not provide a download URL")} +) + // Create a new Session from a SessionResponse. func newSession(sessionResponse SessionResponse) (Session, Error) { username := sessionResponse.Username if username == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")} - } - mailAccountId := sessionResponse.PrimaryAccounts.Mail - if mailAccountId == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a primary mail account")} + return Session{}, invalidSessionResponseErrorMissingUsername } apiStr := sessionResponse.ApiUrl if apiStr == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")} + return Session{}, invalidSessionResponseErrorMissingApiUrl } apiUrl, err := url.Parse(apiStr) if err != nil { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")} + return Session{}, invalidSessionResponseErrorInvalidApiUrl } uploadUrl := sessionResponse.UploadUrl if uploadUrl == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")} + return Session{}, invalidSessionResponseErrorMissingUploadUrl } downloadUrl := sessionResponse.DownloadUrl if downloadUrl == "" { - return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an download URL")} + return Session{}, invalidSessionResponseErrorMissingDownloadUrl } return Session{ - Username: username, - DefaultMailAccountId: mailAccountId, - JmapUrl: *apiUrl, - UploadUrlTemplate: uploadUrl, - DownloadUrlTemplate: downloadUrl, - SessionResponse: sessionResponse, + Username: username, + JmapUrl: *apiUrl, + UploadUrlTemplate: uploadUrl, + DownloadUrlTemplate: downloadUrl, + SessionResponse: sessionResponse, }, nil } @@ -91,7 +91,7 @@ func (s *Session) MailAccountId(accountId string) string { return accountId } // TODO(pbleser-oc) handle case where there is no default mail account - return s.DefaultMailAccountId + return s.PrimaryAccounts.Mail } func (s *Session) BlobAccountId(accountId string) string { diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index c4bb1d870c..df393436cb 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -115,9 +115,9 @@ func serveTestFile(t *testing.T, name string) ([]byte, Error) { func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, Error) { command := request.MethodCalls[0].Command switch command { - case MailboxGet: + case CommandMailboxGet: return serveTestFile(t.t, "mailboxes1.json") - case EmailQuery: + case CommandEmailQuery: return serveTestFile(t.t, "mails1.json") default: require.Fail(t.t, "TestJmapApiClient: unsupported jmap command: %v", command) @@ -149,7 +149,7 @@ func TestRequests(t *testing.T) { jmapUrl, err := url.Parse("http://localhost/jmap") require.NoError(err) - session := Session{DefaultMailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl} + session := Session{Username: "user123", JmapUrl: *jmapUrl} folders, err := client.GetAllMailboxes("a", &session, ctx, &logger) require.NoError(err) diff --git a/pkg/jmap/jmap_tools_test.go b/pkg/jmap/jmap_tools_test.go index e2ba0eab1b..d6c385b770 100644 --- a/pkg/jmap/jmap_tools_test.go +++ b/pkg/jmap/jmap_tools_test.go @@ -18,7 +18,7 @@ func TestDeserializeMailboxGetResponse(t *testing.T) { require.Equal("3e25b2a0", data.SessionState) require.Len(data.MethodResponses, 1) resp := data.MethodResponses[0] - require.Equal(MailboxGet, resp.Command) + require.Equal(CommandMailboxGet, resp.Command) require.Equal("0", resp.Tag) require.IsType(MailboxGetResponse{}, resp.Parameters) mgr := resp.Parameters.(MailboxGetResponse) @@ -75,7 +75,7 @@ func TestDeserializeEmailGetResponse(t *testing.T) { require.Equal("3e25b2a0", data.SessionState) require.Len(data.MethodResponses, 2) resp := data.MethodResponses[1] - require.Equal(EmailGet, resp.Command) + require.Equal(CommandEmailGet, resp.Command) require.Equal("1", resp.Tag) require.IsType(EmailGetResponse{}, resp.Parameters) egr := resp.Parameters.(EmailGetResponse) diff --git a/services/groupware/Makefile b/services/groupware/Makefile index 05e6121405..d6f8b4ba09 100644 --- a/services/groupware/Makefile +++ b/services/groupware/Makefile @@ -14,8 +14,8 @@ include ../../.make/docs.mk apidoc: swagger.yml .PHONY: swagger.yml -swagger.yml: - swagger generate spec -c groupware -o ./swagger.yml +swagger.yml: apidoc.yml + swagger generate spec --output=$@ --include='groupware' --include='jmap' --scan-models --input=$< APIDOC_PORT=9999 diff --git a/services/groupware/apidoc.yml b/services/groupware/apidoc.yml new file mode 100644 index 0000000000..655df9d206 --- /dev/null +++ b/services/groupware/apidoc.yml @@ -0,0 +1,9 @@ +tags: + - name: init + description: Initialization APIs + - name: mailboxes + description: APIs that pertain to mailboxes + - name: messages + description: APIs about emails + - name: vacation + description: APIs about vacation responses \ No newline at end of file diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go index a4e7b03459..e5cdbd7b77 100644 --- a/services/groupware/pkg/groupware/groupware_api_identity.go +++ b/services/groupware/pkg/groupware/groupware_api_identity.go @@ -2,8 +2,28 @@ package groupware import ( "net/http" + + "github.com/opencloud-eu/opencloud/pkg/jmap" ) +// When the request suceeds. +// swagger:response GetIdentitiesResponse +type SwaggerGetIdentitiesResponse struct { + // in: body + Body struct { + *jmap.Identities + } +} + +// swagger:route GET /accounts/{accountid}/identities identities identities +// Get the list of identities that are associated with an account. +// +// responses: +// +// 200: GetIdentitiesResponse +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 func (g Groupware) GetIdentities(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { res, err := g.jmap.GetIdentity(req.GetAccountId(), req.session, req.ctx, req.logger) diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go index 02cfef14cd..c289958f80 100644 --- a/services/groupware/pkg/groupware/groupware_api_index.go +++ b/services/groupware/pkg/groupware/groupware_api_index.go @@ -9,24 +9,76 @@ import ( type IndexLimits struct { // The maximum file size, in octets, that the server will accept for a single file upload (for any purpose). - MaxSizeUpload int `json:"maxSizeUpload"` - MaxConcurrentUpload int `json:"maxConcurrentUpload"` - MaxSizeRequest int `json:"maxSizeRequest"` + MaxSizeUpload int `json:"maxSizeUpload"` + + // The maximum number of concurrent requests the server will accept to the upload endpoint. + MaxConcurrentUpload int `json:"maxConcurrentUpload"` + + // The maximum size, in octets, that the server will accept for a single request to the API endpoint. + MaxSizeRequest int `json:"maxSizeRequest"` + + // The maximum number of concurrent requests the server will accept to the API endpoint. MaxConcurrentRequests int `json:"maxConcurrentRequests"` } type IndexAccountMailCapabilities struct { - MaxMailboxDepth int `json:"maxMailboxDepth"` - MaxSizeMailboxName int `json:"maxSizeMailboxName"` - MaxSizeAttachmentsPerEmail int `json:"maxSizeAttachmentsPerEmail"` - MayCreateTopLevelMailbox bool `json:"mayCreateTopLevelMailbox"` - MaxDelayedSend int `json:"maxDelayedSend"` + // The maximum depth of the Mailbox hierarchy (i.e., one more than the maximum number of ancestors + // a Mailbox may have), or null for no limit. + MaxMailboxDepth int `json:"maxMailboxDepth"` + + // The maximum length, in (UTF-8) octets, allowed for the name of a Mailbox. + // + // This MUST be at least 100, although it is recommended servers allow more. + MaxSizeMailboxName int `json:"maxSizeMailboxName"` + + // The maximum number of Mailboxes that can be can assigned to a single Email object. + // + // This MUST be an integer >= 1, or null for no limit (or rather, the limit is always the number of + // Mailboxes in the account). + MaxMailboxesPerEmail int `json:"maxMailboxesPerEmail"` + + // The maximum total size of attachments, in octets, allowed for a single Email object. + // + // A server MAY still reject the import or creation of an Email with a lower attachment size total + // (for example, if the body includes several megabytes of text, causing the size of the encoded + // MIME structure to be over some server-defined limit). + // + // Note that this limit is for the sum of unencoded attachment sizes. Users are generally not + // knowledgeable about encoding overhead, etc., nor should they need to be, so marketing and help + // materials normally tell them the “max size attachments”. This is the unencoded size they see + // on their hard drive, so this capability matches that and allows the client to consistently + // enforce what the user understands as the limit. + MaxSizeAttachmentsPerEmail int `json:"maxSizeAttachmentsPerEmail"` + + // If true, the user may create a Mailbox in this account with a null parentId. + MayCreateTopLevelMailbox bool `json:"mayCreateTopLevelMailbox"` + + // The number in seconds of the maximum delay the server supports in sending. + // + // This is 0 if the server does not support delayed send. + MaxDelayedSend int `json:"maxDelayedSend"` } type IndexAccountSieveCapabilities struct { - MaxSizeScriptName int `json:"maxSizeScriptName"` - MaxSizeScript int `json:"maxSizeScript"` - MaxNumberScripts int `json:"maxNumberScripts"` + // The maximum length, in octets, allowed for the name of a SieveScript. + // + // For compatibility with ManageSieve, this MUST be at least 512 (up + // to 128 Unicode characters). + MaxSizeScriptName int `json:"maxSizeScriptName"` + + // The maximum size (in octets) of a Sieve script the server is willing + // to store for the user, or null for no limit. + MaxSizeScript int `json:"maxSizeScript"` + + // The maximum number of Sieve scripts the server is willing to store + // for the user, or null for no limit. + MaxNumberScripts int `json:"maxNumberScripts"` + + // The maximum number of Sieve "redirect" actions a script can perform + // during a single evaluation, or null for no limit. + // + // Note that this is different from the total number of "redirect" + // actions a script can contain. MaxNumberRedirects int `json:"maxNumberRedirects"` } @@ -36,24 +88,49 @@ type IndexAccountCapabilities struct { } type IndexAccount struct { - Name string `json:"name"` - IsPersonal bool `json:"isPersonal"` - IsReadOnly bool `json:"isReadOnly"` + // A user-friendly string to show when presenting content from this account, + // e.g., the email address representing the owner of the account. + Name string `json:"name"` + + // This is true if the account belongs to the authenticated user rather than + // a group account or a personal account of another user that has been shared + // with them. + IsPersonal bool `json:"isPersonal"` + + // This is true if the entire account is read-only. + IsReadOnly bool `json:"isReadOnly"` + Capabilities IndexAccountCapabilities `json:"capabilities"` - Identities []jmap.Identity `json:"identities,omitempty"` + + // The identities associated with this account. + Identities []jmap.Identity `json:"identities,omitempty"` } type IndexPrimaryAccounts struct { - Mail string `json:"mail"` - Submission string `json:"submission"` + Mail string `json:"mail"` + Submission string `json:"submission"` + Blob string `json:"blob"` + VacationResponse string `json:"vacationResponse"` + Sieve string `json:"sieve"` } type IndexResponse struct { - Version string `json:"version"` - Capabilities []string `json:"capabilities"` - Limits IndexLimits `json:"limits"` - Accounts map[string]IndexAccount `json:"accounts"` - PrimaryAccounts IndexPrimaryAccounts `json:"primaryAccounts"` + // The API version. + Version string `json:"version"` + + // A list of capabilities of this API version. + Capabilities []string `json:"capabilities"` + + // API limits. + Limits IndexLimits `json:"limits"` + + // Accounts that are available to the user. + // + // The key of the mapis the identifier. + Accounts map[string]IndexAccount `json:"accounts"` + + // Primary accounts for usage types. + PrimaryAccounts IndexPrimaryAccounts `json:"primaryAccounts"` } // When the request suceeds. @@ -65,8 +142,8 @@ type SwaggerIndexResponse struct { } } -// swagger:route GET / index -// Get initial bootup information +// swagger:route GET / init index +// Get initial bootstrapping information for a user. // // responses: // @@ -97,6 +174,7 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { Mail: IndexAccountMailCapabilities{ MaxMailboxDepth: account.AccountCapabilities.Mail.MaxMailboxDepth, MaxSizeMailboxName: account.AccountCapabilities.Mail.MaxSizeMailboxName, + MaxMailboxesPerEmail: account.AccountCapabilities.Mail.MaxMailboxesPerEmail, MaxSizeAttachmentsPerEmail: account.AccountCapabilities.Mail.MaxSizeAttachmentsPerEmail, MayCreateTopLevelMailbox: account.AccountCapabilities.Mail.MayCreateTopLevelMailbox, MaxDelayedSend: account.AccountCapabilities.Submission.MaxDelayedSend, @@ -126,8 +204,11 @@ func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { }, Accounts: accounts, PrimaryAccounts: IndexPrimaryAccounts{ - Mail: req.session.PrimaryAccounts.Mail, - Submission: req.session.PrimaryAccounts.Submission, + Mail: req.session.PrimaryAccounts.Mail, + Submission: req.session.PrimaryAccounts.Submission, + Blob: req.session.PrimaryAccounts.Blob, + VacationResponse: req.session.PrimaryAccounts.VacationResponse, + Sieve: req.session.PrimaryAccounts.Sieve, }, }, req.session.State) }) diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go index 9dbf883d35..dac8667362 100644 --- a/services/groupware/pkg/groupware/groupware_api_mailbox.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -18,7 +18,7 @@ type SwaggerGetMailboxById200 struct { } } -// swagger:route GET /accounts/{account}/mailboxes/{id} mailboxes_by_id +// swagger:route GET /accounts/{account}/mailboxes/{id} mailboxes mailboxes_by_id // Get a specific mailbox by its identifier. // // A Mailbox represents a named set of Emails. @@ -73,7 +73,7 @@ type SwaggerMailboxesResponse200 struct { Body []jmap.Mailbox } -// swagger:route GET /accounts/{account}/mailboxes mailboxes +// swagger:route GET /accounts/{account}/mailboxes mailboxes mailboxes // Get the list of all the mailboxes of an account. // // A Mailbox represents a named set of Emails. diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go index 201ec25b0d..880594fdae 100644 --- a/services/groupware/pkg/groupware/groupware_api_messages.go +++ b/services/groupware/pkg/groupware/groupware_api_messages.go @@ -12,6 +12,45 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" ) +// When the request succeeds without a "since" query parameter. +// swagger:response GetAllMessagesInMailbox200 +type SwaggerGetAllMessagesInMailbox200 struct { + // in: body + Body struct { + *jmap.Emails + } +} + +// When the request succeeds with a "since" query parameter. +// swagger:response GetAllMessagesInMailboxSince200 +type SwaggerGetAllMessagesInMailboxSince200 struct { + // in: body + Body struct { + *jmap.EmailsSince + } +} + +// swagger:route GET /accounts/{account}/mailboxes/{id}/messages messages get_all_messages_in_mailbox +// Get all the emails in a mailbox. +// +// Retrieve the list of all the emails that are in a given mailbox. +// +// The mailbox must be specified by its id, as part of the request URL path. +// +// A limit and an offset may be specified using the query parameters 'limit' and 'offset', +// respectively. +// +// When the query parameter 'since' or the 'if-none-match' header is specified, then the +// request behaves differently, performing a changes query to determine what has changed in +// that mailbox since a given state identifier. +// +// responses: +// +// 200: GetAllMessagesInMailbox200 +// 200: GetAllMessagesInMailboxSince200 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 func (g Groupware) GetAllMessagesInMailbox(w http.ResponseWriter, r *http.Request) { mailboxId := chi.URLParam(r, UriParamMailboxId) since := r.Header.Get(HeaderSince) @@ -144,7 +183,7 @@ type MessageSearchResults struct { QueryState string `json:"queryState,omitempty"` } -func (g Groupware) buildQuery(req Request) (bool, jmap.EmailFilterElement, int, int, *log.Logger, Response) { +func (g Groupware) buildFilter(req Request) (bool, jmap.EmailFilterElement, int, int, *log.Logger, Response) { q := req.r.URL.Query() mailboxId := q.Get(QueryParamMailboxId) notInMailboxIds := q[QueryParamNotInMailboxId] @@ -270,22 +309,19 @@ func (g Groupware) buildQuery(req Request) (bool, jmap.EmailFilterElement, int, } } } + return true, filter, offset, limit, logger, Response{} } func (g Groupware) searchMessages(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - ok, filter, offset, limit, logger, errResp := g.buildQuery(req) + ok, filter, offset, limit, logger, errResp := g.buildFilter(req) if !ok { return errResp } - var empty jmap.EmailFilterElement - - if filter == empty { - errorId := req.errorId() - msg := "Invalid search request has no criteria" - return errorResponse(apiError(errorId, ErrorInvalidUserRequest, withDetail(msg))) + if !filter.IsNotEmpty() { + filter = nil } fetchEmails, ok, err := req.parseBoolParam(QueryParamSearchFetchEmails, false) @@ -293,7 +329,7 @@ func (g Groupware) searchMessages(w http.ResponseWriter, r *http.Request) { return errorResponse(err) } if ok { - logger = &log.Logger{Logger: logger.With().Bool(QueryParamSearchFetchEmails, fetchEmails).Logger()} + logger = log.From(logger.With().Bool(QueryParamSearchFetchEmails, fetchEmails)) } if fetchEmails { @@ -302,7 +338,7 @@ func (g Groupware) searchMessages(w http.ResponseWriter, r *http.Request) { return errorResponse(err) } if ok { - logger = &log.Logger{Logger: logger.With().Bool(QueryParamSearchFetchBodies, fetchBodies).Logger()} + logger = log.From(logger.With().Bool(QueryParamSearchFetchBodies, fetchBodies)) } results, jerr := g.jmap.QueryEmails(req.GetAccountId(), filter, req.session, req.ctx, logger, offset, limit, fetchBodies, g.maxBodyValueBytes) @@ -424,7 +460,7 @@ func (g Groupware) UpdateMessage(w http.ResponseWriter, r *http.Request) { l := req.logger.With() l.Str(UriParamMessageId, messageId) - logger := &log.Logger{Logger: l.Logger()} + logger := log.From(l) var body map[string]any err := req.body(&body) @@ -463,7 +499,7 @@ func (g Groupware) DeleteMessage(w http.ResponseWriter, r *http.Request) { l := req.logger.With() l.Str(UriParamMessageId, messageId) - logger := &log.Logger{Logger: l.Logger()} + logger := log.From(l) deleted, jerr := g.jmap.DeleteEmails(req.GetAccountId(), []string{messageId}, req.session, req.ctx, logger) if jerr != nil { diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go index a5c1a2e852..82e1bd6934 100644 --- a/services/groupware/pkg/groupware/groupware_api_vacation.go +++ b/services/groupware/pkg/groupware/groupware_api_vacation.go @@ -7,15 +7,15 @@ import ( ) // When the request succeeds. -// swagger:response VacationResponse200 -type SwaggerVacationResponse200 struct { +// swagger:response GetVacationResponse200 +type SwaggerGetVacationResponse200 struct { // in: body Body struct { *jmap.VacationResponseGetResponse } } -// swagger:route GET /accounts/{account}/vacation vacation +// swagger:route GET /accounts/{account}/vacation vacation getvacation // Get vacation notice information. // // A vacation response sends an automatic reply when a message is delivered to the mail store, informing the original @@ -25,7 +25,7 @@ type SwaggerVacationResponse200 struct { // // responses: // -// 200: VacationResponse200 +// 200: GetVacationResponse200 // 400: ErrorResponse400 // 500: ErrorResponse500 func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { @@ -38,6 +38,26 @@ func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { }) } +// When the request succeeds. +// swagger:response SetVacationResponse200 +type SwaggerSetVacationResponse200 struct { + // in: body + Body struct { + *jmap.VacationResponseChange + } +} + +// swagger:route PUT /accounts/{account}/vacation vacation setvacation +// Set the vacation notice information. +// +// A vacation response sends an automatic reply when a message is delivered to the mail store, informing the original +// sender that their message may not be read for some time. +// +// responses: +// +// 200: SetVacationResponse200 +// 400: ErrorResponse400 +// 500: ErrorResponse500 func (g Groupware) SetVacation(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { var body jmap.VacationResponsePayload diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index d84c294df4..34355ced5a 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -54,7 +54,8 @@ func (g Groupware) Route(r chi.Router) { r.Get("/", g.GetMessages) // ?fetchemails=true&fetchbodies=true&text=&subject=&body=&keyword=&keyword=&... r.Post("/", g.CreateMessage) r.Get("/{messageid}", g.GetMessagesById) - r.Put("/{messageid}", g.UpdateMessage) // or PATCH? + // r.Put("/{messageid}", g.ReplaceMessage) // TODO + r.Patch("/{messageid}", g.UpdateMessage) r.Delete("/{messageId}", g.DeleteMessage) }) r.Route("/blobs", func(r chi.Router) {