diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go
index 65580d0f82..29ffde521f 100644
--- a/pkg/jmap/jmap.go
+++ b/pkg/jmap/jmap.go
@@ -2,6 +2,7 @@ package jmap
import (
"context"
+ "encoding/base64"
"fmt"
"io"
"net/url"
@@ -55,14 +56,51 @@ func NewClient(wellKnown SessionClient, api ApiClient) Client {
type Session struct {
// The name of the user to use to authenticate against Stalwart
Username string
+
// The base URL to use for JMAP operations towards Stalwart
JmapUrl url.URL
+
+ // The upload URL template
+ UploadUrlTemplate string
+
// TODO
DefaultMailAccountId string
SessionResponse
}
+// 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")}
+ }
+ apiStr := sessionResponse.ApiUrl
+ if apiStr == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
+ }
+ apiUrl, err := url.Parse(apiStr)
+ if err != nil {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")}
+ }
+ uploadUrl := sessionResponse.UploadUrl
+ if uploadUrl == "" {
+ return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")}
+ }
+
+ return Session{
+ Username: username,
+ DefaultMailAccountId: mailAccountId,
+ JmapUrl: *apiUrl,
+ UploadUrlTemplate: uploadUrl,
+ SessionResponse: sessionResponse,
+ }, nil
+}
+
func (s *Session) MailAccountId(accountId string) string {
if accountId != "" && accountId != defaultAccountId {
return accountId
@@ -71,6 +109,14 @@ func (s *Session) MailAccountId(accountId string) string {
return s.DefaultMailAccountId
}
+func (s *Session) BlobAccountId(accountId string) string {
+ if accountId != "" && accountId != defaultAccountId {
+ return accountId
+ }
+ // TODO(pbleser-oc) handle case where there is no default blob account
+ return s.PrimaryAccounts.Blob
+}
+
const (
logOperation = "operation"
logUsername = "username"
@@ -81,6 +127,7 @@ const (
logLimit = "limit"
logApiUrl = "apiurl"
logSessionState = "session-state"
+ logSince = "since"
defaultAccountId = "*"
@@ -114,32 +161,6 @@ func (j *Client) onSessionOutdated(session *Session) {
})
}
-// Create a new Session from a WellKnownResponse.
-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")}
- }
- apiStr := sessionResponse.ApiUrl
- if apiStr == "" {
- return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an API URL")}
- }
- 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{
- Username: username,
- DefaultMailAccountId: mailAccountId,
- JmapUrl: *apiUrl,
- SessionResponse: sessionResponse,
- }, nil
-}
-
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Error) {
wk, err := j.wellKnown.GetSession(username, logger)
@@ -242,7 +263,7 @@ func (j *Client) SearchMailboxes(accountId string, session *Session, ctx context
invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: aid, Filter: filter}, "0"),
invocation(MailboxGet, MailboxGetRefCommand{
AccountId: aid,
- IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
+ IdRef: &ResultReference{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"},
}, "1"),
)
if err != nil {
@@ -310,7 +331,7 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
get := EmailGetRefCommand{
AccountId: aid,
FetchAllBodyValues: fetchBodies,
- IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
+ IdRef: &ResultReference{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"},
}
if maxBodyValueBytes >= 0 {
get.MaxBodyValueBytes = maxBodyValueBytes
@@ -333,3 +354,330 @@ func (j *Client) GetAllEmails(accountId string, session *Session, ctx context.Co
return Emails{Emails: response.List, State: body.SessionState}, nil
})
}
+
+type EmailsSince struct {
+ Destroyed []string `json:"destroyed,omitzero"`
+ HasMoreChanges bool `json:"hasMoreChanges,omitzero"`
+ NewState string `json:"newState"`
+ Created []Email `json:"created,omitempty"`
+ Updated []Email `json:"updated,omitempty"`
+ State string `json:"state,omitempty"`
+}
+
+func (j *Client) GetEmailsInMailboxSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, mailboxId string, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.loggerParams(aid, "GetEmailsInMailboxSince", session, logger, func(z zerolog.Context) zerolog.Context {
+ return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
+ })
+
+ changes := MailboxChangesCommand{
+ AccountId: aid,
+ SinceState: since,
+ }
+ if maxChanges >= 0 {
+ changes.MaxChanges = maxChanges
+ }
+
+ getCreated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: MailboxChanges, Path: "/created", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ getCreated.MaxBodyValueBytes = maxBodyValueBytes
+ }
+ getUpdated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: MailboxChanges, 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"),
+ )
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
+ var mailboxResponse MailboxChangesResponse
+ err = retrieveResponseMatchParameters(body, MailboxChanges, "0", &mailboxResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var createdResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var updatedResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ return EmailsSince{
+ Destroyed: mailboxResponse.Destroyed,
+ HasMoreChanges: mailboxResponse.HasMoreChanges,
+ NewState: mailboxResponse.NewState,
+ Created: createdResponse.List,
+ Updated: createdResponse.List,
+ State: body.SessionState,
+ }, nil
+ })
+}
+
+func (j *Client) GetEmailsSince(accountId string, session *Session, ctx context.Context, logger *log.Logger, since string, fetchBodies bool, maxBodyValueBytes int, maxChanges int) (EmailsSince, Error) {
+ aid := session.MailAccountId(accountId)
+ logger = j.loggerParams(aid, "GetEmailsSince", session, logger, func(z zerolog.Context) zerolog.Context {
+ return z.Bool(logFetchBodies, fetchBodies).Str(logSince, since)
+ })
+
+ changes := EmailChangesCommand{
+ AccountId: aid,
+ SinceState: since,
+ }
+ if maxChanges >= 0 {
+ changes.MaxChanges = maxChanges
+ }
+
+ getCreated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: EmailChanges, Path: "/created", ResultOf: "0"},
+ }
+ if maxBodyValueBytes >= 0 {
+ getCreated.MaxBodyValueBytes = maxBodyValueBytes
+ }
+ getUpdated := EmailGetRefCommand{
+ AccountId: aid,
+ FetchAllBodyValues: fetchBodies,
+ IdRef: &ResultReference{Name: EmailChanges, 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"),
+ )
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (EmailsSince, Error) {
+ var changesResponse EmailChangesResponse
+ err = retrieveResponseMatchParameters(body, EmailChanges, "0", &changesResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var createdResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "1", &createdResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var updatedResponse EmailGetResponse
+ err = retrieveResponseMatchParameters(body, EmailGet, "2", &updatedResponse)
+ if err != nil {
+ return EmailsSince{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ return EmailsSince{
+ Destroyed: changesResponse.Destroyed,
+ HasMoreChanges: changesResponse.HasMoreChanges,
+ NewState: changesResponse.NewState,
+ Created: createdResponse.List,
+ Updated: createdResponse.List,
+ State: body.SessionState,
+ }, nil
+ })
+}
+
+func (j *Client) GetBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, id string) (*Blob, Error) {
+ aid := session.BlobAccountId(accountId)
+
+ cmd, err := request(
+ invocation(BlobUpload, BlobGetCommand{
+ AccountId: aid,
+ Ids: []string{id},
+ Properties: []string{BlobPropertyData, BlobPropertyDigestSha512, BlobPropertySize},
+ }, "0"),
+ )
+ if err != nil {
+ return nil, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (*Blob, Error) {
+ var response BlobGetResponse
+ err = retrieveResponseMatchParameters(body, BlobGet, "0", &response)
+ if err != nil {
+ return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(response.List) != 1 {
+ return nil, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ get := response.List[0]
+ return &get, nil
+ })
+}
+
+type UploadedBlob struct {
+ Id string `json:"id"`
+ Size int `json:"size"`
+ Type string `json:"type"`
+ Sha512 string `json:"sha:512"`
+}
+
+func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) {
+ aid := session.MailAccountId(accountId)
+
+ encoded := base64.StdEncoding.EncodeToString(data)
+
+ upload := BlobUploadCommand{
+ AccountId: aid,
+ Create: map[string]UploadObject{
+ "0": {
+ Data: []DataSourceObject{{
+ DataAsBase64: encoded,
+ }},
+ Type: contentType,
+ },
+ },
+ }
+
+ getHash := BlobGetRefCommand{
+ AccountId: aid,
+ IdRef: &ResultReference{
+ ResultOf: "0",
+ Name: BlobUpload,
+ Path: "/ids",
+ },
+ Properties: []string{BlobPropertyDigestSha512},
+ }
+
+ cmd, err := request(
+ invocation(BlobUpload, upload, "0"),
+ invocation(BlobGet, getHash, "1"),
+ )
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
+ var uploadResponse BlobUploadResponse
+ err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var getResponse BlobGetResponse
+ err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(uploadResponse.Created) != 1 {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ upload, ok := uploadResponse.Created["0"]
+ if !ok {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(getResponse.List) != 1 {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ get := getResponse.List[0]
+
+ return UploadedBlob{
+ Id: upload.Id,
+ Size: upload.Size,
+ Type: upload.Type,
+ Sha512: get.DigestSha512,
+ }, nil
+ })
+
+}
+
+func (j *Client) ImportEmail(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte) (UploadedBlob, Error) {
+ aid := session.MailAccountId(accountId)
+
+ encoded := base64.StdEncoding.EncodeToString(data)
+
+ upload := BlobUploadCommand{
+ AccountId: aid,
+ Create: map[string]UploadObject{
+ "0": {
+ Data: []DataSourceObject{{
+ DataAsBase64: encoded,
+ }},
+ Type: EmailMimeType,
+ },
+ },
+ }
+
+ getHash := BlobGetRefCommand{
+ AccountId: aid,
+ IdRef: &ResultReference{
+ ResultOf: "0",
+ Name: BlobUpload,
+ Path: "/ids",
+ },
+ Properties: []string{BlobPropertyDigestSha512},
+ }
+
+ cmd, err := request(
+ invocation(BlobUpload, upload, "0"),
+ invocation(BlobGet, getHash, "1"),
+ )
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err}
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, func(body *Response) (UploadedBlob, Error) {
+ var uploadResponse BlobUploadResponse
+ err = retrieveResponseMatchParameters(body, BlobUpload, "0", &uploadResponse)
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ var getResponse BlobGetResponse
+ err = retrieveResponseMatchParameters(body, BlobGet, "1", &getResponse)
+ if err != nil {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(uploadResponse.Created) != 1 {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ upload, ok := uploadResponse.Created["0"]
+ if !ok {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+
+ if len(getResponse.List) != 1 {
+ return UploadedBlob{}, SimpleError{code: JmapErrorInvalidJmapResponsePayload, err: err}
+ }
+ get := getResponse.List[0]
+
+ return UploadedBlob{
+ Id: upload.Id,
+ Size: upload.Size,
+ Type: upload.Type,
+ Sha512: get.DigestSha512,
+ }, nil
+ })
+
+}
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index 2dcd7ff1c2..851be0f621 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -181,6 +181,68 @@ type SessionResponse struct {
State string `json:"state,omitempty"`
}
+// SetError type values.
+const (
+ // (create; update; destroy). The create/update/destroy would violate an ACL or other permissions policy.
+ SetErrorTypeForbidden = "forbidden"
+
+ // (create; update). The create would exceed a server-defined limit on the number or total size of objects of this type.
+ SetErrorTypeOverQuota = "overQuota"
+
+ // (create; update). 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.
+ SetErrorTypeTooLarge = "tooLarge"
+
+ // (create). 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.
+ SetErrorTypeRateLimit = "rateLimit"
+
+ // (update; destroy). The id given to update/destroy cannot be found.
+ SetErrorTypeNotFound = "notFound"
+
+ // (update) The PatchObject given to update the record was not a valid patch (see the patch description).
+ SetErrorTypeInvalidPatch = "invalidPatch"
+
+ // (update). 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.
+ SetErrorTypeWillDestroy = "willDestroy"
+
+ // (create; update). 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.
+ // Note, to allow clients to pass whole objects back, it is not an error to include a server-set property in an
+ // update as long as the value is identical to the current value on the server.
+ // - There is a reference to another record (foreign key), and the given id does not correspond to a valid record.
+ //
+ // The SetError object SHOULD also have a property called properties of type String[] that lists all the properties
+ // that were invalid.
+ //
+ // 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.
+ SetErrorTypeInvalidProperties = "invalidProperties"
+
+ // (create; destroy). This is a singleton type, so you cannot create another one or destroy the existing one.
+ SetErrorTypeSingleton = "singleton"
+
+ // The total number of objects to create, update, or destroy exceeds the maximum number the server is
+ // willing to process in a single method call.
+ SetErrorTypeRequestTooLarge = "requestTooLarge"
+
+ // An ifInState argument was supplied, and it does not match the current state.
+ SetErrorTypeStateMismatch = "stateMismatch"
+)
+
+type SetError struct {
+ // The type of error.
+ Type string `json:"type"`
+
+ // A description of the error to help with debugging that includes an explanation of what the problem was.
+ //
+ // This is a non-localised string and is not intended to be shown directly to end users.
+ Description string `json:"description,omitempty"`
+}
+
type Mailbox struct {
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
@@ -201,8 +263,14 @@ type MailboxGetCommand struct {
}
type MailboxGetRefCommand struct {
- AccountId string `json:"accountId"`
- IdRef *Ref `json:"#ids,omitempty"`
+ AccountId string `json:"accountId"`
+ IdRef *ResultReference `json:"#ids,omitempty"`
+}
+
+type MailboxChangesCommand struct {
+ AccountId string `json:"accountId"`
+ SinceState string `json:"sinceState,omitempty"`
+ MaxChanges int `json:"maxChanges,omitzero"`
}
type MailboxFilterCondition struct {
@@ -267,84 +335,527 @@ type EmailQueryCommand struct {
}
type EmailGetCommand struct {
- AccountId string `json:"accountId"`
- FetchAllBodyValues bool `json:"fetchAllBodyValues,omitempty"`
- MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
- Ids []string `json:"ids,omitempty"`
+ // The ids of the Email 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.
+ Ids []string `json:"ids,omitempty"`
+
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // If supplied, only the properties listed in the array are returned for each Email object.
+ //
+ // If null, the following properties are returned:
+ //
+ // [ "id", "blobId", "threadId", "mailboxIds", "keywords", "size",
+ // "receivedAt", "messageId", "inReplyTo", "references", "sender", "from",
+ // "to", "cc", "bcc", "replyTo", "subject", "sentAt", "hasAttachment",
+ // "preview", "bodyValues", "textBody", "htmlBody", "attachments" ]
+ //
+ // 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"`
+
+ // A list of properties to fetch for each EmailBodyPart returned.
+ //
+ // If omitted, this defaults to:
+ //
+ // [ "partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location" ]
+ //
+ BodyProperties []string `json:"bodyProperties,omitempty"`
+
+ // (default: false) If true, the bodyValues property includes any text/* part in the textBody property.
+ FetchTextBodyValues bool `json:"fetchTextBodyValues,omitzero"`
+
+ // (default: false) If true, the bodyValues property includes any text/* part in the htmlBody property.
+ FetchHTMLBodyValues bool `json:"fetchHTMLBodyValues,omitzero"`
+
+ // (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
+ // MUST be truncated if necessary so it does not exceed this number of octets in size.
+ //
+ // If 0 (the default), no truncation occurs.
+ //
+ // The server MUST ensure the truncation results in valid UTF-8 and does not occur mid-codepoint.
+ //
+ // If the part is of type text/html, the server SHOULD NOT truncate inside an HTML tag, e.g., in
+ // the middle of .
+ //
+ // There is no requirement for the truncated form to be a balanced tree or valid HTML (indeed, the original
+ // source may well be neither of these things).
+ MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
}
-type Ref struct {
- Name Command `json:"name"`
- Path string `json:"path,omitempty"`
- ResultOf string `json:"resultOf,omitempty"`
+// Reference to Previous Method Results
+//
+// To allow clients to make more efficient use of the network and avoid round trips, an argument to one method
+// can be taken from the result of a previous method call in the same request.
+//
+// To do this, the client prefixes the argument name with # (an [octothorpe]).
+//
+// When processing a method call, the server MUST first check the arguments object for any names beginning with #.
+//
+// If found, the result reference should be resolved and the value used as the “real” argument.
+//
+// The method is then processed as normal.
+//
+// If any result reference fails to resolve, the whole method MUST be rejected with an invalidResultReference error.
+//
+// If an arguments object contains the same argument name in normal and referenced form (e.g., foo and #foo),
+// the method MUST return an invalidArguments error.
+//
+// To resolve:
+//
+// 1. Find the first response with a method call id identical to the resultOf property of the ResultReference
+// in the methodResponses array from previously processed method calls in the same request.
+// If none, evaluation fails.
+// 2. If the response name is not identical to the name property of the ResultReference, evaluation fails.
+// 3. Apply the path to the arguments object of the response (the second item in the response array)
+// following the JSON Pointer algorithm [RFC6901], except with the following addition in “Evaluation” (see Section 4):
+// 4. If the currently referenced value is a JSON array, the reference token may be exactly the single character *,
+// making the new referenced value the result of applying the rest of the JSON Pointer tokens to every item in the
+// array and returning the results in the same order in a new array.
+// 5. If the result of applying the rest of the pointer tokens to each item was itself an array, the contents of this
+// array are added to the output rather than the array itself (i.e., the result is flattened from an array of
+// arrays to a single array).
+//
+// [octothorpe]; https://en.wiktionary.org/wiki/octothorpe
+// [RFC6901]: https://datatracker.ietf.org/doc/html/rfc6901
+type ResultReference struct {
+ // The method call id of a previous method call in the current request.
+ ResultOf string `json:"resultOf"`
+
+ // The required name of a response to that method call.
+ Name Command `json:"name"`
+
+ // A pointer into the arguments of the response selected via the name and resultOf properties.
+ //
+ // This is a JSON Pointer [RFC6901], except it also allows the use of * to map through an array.
+ //
+ // [RFC6901]: https://datatracker.ietf.org/doc/html/rfc6901
+ Path string `json:"path,omitempty"`
}
type EmailGetRefCommand struct {
- AccountId string `json:"accountId"`
- FetchAllBodyValues bool `json:"fetchAllBodyValues,omitempty"`
- MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
- IdRef *Ref `json:"#ids,omitempty"`
+ // The ids of the Email 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"`
+
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // If supplied, only the properties listed in the array are returned for each Email object.
+ //
+ // If null, the following properties are returned:
+ //
+ // [ "id", "blobId", "threadId", "mailboxIds", "keywords", "size",
+ // "receivedAt", "messageId", "inReplyTo", "references", "sender", "from",
+ // "to", "cc", "bcc", "replyTo", "subject", "sentAt", "hasAttachment",
+ // "preview", "bodyValues", "textBody", "htmlBody", "attachments" ]
+ //
+ // 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"`
+
+ // A list of properties to fetch for each EmailBodyPart returned.
+ //
+ // If omitted, this defaults to:
+ //
+ // [ "partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location" ]
+ //
+ BodyProperties []string `json:"bodyProperties,omitempty"`
+
+ // (default: false) If true, the bodyValues property includes any text/* part in the textBody property.
+ FetchTextBodyValues bool `json:"fetchTextBodyValues,omitzero"`
+
+ // (default: false) If true, the bodyValues property includes any text/* part in the htmlBody property.
+ FetchHTMLBodyValues bool `json:"fetchHTMLBodyValues,omitzero"`
+
+ // (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
+ // MUST be truncated if necessary so it does not exceed this number of octets in size.
+ //
+ // If 0 (the default), no truncation occurs.
+ //
+ // The server MUST ensure the truncation results in valid UTF-8 and does not occur mid-codepoint.
+ //
+ // If the part is of type text/html, the server SHOULD NOT truncate inside an HTML tag, e.g., in
+ // the middle of .
+ //
+ // There is no requirement for the truncated form to be a balanced tree or valid HTML (indeed, the original
+ // source may well be neither of these things).
+ MaxBodyValueBytes int `json:"maxBodyValueBytes,omitempty"`
+}
+
+type EmailChangesCommand struct {
+ // 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 Email/get response.
+ // The server will return the changes that have occurred since this state.
+ SinceState string `json:"sinceState,omitzero,omitempty"`
+
+ // 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.
+ MaxChanges int `json:"maxChanges,omitzero"`
}
type EmailAddress struct {
- Name string `json:"name,omitempty"`
+ // The display-name of the mailbox [RFC5322].
+ //
+ // If this is a quoted-string:
+ // 1. The surrounding DQUOTE characters are removed.
+ // 2. Any quoted-pair is decoded.
+ // 3. White space is unfolded, and then any leading and trailing white space is removed.
+ // If there is no display-name but there is a comment immediately following the addr-spec, the value of this
+ // SHOULD be used instead. Otherwise, this property is null.
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+ Name string `json:"name,omitempty"`
+
+ // The addr-spec of the mailbox [RFC5322].
+ //
+ // Any syntactically correct encoded sections [RFC2047] with a known encoding MUST be decoded,
+ // following the same rules as for the Text form.
+ //
+ // Parsing SHOULD be best effort in the face of invalid structure to accommodate invalid messages and
+ // semi-complete drafts. EmailAddress objects MAY have an email property that does not conform to the
+ // addr-spec form (for example, may not contain an @ symbol).
+ //
+ // [RFC2047]: https://www.rfc-editor.org/rfc/rfc2047.html
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
Email string `json:"email,omitempty"`
}
-type EmailBodyRef struct {
- PartId string `json:"partId,omitempty"`
- BlobId string `json:"blobId,omitempty"`
- Size int `json:"size,omitempty"`
- Name string `json:"name,omitempty"`
- Type string `json:"type,omitempty"`
- Charset string `json:"charset,omitempty"`
- Disposition string `json:"disposition,omitempty"`
- Cid string `json:"cid,omitempty"`
- Language string `json:"language,omitempty"`
- Location string `json:"location,omitempty"`
+type EmailAddressGroup struct {
+ // The display-name of the group [RFC5322], or null if the addresses are not part of a group.
+ //
+ // If this is a quoted-string, it is processed the same as the name in the EmailAddress type.
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+
+ Name string `json:"name,omitempty"`
+
+ // The mailbox values that belong to this group, represented as EmailAddress objects.
+ Addresses []EmailAddress `json:"addresses,omitempty"`
}
-type EmailBody struct {
- IsEncodingProblem bool `json:"isEncodingProblem,omitempty"`
- IsTruncated bool `json:"isTruncated,omitempty"`
- Value string `json:"value,omitempty"`
+type EmailHeader struct {
+ // The header field name as defined in [RFC5322], with the same capitalization that it has in the message.
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+ Name string `json:"name"`
+
+ // The header field value as defined in [RFC5322], in Raw form.
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+ Value string `json:"value"`
}
+
+type EmailBodyPart struct {
+ // Identifies this part uniquely within the Email.
+ //
+ // This is scoped to the emailId and has no meaning outside of the JMAP Email object representation.
+ // This is null if, and only if, the part is of type multipart/*.
+ PartId string `json:"partId,omitempty"`
+
+ // The id representing the raw octets of the contents of the part, after decoding any known
+ // Content-Transfer-Encoding (as defined in [RFC2045]), or null if, and only if, the part is of type multipart/*.
+ //
+ // Note that two parts may be transfer-encoded differently but have the same blob id if their decoded octets are identical
+ // and the server is using a secure hash of the data for the blob id.
+ // If the transfer encoding is unknown, it is treated as though it had no transfer encoding.
+ //
+ // [RFC2045]: https://www.rfc-editor.org/rfc/rfc2045.html
+ BlobId string `json:"blobId,omitempty"`
+
+ // The size, in octets, of the raw data after content transfer decoding (as referenced by the blobId, i.e.,
+ // the number of octets in the file the user would download).
+ Size int `json:"size,omitempty"`
+
+ // This is a list of all header fields in the part, in the order they appear in the message.
+ //
+ // The values are in Raw form.
+ Headers []EmailHeader `json:"headers,omitempty"`
+
+ // This is the decoded filename parameter of the Content-Disposition header field per [RFC2231], or
+ // (for compatibility with existing systems).
+ //
+ // If not present, then it’s the decoded name parameter of the Content-Type header field per [RFC2047].
+ //
+ // [RFC2231]: https://www.rfc-editor.org/rfc/rfc2231.html
+ // [RFC2047]: https://www.rfc-editor.org/rfc/rfc2047.html
+ Name string `json:"name,omitempty"`
+
+ // The value of the Content-Type header field of the part, if present; otherwise, the implicit type as per
+ // the MIME standard (text/plain or message/rfc822 if inside a multipart/digest).
+ //
+ // CFWS is removed and any parameters are stripped.
+ Type string `json:"type,omitempty"`
+
+ // The value of the charset parameter of the Content-Type header field, if present, or null if the header
+ // field is present but not of type text/*.
+ //
+ // If there is no Content-Type header field, or it exists and is of type text/* but has no charset parameter,
+ // this is the implicit charset as per the MIME standard: us-ascii.
+ Charset string `json:"charset,omitempty"`
+
+ // The value of the Content-Disposition header field of the part, if present;
+ // otherwise, it’s null.
+ //
+ // CFWS is removed and any parameters are stripped.
+ Disposition string `json:"disposition,omitempty"`
+
+ // The value of the Content-Id header field of the part, if present; otherwise it’s null.
+ //
+ // CFWS and surrounding angle brackets (<>) are removed.
+ // This may be used to reference the content from within a text/html body part HTML using the cid: protocol, as defined in [RFC2392].
+ //
+ // [RFC2392]: https://www.rfc-editor.org/rfc/rfc2392.html
+ Cid string `json:"cid,omitempty"`
+
+ // The list of language tags, as defined in [RFC3282], in the Content-Language header field of the part, if present.
+ //
+ // [RFC3282]: https://www.rfc-editor.org/rfc/rfc3282.html
+ Language string `json:"language,omitempty"`
+
+ // The URI, as defined in [RFC2557], in the Content-Location header field of the part, if present.
+ //
+ // [RFC2557]: https://www.rfc-editor.org/rfc/rfc2557.html
+ Location string `json:"location,omitempty"`
+
+ // If the type is multipart/*, this contains the body parts of each child.
+ SubParts []EmailBodyPart `json:"subParts,omitempty"`
+}
+
+type EmailBodyValue struct {
+ // The value of the body part after decoding Content-Transfer-Encoding and the Content-Type charset,
+ // if both known to the server, and with any CRLF replaced with a single LF.
+ //
+ // The server MAY use heuristics to determine the charset to use for decoding if the charset is unknown,
+ // no charset is given, or it believes the charset given is incorrect.
+ //
+ // Decoding is best effort; the server SHOULD insert the unicode replacement character (U+FFFD) and continue
+ // when a malformed section is encountered.
+ //
+ // Note that due to the charset decoding and line ending normalisation, the length of this string will
+ // 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,
+ // or the charset was unknown, or the content-transfer-encoding was unknown.
+ IsEncodingProblem bool `json:"isEncodingProblem,omitzero"`
+
+ // (default: false) This is true if the value has been truncated.
+ IsTruncated bool `json:"isTruncated,omitzero"`
+}
+
type Email struct {
- Id string `json:"id,omitempty"`
- MessageId []string `json:"messageId,omitempty"`
- BlobId string `json:"blobId,omitempty"`
- ThreadId string `json:"threadId,omitempty"`
- Size int `json:"size,omitempty"`
- From []EmailAddress `json:"from,omitempty"`
- To []EmailAddress `json:"to,omitempty"`
- Cc []EmailAddress `json:"cc,omitempty"`
- Bcc []EmailAddress `json:"bcc,omitempty"`
- ReplyTo []EmailAddress `json:"replyTo,omitempty"`
- Subject string `json:"subject,omitempty"`
- HasAttachments bool `json:"hasAttachments,omitempty"`
- ReceivedAt time.Time `json:"receivedAt,omitempty"`
- SentAt time.Time `json:"sentAt,omitempty"`
- Preview string `json:"preview,omitempty"`
- BodyValues map[string]EmailBody `json:"bodyValues,omitempty"`
- TextBody []EmailBodyRef `json:"textBody,omitempty"`
- HtmlBody []EmailBodyRef `json:"htmlBody,omitempty"`
- Keywords map[string]bool `json:"keywords,omitempty"`
- MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
+ // 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
+ Id string `json:"id,omitempty"`
+
+ // The id representing the raw octets of the message [RFC5322] for this Email.
+ //
+ // 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
+ BlobId string `json:"blobId,omitempty"`
+
+ // The id of the Thread to which this Email belongs.
+ ThreadId string `json:"threadId,omitempty"`
+
+ // The set of Mailbox ids this Email belongs to.
+ //
+ // An Email in the mail store MUST belong to one or more Mailboxes at all times (until it is destroyed).
+ // 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.
+ MailboxIds map[string]bool `json:"mailboxIds,omitempty"`
+
+ // A set of keywords that apply to the Email.
+ //
+ // The set is represented as an object, with the keys being the keywords.
+ //
+ // The value for each key in the object MUST be true.
+ //
+ // Keywords are shared with IMAP.
+ //
+ // The six system keywords from IMAP get special treatment.
+ //
+ // The following four keywords have their first character changed from \ in IMAP to $ in JMAP and have particular semantic meaning:
+ //
+ // - $draft: The Email is a draft the user is composing.
+ // - $seen: The Email has been read.
+ // - $flagged: The Email has been flagged for urgent/special attention.
+ // - $answered: The Email has been replied to.
+ //
+ // The IMAP \Recent keyword is not exposed via JMAP. The IMAP \Deleted keyword is also not present: IMAP uses a delete+expunge model,
+ // which JMAP does not. Any message with the \Deleted keyword MUST NOT be visible via JMAP (and so are not counted in the
+ // “totalEmails”, “unreadEmails”, “totalThreads”, and “unreadThreads” Mailbox properties).
+ //
+ // Users may add arbitrary keywords to an Email.
+ // For compatibility with IMAP, a keyword is a case-insensitive string of 1–255 characters in the ASCII subset
+ // %x21–%x7e (excludes control chars and space), and it MUST NOT include any of these characters:
+ //
+ // ( ) { ] % * " \
+ //
+ // Because JSON is case sensitive, servers MUST return keywords in lowercase.
+ //
+ // The [IMAP and JMAP Keywords] registry as established in [RFC5788] assigns semantic meaning to some other
+ // keywords in common use.
+ //
+ // New keywords may be established here in the future. In particular, note:
+ //
+ // - $forwarded: The Email has been forwarded.
+ // - $phishing: The Email is highly likely to be phishing.
+ // Clients SHOULD warn users to take care when viewing this Email and disable links and attachments.
+ // - $junk: The Email is definitely spam.
+ // Clients SHOULD set this flag when users report spam to help train automated spam-detection systems.
+ // - $notjunk: The Email is definitely not spam.
+ // Clients SHOULD set this flag when users indicate an Email is legitimate, to help train automated spam-detection systems.
+ //
+ // [IMAP and JMAP Keywords]: https://www.iana.org/assignments/imap-jmap-keywords/
+ // [RFC5788]: https://www.rfc-editor.org/rfc/rfc5788.html
+ Keywords map[string]bool `json:"keywords,omitempty"`
+
+ // The size, in octets, of the raw data for the message [RFC5322]
+ // (as referenced by the blobId, i.e., the number of octets in the file the user would download).
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+ Size int `json:"size"`
+
+ // The date the Email was received by the message store.
+ //
+ // This is the internal date in IMAP [RFC3501].
+ //
+ // [RFC3501]: https://www.rfc-editor.org/rfc/rfc3501.html
+ ReceivedAt time.Time `json:"receivedAt,omitempty"`
+
+ // This is a list of all header fields [RFC5322], in the same order they appear in the message.
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+ Headers []EmailHeader `json:"headers,omitempty"`
+
+ // The value is identical to the value of header:Message-ID:asMessageIds.
+ //
+ // For messages conforming to [RFC5322] this will be an array with a single entry.
+ //
+ // [RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html
+ MessageId []string `json:"messageId,omitempty"`
+
+ // The value is identical to the value of header:In-Reply-To:asMessageIds.
+ InReplyTo []string `json:"inReplyTo,omitempty"`
+
+ // The value is identical to the value of header:References:asMessageIds.
+ References []string `json:"references,omitempty"`
+
+ // The value is identical to the value of header:Sender:asAddresses.
+ Sender []EmailAddress `json:"sender,omitempty"`
+
+ // The value is identical to the value of header:From:asAddresses.
+ From []EmailAddress `json:"from,omitempty"`
+
+ // The value is identical to the value of header:To:asAddresses.
+ To []EmailAddress `json:"to,omitempty"`
+
+ // The value is identical to the value of header:Cc:asAddresses.
+ Cc []EmailAddress `json:"cc,omitempty"`
+
+ // The value is identical to the value of header:Bcc:asAddresses.
+ Bcc []EmailAddress `json:"bcc,omitempty"`
+
+ // The value is identical to the value of header:Reply-To:asAddresses.
+ ReplyTo []EmailAddress `json:"replyTo,omitempty"`
+
+ // The value is identical to the value of header:Subject:asText.
+ Subject string `json:"subject,omitempty"`
+
+ // The value is identical to the value of header:Date:asDate.
+ SentAt time.Time `json:"sentAt,omitempty"`
+
+ // This is the full MIME structure of the message body, without recursing into message/rfc822 or message/global parts.
+ //
+ // Note that EmailBodyParts may have subParts if they are of type multipart/*.
+ BodyStructure EmailBodyPart `json:"bodyStructure,omitzero"`
+
+ // This is a map of partId to an EmailBodyValue object for none, some, or all text/* parts.
+ //
+ // Which parts are included and whether the value is truncated is determined by various arguments to Email/get and Email/parse.
+ BodyValues map[string]EmailBodyValue `json:"bodyValues,omitempty"`
+
+ // A list of text/plain, text/html, image/*, audio/*, and/or video/* parts to display (sequentially) as the
+ // message body, with a preference for text/plain when alternative versions are available.
+ TextBody []EmailBodyPart `json:"textBody,omitempty"`
+
+ // A list of text/plain, text/html, image/*, audio/*, and/or video/* parts to display (sequentially) as the
+ // message body, with a preference for text/html when alternative versions are available.
+ HtmlBody []EmailBodyPart `json:"htmlBody,omitempty"`
+
+ // A list, traversing depth-first, of all parts in bodyStructure.
+ //
+ // They must satisfy either of the following conditions:
+ //
+ // - not of type multipart/* and not included in textBody or htmlBody
+ // - of type image/*, audio/*, or video/* and not in both textBody and htmlBody
+ //
+ // None of these parts include subParts, including message/* types.
+ //
+ // Attached messages may be fetched using the Email/parse method and the blobId.
+ //
+ // Note that a text/html body part HTML may reference image parts in attachments by using cid:
+ // links to reference the Content-Id, as defined in [RFC2392], or by referencing the Content-Location.
+ //
+ // [RFC2392]: https://www.rfc-editor.org/rfc/rfc2392.html
+ Attachments []EmailBodyPart `json:"attachments,omitempty"`
+
+ // This is true if there are one or more parts in the message that a client UI should offer as downloadable.
+ //
+ // A server SHOULD set hasAttachment to true if the attachments list contains at least one item that
+ // does not have Content-Disposition: inline.
+ //
+ // The server MAY ignore parts in this list that are processed automatically in some way or are referenced
+ // as embedded images in one of the text/html parts of the message.
+ //
+ // The server MAY set hasAttachment based on implementation-defined or site-configurable heuristics.
+ HasAttachment bool `json:"hasAttachment,omitempty"`
+
+ // A plaintext fragment of the message body.
+ //
+ // This is intended to be shown as a preview line when listing messages in the mail store and may be truncated
+ // when shown.
+ //
+ // The server may choose which part of the message to include in the preview; skipping quoted sections and
+ // salutations and collapsing white space can result in a more useful preview.
+ //
+ // This MUST NOT be more than 256 characters in length.
+ //
+ // As this is derived from the message content by the server, and the algorithm for doing so could change over
+ // time, fetching this for an Email a second time MAY return a different result.
+ // However, the previous value is not considered incorrect, and the change SHOULD NOT cause the Email object
+ // to be considered as changed by the server.
+ Preview string `json:"preview,omitempty"`
}
type Command string
-const (
- EmailGet Command = "Email/get"
- EmailQuery Command = "Email/query"
- EmailSet Command = "Email/set"
- ThreadGet Command = "Thread/get"
- MailboxGet Command = "Mailbox/get"
- MailboxQuery Command = "Mailbox/query"
- IdentityGet Command = "Identity/get"
- VacationResponseGet Command = "VacationResponse/get"
-)
-
type Invocation struct {
Command Command
Parameters any
@@ -360,9 +871,17 @@ func invocation(command Command, parameters any, tag string) Invocation {
}
type Request struct {
- Using []string `json:"using"`
- MethodCalls []Invocation `json:"methodCalls"`
- CreatedIds map[string]string `json:"createdIds,omitempty"`
+ // 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.
+ //
+ // [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.
+ CreatedIds map[string]string `json:"createdIds,omitempty"`
}
func request(methodCalls ...Invocation) (Request, error) {
@@ -374,39 +893,189 @@ func request(methodCalls ...Invocation) (Request, error) {
}
type Response struct {
- MethodResponses []Invocation `json:"methodResponses"`
- CreatedIds map[string]string `json:"createdIds,omitempty"`
- SessionState string `json:"sessionState"`
+ // 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.
+ // 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.
+ //
+ // [Section 2]: https://jmap.io/spec-core.html#the-jmap-session-resource
+ SessionState string `json:"sessionState"`
}
type EmailQueryResponse struct {
- AccountId string `json:"accountId"`
- QueryState string `json:"queryState"`
- CanCalculateChanges bool `json:"canCalculateChanges"`
- Position int `json:"position"`
- Ids []string `json:"ids"`
- Total int `json:"total"`
+ // 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).
+ // 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.
+ // 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"`
}
+
type EmailGetResponse struct {
- AccountId string `json:"accountId"`
- State string `json:"state"`
- List []Email `json:"list"`
- NotFound []any `json:"notFound"`
+ // 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"`
+}
+
+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"`
}
type MailboxGetResponse struct {
- AccountId string `json:"accountId"`
- State string `json:"state"`
- List []Mailbox `json:"list"`
- NotFound []any `json:"notFound"`
+ // 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 Mailbox 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 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"`
+}
+
+type MailboxChangesResponse 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 Mailbox/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"`
+
+ // If only the “totalEmails”, “unreadEmails”, “totalThreads”, and/or “unreadThreads” Mailbox properties have
+ // changed since the old state, this will be the list of properties that may have changed.
+ //
+ // If the server is unable to tell if only counts have changed, it MUST just be null.
+ UpdatedProperties []string `json:"updatedProperties,omitempty"`
}
type MailboxQueryResponse struct {
- AccountId string `json:"accountId"`
- QueryState string `json:"queryState"`
- CanCalculateChanges bool `json:"canCalculateChanges"`
- Position int `json:"position"`
- Ids []string `json:"ids"`
+ // 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 Mailbox/queryChanges to get the difference.
+ QueryState string `json:"queryState"`
+
+ // This is true if the server supports calling Mailbox/queryChanges with these filter/sort parameters.
+ //
+ // Note, this does not guarantee that the Mailbox/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 Mailbox 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 Mailbox in the results (given the filter).
+ //
+ // 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.
+ //
+ // 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"`
}
type EmailBodyStructure struct {
@@ -422,7 +1091,7 @@ type EmailCreate struct {
Subject string `json:"subject,omitempty"`
ReceivedAt time.Time `json:"receivedAt,omitzero"`
SentAt time.Time `json:"sentAt,omitzero"`
- BodyStructure EmailBodyStructure `json:"bodyStructure,omitempty"`
+ BodyStructure EmailBodyStructure `json:"bodyStructure"`
}
type EmailSetCommand struct {
@@ -433,6 +1102,38 @@ type EmailSetCommand struct {
type EmailSetResponse struct {
}
+const (
+ EmailMimeType = "message/rfc822"
+)
+
+type EmailToImport struct {
+ BlobId string `json:"blobId"`
+ MailboxIds map[string]bool `json:"mailboxIds"`
+ Keywords map[string]bool `json:"keywords"`
+ ReceivedAt time.Time `json:"receivedAt"`
+}
+
+type EmailImportCommand struct {
+ AccountId string `json:"accountId"`
+ IfInState string `json:"ifInState,omitempty"`
+ Emails map[string]EmailToImport `json:"emails"`
+}
+
+type ImportedEmail struct {
+ Id string `json:"id"`
+ BlobId string `json:"blobId"`
+ ThreadId string `json:"threadId"`
+ 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"`
+}
+
type Thread struct {
Id string
EmailIds []string
@@ -513,10 +1214,113 @@ type VacationResponseGetResponse struct {
NotFound []any `json:"notFound,omitempty"`
}
+// One of these attributes must be set, but not both.
+type DataSourceObject struct {
+ DataAsText string `json:"data:asText,omitempty"`
+ DataAsBase64 string `json:"data:asBase64,omitempty"`
+}
+
+type UploadObject struct {
+ Data []DataSourceObject `json:"data"`
+ Type string `json:"type,omitempty"`
+}
+
+type BlobUploadCommand struct {
+ AccountId string `json:"accountId"`
+ Create map[string]UploadObject `json:"create"`
+}
+
+type BlobUploadCreateResult struct {
+ Id string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Size int `json:"size"`
+}
+
+type BlobUploadResponse struct {
+ AccountId string `json:"accountId"`
+ Created map[string]BlobUploadCreateResult `json:"created"`
+}
+
+const (
+ BlobPropertyDataAsText = "data:asText"
+ BlobPropertyDataAsBase64 = "data:asBase64"
+ BlobPropertyData = "data"
+ BlobPropertySize = "size"
+ // https://www.iana.org/assignments/http-digest-hash-alg/http-digest-hash-alg.xhtml
+ BlobPropertyDigestSha256 = "digest:sha256"
+ // https://www.iana.org/assignments/http-digest-hash-alg/http-digest-hash-alg.xhtml
+ BlobPropertyDigestSha512 = "digest:sha512"
+)
+
+type BlobGetCommand struct {
+ AccountId string `json:"accountId"`
+ Ids []string `json:"ids,omitempty"`
+ Properties []string `json:"properties,omitempty"`
+ Offset int `json:"offset,omitzero"`
+ Length int `json:"length,omitzero"`
+}
+
+type BlobGetRefCommand struct {
+ AccountId string `json:"accountId"`
+ IdRef *ResultReference `json:"#ids,omitempty"`
+ Properties []string `json:"properties,omitempty"`
+ Offset int `json:"offset,omitzero"`
+ Length int `json:"length,omitzero"`
+}
+
+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"`
+}
+
+// Picks the best digest if available, or ""
+func (b *Blob) Digest() string {
+ if b.DigestSha512 != "" {
+ return b.DigestSha512
+ } else if b.DigestSha256 != "" {
+ return b.DigestSha256
+ } else {
+ return ""
+ }
+}
+
+type BlobGetResponse struct {
+ AccountId string `json:"accountId"`
+ State string `json:"state,omitempty"`
+ List []Blob `json:"list,omitempty"`
+ NotFound []any `json:"notFound,omitempty"`
+}
+
+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"
+ ThreadGet Command = "Thread/get"
+ MailboxGet Command = "Mailbox/get"
+ MailboxQuery Command = "Mailbox/query"
+ MailboxChanges Command = "Mailbox/changes"
+ IdentityGet Command = "Identity/get"
+ VacationResponseGet Command = "VacationResponse/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{} },
ThreadGet: func() any { return ThreadGetResponse{} },
IdentityGet: func() any { return IdentityGetResponse{} },
diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go
index ed531f404d..147a17a164 100644
--- a/pkg/jmap/jmap_test.go
+++ b/pkg/jmap/jmap_test.go
@@ -122,11 +122,11 @@ func TestRequests(t *testing.T) {
{
email := emails.Emails[0]
require.Equal("Ornare Senectus Ultrices Elit", email.Subject)
- require.Equal(false, email.HasAttachments)
+ require.Equal(false, email.HasAttachment)
}
{
email := emails.Emails[1]
require.Equal("Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", email.Subject)
- require.Equal(false, email.HasAttachments)
+ require.Equal(false, email.HasAttachment)
}
}
diff --git a/services/groupware/pkg/groupware/groupware_api_blob.go b/services/groupware/pkg/groupware/groupware_api_blob.go
new file mode 100644
index 0000000000..33e5ae23b7
--- /dev/null
+++ b/services/groupware/pkg/groupware/groupware_api_blob.go
@@ -0,0 +1,25 @@
+package groupware
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func (g Groupware) GetBlob(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ blobId := chi.URLParam(req.r, UriParamBlobId)
+ if blobId == "" {
+ errorId := req.errorId()
+ msg := fmt.Sprintf("Invalid value for path parameter '%v': empty", UriParamBlobId)
+ return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ withDetail(msg),
+ withSource(&ErrorSource{Parameter: UriParamBlobId}),
+ )
+ }
+
+ res, err := g.jmap.GetBlob(req.GetAccountId(), req.session, req.ctx, req.logger, blobId)
+ return res, res.Digest(), req.apiErrorFromJmap(err)
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_api_mailbox.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go
index 0ec91b5227..941ba1b43c 100644
--- a/services/groupware/pkg/groupware/groupware_api_mailbox.go
+++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go
@@ -106,6 +106,7 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) {
if subscribed != "" {
b, err := strconv.ParseBool(subscribed)
if err != nil {
+ // TODO proper response object
w.WriteHeader(http.StatusBadRequest)
return
}
diff --git a/services/groupware/pkg/groupware/groupware_api_messages.go b/services/groupware/pkg/groupware/groupware_api_messages.go
index d9451fcb3a..e8f1120f7f 100644
--- a/services/groupware/pkg/groupware/groupware_api_messages.go
+++ b/services/groupware/pkg/groupware/groupware_api_messages.go
@@ -12,45 +12,70 @@ import (
func (g Groupware) GetAllMessages(w http.ResponseWriter, r *http.Request) {
mailboxId := chi.URLParam(r, UriParamMailboxId)
- g.respond(w, r, func(req Request) (any, string, *Error) {
- if mailboxId == "" {
- errorId := req.errorId()
- msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
- return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
- withDetail(msg),
- withSource(&ErrorSource{Parameter: UriParamMailboxId}),
- )
- }
- page, ok, err := req.parseNumericParam(QueryParamPage, -1)
- if err != nil {
- return nil, "", err
- }
- logger := req.logger
- if ok {
- logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()}
- }
+ since := r.Header.Get(HeaderSince)
- size, ok, err := req.parseNumericParam(QueryParamSize, -1)
- if err != nil {
- return nil, "", err
- }
- if ok {
- logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()}
- }
+ if since != "" {
+ // ... then it's a completely different operation
+ maxChanges := -1
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ if mailboxId == "" {
+ errorId := req.errorId()
+ msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
+ return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ withDetail(msg),
+ withSource(&ErrorSource{Parameter: UriParamMailboxId}),
+ )
+ }
+ logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
- offset := page * size
- limit := size
- if limit < 0 {
- limit = g.defaultEmailLimit
- }
+ emails, jerr := g.jmap.GetEmailsInMailboxSince(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, since, true, g.maxBodyValueBytes, maxChanges)
+ if jerr != nil {
+ return nil, "", req.apiErrorFromJmap(jerr)
+ }
- emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
- if jerr != nil {
- return nil, "", req.apiErrorFromJmap(jerr)
- }
+ return emails, emails.State, nil
+ })
+ } else {
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ if mailboxId == "" {
+ errorId := req.errorId()
+ msg := fmt.Sprintf("Missing required mailbox ID path parameter '%v'", UriParamMailboxId)
+ return nil, "", apiError(errorId, ErrorInvalidRequestParameter,
+ withDetail(msg),
+ withSource(&ErrorSource{Parameter: UriParamMailboxId}),
+ )
+ }
+ page, ok, err := req.parseNumericParam(QueryParamPage, -1)
+ if err != nil {
+ return nil, "", err
+ }
+ logger := req.logger
+ if ok {
+ logger = &log.Logger{Logger: logger.With().Int(QueryParamPage, page).Logger()}
+ }
- return emails, emails.State, nil
- })
+ size, ok, err := req.parseNumericParam(QueryParamSize, -1)
+ if err != nil {
+ return nil, "", err
+ }
+ if ok {
+ logger = &log.Logger{Logger: logger.With().Int(QueryParamSize, size).Logger()}
+ }
+
+ offset := page * size
+ limit := size
+ if limit < 0 {
+ limit = g.defaultEmailLimit
+ }
+
+ emails, jerr := g.jmap.GetAllEmails(req.GetAccountId(), req.session, req.ctx, logger, mailboxId, offset, limit, true, g.maxBodyValueBytes)
+ if jerr != nil {
+ return nil, "", req.apiErrorFromJmap(jerr)
+ }
+
+ return emails, emails.State, nil
+ })
+ }
}
func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
@@ -75,3 +100,22 @@ func (g Groupware) GetMessagesById(w http.ResponseWriter, r *http.Request) {
return emails, emails.State, nil
})
}
+
+func (g Groupware) GetMessageUpdates(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ since := q.Get(QueryParamSince)
+ if since == "" {
+ since = r.Header.Get("If-None-Match")
+ }
+ maxChanges := -1
+ g.respond(w, r, func(req Request) (any, string, *Error) {
+ logger := &log.Logger{Logger: req.logger.With().Str(HeaderSince, since).Logger()}
+
+ emails, jerr := g.jmap.GetEmailsSince(req.GetAccountId(), req.session, req.ctx, logger, since, true, g.maxBodyValueBytes, maxChanges)
+ if jerr != nil {
+ return nil, "", req.apiErrorFromJmap(jerr)
+ }
+
+ return emails, emails.State, nil
+ })
+}
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index f00e020005..a1f24d6d4b 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -10,6 +10,9 @@ const (
QueryParamPage = "page"
QueryParamSize = "size"
UriParamMessagesId = "id"
+ UriParamBlobId = "blobid"
+ QueryParamSince = "since"
+ HeaderSince = "if-none-match"
)
func (g Groupware) Route(r chi.Router) {
@@ -21,8 +24,10 @@ func (g Groupware) Route(r chi.Router) {
r.Get("/mailboxes/{mailbox}", g.GetMailbox)
r.Get("/mailboxes/{mailbox}/messages", g.GetAllMessages)
r.Get("/messages/{id}", g.GetMessagesById)
+ r.Get("/messages", g.GetMessageUpdates)
r.Get("/identity", g.GetIdentity)
r.Get("/vacation", g.GetVacation)
+ r.Get("/blobs/{blobid}", g.GetBlob)
})
r.NotFound(g.NotFound)
}