diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go index 285412a13..363f00eb7 100644 --- a/pkg/jmap/jmap.go +++ b/pkg/jmap/jmap.go @@ -47,9 +47,14 @@ func NewClient(wellKnown SessionClient, api ApiClient) Client { // instead of being part of the Session, since the username is always part of the request (typically in // the authentication token payload.) type Session struct { - Username string // The name of the user to use to authenticate against Stalwart - AccountId string // The identifier of the account to use when performing JMAP operations with Stalwart - JmapUrl url.URL // The base URL to use for JMAP operations towards Stalwart + // 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 + // TODO + MailAccountId string + + SessionResponse } const ( @@ -75,7 +80,7 @@ const ( // Create a new log.Logger that is decorated with fields containing information about the Session. func (s Session) DecorateLogger(l log.Logger) log.Logger { return log.Logger{ - Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.AccountId).Logger(), + Logger: l.With().Str(logUsername, s.Username).Str(logAccountId, s.MailAccountId).Logger(), } } @@ -85,8 +90,8 @@ func NewSession(sessionResponse SessionResponse) (Session, Error) { if username == "" { return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide a username")} } - accountId := sessionResponse.PrimaryAccounts[JmapMail] - if accountId == "" { + 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 @@ -98,9 +103,10 @@ func NewSession(sessionResponse SessionResponse) (Session, Error) { return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response provides an invalid API URL")} } return Session{ - Username: username, - AccountId: accountId, - JmapUrl: *apiUrl, + Username: username, + MailAccountId: mailAccountId, + JmapUrl: *apiUrl, + SessionResponse: sessionResponse, }, nil } @@ -114,18 +120,18 @@ func (j *Client) FetchSession(username string, logger *log.Logger) (Session, Err } func (j *Client) logger(operation string, session *Session, logger *log.Logger) *log.Logger { - return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.AccountId).Logger()} + return &log.Logger{Logger: logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId).Logger()} } func (j *Client) loggerParams(operation string, session *Session, logger *log.Logger, params func(zerolog.Context) zerolog.Context) *log.Logger { - base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.AccountId) + base := logger.With().Str(logOperation, operation).Str(logUsername, session.Username).Str(logAccountId, session.MailAccountId) return &log.Logger{Logger: params(base).Logger()} } // https://jmap.io/spec-mail.html#identityget func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log.Logger) (IdentityGetResponse, Error) { logger = j.logger("GetIdentity", session, logger) - cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0")) + cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.MailAccountId}, "0")) if err != nil { return IdentityGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } @@ -139,7 +145,7 @@ func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log. // https://jmap.io/spec-mail.html#vacationresponseget func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, Error) { logger = j.logger("GetVacationResponse", session, logger) - cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0")) + cmd, err := request(invocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.MailAccountId}, "0")) if err != nil { return VacationResponseGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } @@ -153,7 +159,7 @@ func (j *Client) GetVacationResponse(session *Session, ctx context.Context, logg // https://jmap.io/spec-mail.html#mailboxget func (j *Client) GetMailbox(session *Session, ctx context.Context, logger *log.Logger, ids []string) (MailboxGetResponse, Error) { logger = j.logger("GetMailbox", session, logger) - cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId, Ids: ids}, "0")) + cmd, err := request(invocation(MailboxGet, MailboxGetCommand{AccountId: session.MailAccountId, Ids: ids}, "0")) if err != nil { return MailboxGetResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } @@ -171,7 +177,7 @@ func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger * // https://jmap.io/spec-mail.html#mailboxquery func (j *Client) QueryMailbox(session *Session, ctx context.Context, logger *log.Logger, filter MailboxFilterCondition) (MailboxQueryResponse, Error) { logger = j.logger("QueryMailbox", session, logger) - cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0")) + cmd, err := request(invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0")) if err != nil { return MailboxQueryResponse{}, SimpleError{code: JmapErrorInvalidJmapRequestPayload, err: err} } @@ -191,9 +197,9 @@ func (j *Client) SearchMailboxes(session *Session, ctx context.Context, logger * logger = j.logger("SearchMailboxes", session, logger) cmd, err := request( - invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.AccountId, Filter: filter}, "0"), + invocation(MailboxQuery, SimpleMailboxQueryCommand{AccountId: session.MailAccountId, Filter: filter}, "0"), invocation(MailboxGet, MailboxGetRefCommand{ - AccountId: session.AccountId, + AccountId: session.MailAccountId, IdRef: &Ref{Name: MailboxQuery, Path: "/ids/*", ResultOf: "0"}, }, "1"), ) @@ -222,7 +228,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo }) query := EmailQueryCommand{ - AccountId: session.AccountId, + AccountId: session.MailAccountId, Filter: &MessageFilter{InMailbox: mailboxId}, Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, @@ -236,7 +242,7 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo } get := EmailGetRefCommand{ - AccountId: session.AccountId, + AccountId: session.MailAccountId, FetchAllBodyValues: fetchBodies, IdRef: &Ref{Name: EmailQuery, Path: "/ids/*", ResultOf: "0"}, } diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index c11cebe61..808b52cbb 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -11,6 +11,11 @@ const ( JmapSubmission = "urn:ietf:params:jmap:submission" JmapVacationResponse = "urn:ietf:params:jmap:vacationresponse" JmapCalendars = "urn:ietf:params:jmap:calendars" + JmapSieve = "urn:ietf:params:jmap:sieve" + JmapBlob = "urn:ietf:params:jmap:blob" + JmapQuota = "urn:ietf:params:jmap:quota" + JmapWebsocket = "urn:ietf:params:jmap:websocket" + JmapKeywordPrefix = "$" JmapKeywordSeen = "$seen" JmapKeywordDraft = "$draft" @@ -23,17 +28,119 @@ const ( JmapKeywordMdnSent = "$mdnsent" ) +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"` +} + +type SessionSubmissionAccountCapabilities struct { + MaxDelayedSend int `json:"maxDelayedSend"` + SubmissionExtensions map[string][]string `json:"submissionExtensions"` +} + +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"` + NotificationMethods []string `json:"notificationMethods"` + ExternalLists any `json:"externalLists"` // ? +} + +type SessionBlobAccountCapabilities struct { + MaxSizeBlobSet int `json:"maxSizeBlobSet"` + MaxDataSources int `json:"maxDataSources"` + SupportedTypeNames []string `json:"supportedTypeNames"` + SupportedDigestAlgorithms []string `json:"supportedDigestAlgorithms"` +} + +type SessionQuotaAccountCapabilities struct { +} + +type SessionAccountCapabilities struct { + Mail SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail"` + Submission SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission"` + VacationResponse SessionVacationResponseAccountCapabilities `json:"urn:ietf:params:jmap:vacationresponse"` + Sieve SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve"` + Blob SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob"` + Quota SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota"` +} + type SessionAccount struct { - Name string `json:"name,omitempty"` - IsPersonal bool `json:"isPersonal"` - IsReadOnly bool `json:"isReadOnly"` - AccountCapabilities map[string]any `json:"accountCapabilities,omitempty"` + Name string `json:"name,omitempty"` + IsPersonal bool `json:"isPersonal"` + IsReadOnly bool `json:"isReadOnly"` + AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities,omitempty"` +} + +type SessionCoreCapabilities struct { + MaxSizeUpload int `json:"maxSizeUpload"` + MaxConcurrentUpload int `json:"maxConcurrentUpload"` + MaxSizeRequest int `json:"maxSizeRequest"` + MaxConcurrentRequests int `json:"maxConcurrentRequests"` + MaxCallsInRequest int `json:"maxCallsInRequest"` + MaxObjectsInGet int `json:"maxObjectsInGet"` + MaxObjectsInSet int `json:"maxObjectsInSet"` + CollationAlgorithms []string `json:"collationAlgorithms"` +} + +type SessionMailCapabilities struct { +} + +type SessionSubmissionCapabilities struct { +} + +type SessionVacationResponseCapabilities struct { +} + +type SessionSieveCapabilities struct { +} + +type SessionBlobCapabilities struct { +} + +type SessionQuotaCapabilities struct { +} + +type SessionWebsocketCapabilities struct { + Url string `json:"url"` + SupportsPush bool `json:"supportsPush"` +} + +type SessionCapabilities struct { + Core SessionCoreCapabilities `json:"urn:ietf:params:jmap:core"` + Mail SessionMailCapabilities `json:"urn:ietf:params:jmap:mail"` + Submission SessionSubmissionCapabilities `json:"urn:ietf:params:jmap:submission"` + VacationResponse SessionVacationResponseCapabilities `json:"urn:ietf:params:jmap:vacationresponse"` + Sieve SessionSieveCapabilities `json:"urn:ietf:params:jmap:sieve"` + Blob SessionBlobCapabilities `json:"urn:ietf:params:jmap:blob"` + Quota SessionQuotaCapabilities `json:"urn:ietf:params:jmap:quota"` + Websocket SessionWebsocketCapabilities `json:"urn:ietf:params:jmap:websocket"` +} + +type SessionPrimaryAccounts struct { + Core string `json:"urn:ietf:params:jmap:core"` + Mail string `json:"urn:ietf:params:jmap:mail"` + Submission string `json:"urn:ietf:params:jmap:submission"` + VacationResponse string `json:"urn:ietf:params:jmap:vacationresponse"` + Sieve string `json:"urn:ietf:params:jmap:sieve"` + Blob string `json:"urn:ietf:params:jmap:blob"` + Quota string `json:"urn:ietf:params:jmap:quota"` + Websocket string `json:"urn:ietf:params:jmap:websocket"` } type SessionResponse struct { - Capabilities map[string]any `json:"capabilities,omitempty"` + Capabilities SessionCapabilities `json:"capabilities,omitempty"` Accounts map[string]SessionAccount `json:"accounts,omitempty"` - PrimaryAccounts map[string]string `json:"primaryAccounts,omitempty"` + PrimaryAccounts SessionPrimaryAccounts `json:"primaryAccounts,omitempty"` Username string `json:"username,omitempty"` ApiUrl string `json:"apiUrl,omitempty"` DownloadUrl string `json:"downloadUrl,omitempty"` @@ -326,21 +433,45 @@ type VacationResponseGetCommand struct { AccountId string `json:"accountId"` } +// https://datatracker.ietf.org/doc/html/rfc8621#section-8 type VacationResponse struct { - Id string `json:"id"` - IsEnabled bool `json:"isEnabled"` - FromDate time.Time `json:"fromDate,omitzero"` - ToDate time.Time `json:"toDate,omitzero"` - Subject string `json:"subject,omitempty"` - TextBody string `json:"textBody,omitempty"` - HtmlBody string `json:"htmlBody,omitempty"` + // The id of the object. + // There is only ever one VacationResponse object, and its id is "singleton" + Id string `json:"id"` + // 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. + HtmlBody string `json:"htmlBody,omitempty"` } type VacationResponseGetResponse struct { - AccountId string `json:"accountId"` - State string `json:"state,omitempty"` - List []VacationResponse `json:"list,omitempty"` - NotFound []any `json:"notFound,omitempty"` + // 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"` } var CommandResponseTypeMap = map[Command]func() any{ diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index 8d3a09f84..d4f3a7970 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -28,10 +28,20 @@ func (t *TestJmapWellKnownClient) Close() error { } func (t *TestJmapWellKnownClient) GetSession(username string, logger *log.Logger) (SessionResponse, Error) { + pa := generateRandomString(2 + seededRand.Intn(10)) return SessionResponse{ - Username: generateRandomString(8), - ApiUrl: "test://", - PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))}, + Username: generateRandomString(8), + ApiUrl: "test://", + PrimaryAccounts: SessionPrimaryAccounts{ + Core: pa, + Mail: pa, + Submission: pa, + VacationResponse: pa, + Sieve: pa, + Blob: pa, + Quota: pa, + Websocket: pa, + }, }, nil } @@ -99,7 +109,7 @@ func TestRequests(t *testing.T) { jmapUrl, err := url.Parse("http://localhost/jmap") require.NoError(err) - session := Session{AccountId: "123", Username: "user123", JmapUrl: *jmapUrl} + session := Session{MailAccountId: "123", Username: "user123", JmapUrl: *jmapUrl} folders, err := client.GetAllMailboxes(&session, ctx, &logger) require.NoError(err) diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go index 590f738d0..8556c2d8e 100644 --- a/pkg/jmap/jmap_tools.go +++ b/pkg/jmap/jmap_tools.go @@ -32,6 +32,10 @@ func command[T any](api ApiClient, return zero, SimpleError{code: JmapErrorDecodingResponseBody, err: err} } + if data.SessionState != session.State { + // TODO(pbleser-oc) handle session renewal + } + return mapper(&data) } diff --git a/services/groupware/.gitignore b/services/groupware/.gitignore new file mode 100644 index 000000000..782cf6936 --- /dev/null +++ b/services/groupware/.gitignore @@ -0,0 +1 @@ +/swagger.yml diff --git a/services/groupware/Makefile b/services/groupware/Makefile index 24a42cc21..05e612140 100644 --- a/services/groupware/Makefile +++ b/services/groupware/Makefile @@ -9,3 +9,16 @@ include ../../.make/default.mk include ../../.make/go.mk include ../../.make/release.mk include ../../.make/docs.mk + +.PHONY: apidoc +apidoc: swagger.yml + +.PHONY: swagger.yml +swagger.yml: + swagger generate spec -c groupware -o ./swagger.yml + +APIDOC_PORT=9999 + +.PHONY: serve-apidoc +serve-apidoc: swagger.yml + swagger serve --no-open --port=$(APIDOC_PORT) --host=127.0.0.1 --flavor=redoc $< diff --git a/services/groupware/pkg/groupware/groupware_api.go b/services/groupware/pkg/groupware/groupware_api.go new file mode 100644 index 000000000..e3c3e0eea --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api.go @@ -0,0 +1,43 @@ +package groupware + +const ( + Version = "1.0.0" +) + +const ( + CapMail_1 = "mail:1" +) + +var Capabilities = []string{ + CapMail_1, +} + +// When the request contains invalid parameters. +// swagger:response ErrorResponse400 +type SwaggerErrorResponse400 struct { + // in: body + Body struct { + *ErrorResponse + } +} + +// When the requested object does not exist. +// swagger:response ErrorResponse404 +type SwaggerErrorResponse404 struct { +} + +// When the server was unable to complete the request. +// swagger:response ErrorResponse500 +type SwaggerErrorResponse500 struct { + // in: body + Body struct { + *ErrorResponse + } +} + +// swagger:parameters vacation mailboxes +type SwaggerAccountParams struct { + // The identifier of the account. + // in: path + Account string `json:"account"` +} diff --git a/services/groupware/pkg/groupware/groupware_api_account.go b/services/groupware/pkg/groupware/groupware_api_account.go new file mode 100644 index 000000000..f1b62dfd6 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_account.go @@ -0,0 +1,15 @@ +package groupware + +import ( + "net/http" +) + +func (g Groupware) GetAccount(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) (any, string, *Error) { + account, ok := req.GetAccount() + if !ok { + return nil, "", nil + } + return account, req.session.State, nil + }) +} diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go new file mode 100644 index 000000000..a472813bb --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_identity.go @@ -0,0 +1,12 @@ +package groupware + +import ( + "net/http" +) + +func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) (any, string, *Error) { + res, err := g.jmap.GetIdentity(req.session, req.ctx, req.logger) + return res, res.State, apiErrorFromJmap(err) + }) +} diff --git a/services/groupware/pkg/groupware/groupware_api_index.go b/services/groupware/pkg/groupware/groupware_api_index.go new file mode 100644 index 000000000..3bc8edba0 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_index.go @@ -0,0 +1,111 @@ +package groupware + +import ( + "net/http" +) + +type IndexLimits struct { + MaxSizeUpload int `json:"maxSizeUpload"` + MaxConcurrentUpload int `json:"maxConcurrentUpload"` + MaxSizeRequest int `json:"maxSizeRequest"` + 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"` +} + +type IndexAccountSieveCapabilities struct { + MaxSizeScriptName int `json:"maxSizeScriptName"` + MaxSizeScript int `json:"maxSizeScript"` + MaxNumberScripts int `json:"maxNumberScripts"` + MaxNumberRedirects int `json:"maxNumberRedirects"` +} + +type IndexAccountCapabilities struct { + Mail IndexAccountMailCapabilities `json:"mail"` + Sieve IndexAccountSieveCapabilities `json:"sieve"` +} + +type IndexAccount struct { + Name string `json:"name"` + IsPersonal bool `json:"isPersonal"` + IsReadOnly bool `json:"isReadOnly"` + Capabilities IndexAccountCapabilities `json:"capabilities"` +} + +type IndexPrimaryAccounts struct { + Mail string `json:"mail"` + Submission string `json:"submission"` +} + +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"` +} + +// When the request suceeds. +// swagger:response IndexResponse +type SwaggerIndexResponse struct { + // in: body + Body struct { + *IndexResponse + } +} + +// swagger:route GET / index +// Get initial bootup information +// +// responses: +// +// 200: IndexResponse +func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) (any, string, *Error) { + accounts := make(map[string]IndexAccount, len(req.session.Accounts)) + for i, a := range req.session.Accounts { + accounts[i] = IndexAccount{ + Name: a.Name, + IsPersonal: a.IsPersonal, + IsReadOnly: a.IsReadOnly, + Capabilities: IndexAccountCapabilities{ + Mail: IndexAccountMailCapabilities{ + MaxMailboxDepth: a.AccountCapabilities.Mail.MaxMailboxDepth, + MaxSizeMailboxName: a.AccountCapabilities.Mail.MaxSizeMailboxName, + MaxSizeAttachmentsPerEmail: a.AccountCapabilities.Mail.MaxSizeAttachmentsPerEmail, + MayCreateTopLevelMailbox: a.AccountCapabilities.Mail.MayCreateTopLevelMailbox, + MaxDelayedSend: a.AccountCapabilities.Submission.MaxDelayedSend, + }, + Sieve: IndexAccountSieveCapabilities{ + MaxSizeScriptName: a.AccountCapabilities.Sieve.MaxSizeScript, + MaxSizeScript: a.AccountCapabilities.Sieve.MaxSizeScript, + MaxNumberScripts: a.AccountCapabilities.Sieve.MaxNumberScripts, + MaxNumberRedirects: a.AccountCapabilities.Sieve.MaxNumberRedirects, + }, + }, + } + } + + return IndexResponse{ + Version: Version, + Capabilities: Capabilities, + Limits: IndexLimits{ + MaxSizeUpload: req.session.Capabilities.Core.MaxSizeUpload, + MaxConcurrentUpload: req.session.Capabilities.Core.MaxConcurrentUpload, + MaxSizeRequest: req.session.Capabilities.Core.MaxSizeRequest, + MaxConcurrentRequests: req.session.Capabilities.Core.MaxConcurrentRequests, + }, + Accounts: accounts, + PrimaryAccounts: IndexPrimaryAccounts{ + Mail: req.session.PrimaryAccounts.Mail, + Submission: req.session.PrimaryAccounts.Submission, + }, + }, req.session.State, nil + }) +} diff --git a/services/groupware/pkg/groupware/groupware.go b/services/groupware/pkg/groupware/groupware_api_mailbox.go similarity index 56% rename from services/groupware/pkg/groupware/groupware.go rename to services/groupware/pkg/groupware/groupware_api_mailbox.go index 808a1d78f..b2aa4d3e3 100644 --- a/services/groupware/pkg/groupware/groupware.go +++ b/services/groupware/pkg/groupware/groupware_api_mailbox.go @@ -10,43 +10,28 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" ) -func (g Groupware) Route(r chi.Router) { - r.Get("/", g.Index) - r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed= - r.Get("/mailbox/{id}", g.GetMailboxById) - r.Get("/{mailbox}/messages", g.GetMessages) - r.Get("/identity", g.GetIdentity) - r.Get("/vacation", g.GetVacation) -} - -type IndexResponse struct { - AccountId string -} - -func (IndexResponse) Render(w http.ResponseWriter, r *http.Request) error { - return nil -} - -func (g Groupware) Index(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *ApiError) { - return IndexResponse{AccountId: req.session.AccountId}, "", nil - }) -} - -func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *ApiError) { - res, err := g.jmap.GetIdentity(req.session, req.ctx, req.logger) - return res, res.State, apiErrorFromJmap(err) - }) -} - -func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { - g.respond(w, r, func(req Request) (any, string, *ApiError) { - res, err := g.jmap.GetVacationResponse(req.session, req.ctx, req.logger) - return res, res.State, apiErrorFromJmap(err) - }) +// When the request succeeds. +// swagger:response MailboxResponse200 +type SwaggerGetMailboxById200 struct { + // in: body + Body struct { + *jmap.Mailbox + } } +// swagger:route GET /accounts/{account}/mailboxes/{id} mailboxes_by_id +// Get a specific mailbox by its identifier. +// +// A Mailbox represents a named set of Emails. +// This is the primary mechanism for organising Emails within an account. +// It is analogous to a folder or a label in other systems. +// +// responses: +// +// 200: MailboxResponse200 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) { mailboxId := chi.URLParam(r, "mailbox") if mailboxId == "" { @@ -54,7 +39,7 @@ func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) { return } - g.respond(w, r, func(req Request) (any, string, *ApiError) { + g.respond(w, r, func(req Request) (any, string, *Error) { res, err := g.jmap.GetMailbox(req.session, req.ctx, req.logger, []string{mailboxId}) if err != nil { return res, "", apiErrorFromJmap(err) @@ -68,6 +53,41 @@ func (g Groupware) GetMailboxById(w http.ResponseWriter, r *http.Request) { }) } +// swagger:parameters mailboxes +type SwaggerMailboxesParams struct { + // The name of the mailbox, with substring matching. + // in: query + Name string `json:"name,omitempty"` + // The role of the mailbox. + // in: query + Role string `json:"role,omitempty"` + // Whether the mailbox is subscribed by the user or not. + // When omitted, the subscribed and unsubscribed mailboxes are returned. + // in: query + Subscribed bool `json:"subscribed,omitempty"` +} + +// When the request succeeds. +// swagger:response MailboxesResponse200 +type SwaggerMailboxesResponse200 struct { + // in: body + Body []jmap.Mailbox +} + +// swagger:route GET /accounts/{account}/mailboxes mailboxes +// Get the list of all the mailboxes of an account. +// +// A Mailbox represents a named set of Emails. +// This is the primary mechanism for organising Emails within an account. +// It is analogous to a folder or a label in other systems. +// +// When none of the query parameters are specified, all the mailboxes are returned. +// +// responses: +// +// 200: MailboxesResponse200 +// 400: ErrorResponse400 +// 500: ErrorResponse500 func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() var filter jmap.MailboxFilterCondition @@ -94,7 +114,7 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { hasCriteria = true } - g.respond(w, r, func(req Request) (any, string, *ApiError) { + g.respond(w, r, func(req Request) (any, string, *Error) { if hasCriteria { mailboxes, err := g.jmap.SearchMailboxes(req.session, req.ctx, req.logger, filter) if err != nil { @@ -113,7 +133,7 @@ func (g Groupware) GetMailboxes(w http.ResponseWriter, r *http.Request) { func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { mailboxId := chi.URLParam(r, "mailbox") - g.respond(w, r, func(req Request) (any, string, *ApiError) { + g.respond(w, r, func(req Request) (any, string, *Error) { page, ok, _ := ParseNumericParam(r, "page", -1) logger := req.logger if ok { diff --git a/services/groupware/pkg/groupware/groupware_api_vacation.go b/services/groupware/pkg/groupware/groupware_api_vacation.go new file mode 100644 index 000000000..a55d9864e --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_vacation.go @@ -0,0 +1,36 @@ +package groupware + +import ( + "net/http" + + "github.com/opencloud-eu/opencloud/pkg/jmap" +) + +// When the request succeeds. +// swagger:response VacationResponse200 +type SwaggerVacationResponse200 struct { + // in: body + Body struct { + *jmap.VacationResponseGetResponse + } +} + +// swagger:route GET /accounts/{account}/vacation vacation +// Get 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. +// +// The VacationResponse object represents the state of vacation-response-related settings for an account. +// +// responses: +// +// 200: VacationResponse200 +// 400: ErrorResponse400 +// 500: ErrorResponse500 +func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) (any, string, *Error) { + res, err := g.jmap.GetVacationResponse(req.session, req.ctx, req.logger) + return res, res.State, apiErrorFromJmap(err) + }) +} diff --git a/services/groupware/pkg/groupware/groupware_docs.go b/services/groupware/pkg/groupware/groupware_docs.go new file mode 100644 index 000000000..66845cb44 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_docs.go @@ -0,0 +1,26 @@ +// OpenCloud Groupware API +// +// Documentation for the OpenCloud Groupware API +// +// Schemes: https +// BasePath: /groupware +// Version: 1.0.0 +// Host: +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Security: +// - bearer +// +// SecurityDefinitions: +// bearer: +// type: http +// scheme: bearer +// bearerFormat: JWT +// +// swagger:meta +package groupware diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index 4c26e55af..0b096998e 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -10,38 +10,71 @@ import ( ) type Link struct { - Href string `json:"href"` - Rel string `json:"rel,omitempty"` - Title string `json:"title,omitempty"` - Type string `json:"type,omitempty"` - Meta map[string]any `json:"meta,omitempty"` + // A string whose value is a URI-reference [RFC3986 Section 4.1] pointing to the link’s target. + Href string `json:"href"` + // A string indicating the link’s relation type. The string MUST be a valid link relation type. + // required: false + Rel string `json:"rel,omitempty"` + // A string which serves as a label for the destination of a link such that it can be used as a human-readable identifier (e.g., a menu entry). + // required: false + Title string `json:"title,omitempty"` + // A string indicating the media type of the link’s target. + // required: false + Type string `json:"type,omitempty"` + // A meta object containing non-standard meta-information about the link. + // required: false + Meta map[string]any `json:"meta,omitempty"` } type ErrorLinks struct { + // A link that leads to further details about this particular occurrence of the problem. + // When dereferenced, this URI SHOULD return a human-readable description of the error. + // This is either a string containing an URL, or a Link object. About any `json:"about,omitempty"` - Type any `json:"type"` // either a string containing an URL, or a Link object + // A link that identifies the type of error that this particular error is an instance of. + // This URI SHOULD be dereferenceable to a human-readable explanation of the general error. + // This is either a string containing an URL, or a Link object. + Type any `json:"type"` } type ErrorSource struct { - Pointer string `json:"pointer,omitempty"` // a JSON Pointer [RFC6901] to the value in the request document that caused the error - Parameter string `json:"parameter,omitempty"` // a string indicating which URI query parameter caused the error - Header string `json:"header,omitempty"` // a string indicating the name of a single request header which caused the error + // A JSON Pointer [RFC6901] to the value in the request document that caused the error + // (e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute). + // This MUST point to a value in the request document that exists; if it doesn’t, the client SHOULD simply ignore the pointer. + Pointer string `json:"pointer,omitempty"` + // A string indicating which URI query parameter caused the error. + Parameter string `json:"parameter,omitempty"` + // A string indicating the name of a single request header which caused the error. + Header string `json:"header,omitempty"` } -type ApiError struct { - Id string `json:"id"` // a unique identifier for this particular occurrence of the problem - Links *ErrorLinks `json:"links,omitempty"` - NumStatus int `json:"-"` - Status string `json:"status"` // the HTTP status code applicable to this problem, expressed as a string value - Code string `json:"code"` // an application-specific error code, expressed as a string value - Title string `json:"title,omitempty"` // a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem - Detail string `json:"detail,omitempty"` // a human-readable explanation specific to this occurrence of the problem - Source *ErrorSource `json:"source,omitempty"` // an object containing references to the primary source of the error - Meta map[string]any `json:"meta,omitempty"` // a meta object containing non-standard meta-information about the error +// [Error](https://jsonapi.org/format/#error-objects) +type Error struct { + // A unique identifier for this particular occurrence of the problem + Id string `json:"id"` + // Further detail links about the error. + // required: false + Links *ErrorLinks `json:"links,omitempty"` + // swagger:ignore + NumStatus int `json:"-"` + // The HTTP status code applicable to this problem, expressed as a string value. + Status string `json:"status"` + // An application-specific error code, expressed as a string value. + Code string `json:"code"` + // A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem. + Title string `json:"title,omitempty"` + // A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail,omitempty"` + // An object containing references to the primary source of the error. + Source *ErrorSource `json:"source,omitempty"` + // A meta object containing non-standard meta-information about the error. + Meta map[string]any `json:"meta,omitempty"` } +// swagger:response ErrorResponse type ErrorResponse struct { - Errors []ApiError `json:"errors"` + // List of error objects + Errors []Error `json:"errors"` } var _ render.Renderer = ErrorResponse{} @@ -197,14 +230,14 @@ var ( ) type ErrorOpt interface { - apply(error *ApiError) + apply(error *Error) } type ErrorLinksOpt struct { links *ErrorLinks } -func (o ErrorLinksOpt) apply(error *ApiError) { +func (o ErrorLinksOpt) apply(error *Error) { error.Links = o.links } @@ -212,7 +245,7 @@ type SourceLinksOpt struct { source *ErrorSource } -func (o SourceLinksOpt) apply(error *ApiError) { +func (o SourceLinksOpt) apply(error *Error) { error.Source = o.source } @@ -220,7 +253,7 @@ type MetaLinksOpt struct { meta map[string]any } -func (o MetaLinksOpt) apply(error *ApiError) { +func (o MetaLinksOpt) apply(error *Error) { error.Meta = o.meta } @@ -228,7 +261,7 @@ type CodeOpt struct { code string } -func (o CodeOpt) apply(error *ApiError) { +func (o CodeOpt) apply(error *Error) { error.Code = o.code } @@ -237,13 +270,13 @@ type TitleOpt struct { detail string } -func (o TitleOpt) apply(error *ApiError) { +func (o TitleOpt) apply(error *Error) { error.Title = o.title error.Detail = o.detail } func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorResponse { - err := ApiError{ + err := Error{ Id: id, NumStatus: error.Status, Status: strconv.Itoa(error.Status), @@ -257,12 +290,12 @@ func errorResponse(id string, error GroupwareError, options ...ErrorOpt) ErrorRe } return ErrorResponse{ - Errors: []ApiError{err}, + Errors: []Error{err}, } } -func apiError(id string, error GroupwareError, options ...ErrorOpt) ApiError { - err := ApiError{ +func apiError(id string, error GroupwareError, options ...ErrorOpt) Error { + err := Error{ Id: id, NumStatus: error.Status, Status: strconv.Itoa(error.Status), @@ -278,7 +311,7 @@ func apiError(id string, error GroupwareError, options ...ErrorOpt) ApiError { return err } -func apiErrorFromJmap(error jmap.Error) *ApiError { +func apiErrorFromJmap(error jmap.Error) *Error { if error == nil { return nil } @@ -290,6 +323,6 @@ func apiErrorFromJmap(error jmap.Error) *ApiError { return &api } -func errorResponses(errors ...ApiError) ErrorResponse { +func errorResponses(errors ...Error) ErrorResponse { return ErrorResponse{Errors: errors} } diff --git a/services/groupware/pkg/groupware/groupware_lowlevel.go b/services/groupware/pkg/groupware/groupware_framework.go similarity index 92% rename from services/groupware/pkg/groupware/groupware_lowlevel.go rename to services/groupware/pkg/groupware/groupware_framework.go index 10d66b26b..a10d3c16d 100644 --- a/services/groupware/pkg/groupware/groupware_lowlevel.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -158,7 +158,21 @@ type Request struct { session *jmap.Session } -func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, *ApiError)) { +func (r Request) GetAccountId() string { + return chi.URLParam(r.r, "account") +} + +func (r Request) GetAccount() (jmap.SessionAccount, bool) { + accountId := r.GetAccountId() + account, ok := r.session.Accounts[accountId] + if !ok { + r.logger.Debug().Msgf("failed to find account '%v'", accountId) + return jmap.SessionAccount{}, false + } + return account, true +} + +func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) (any, string, *Error)) { ctx := r.Context() logger := g.logger.SubloggerWithRequestID(ctx) session, ok, err := g.session(r, ctx, &logger) @@ -187,6 +201,7 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( logger.Warn().Interface("error", apierr).Msgf("API error: %v", apierr) w.Header().Add("Content-Type", ContentTypeJsonApi) render.Status(r, apierr.NumStatus) + w.WriteHeader(apierr.NumStatus) render.Render(w, r, errorResponses(*apierr)) return } @@ -195,7 +210,9 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func( w.Header().Add("ETag", state) } if response == nil { + logger.Debug().Msgf("respond: response is nil, 404") render.Status(r, http.StatusNotFound) + w.WriteHeader(http.StatusNotFound) } else { render.Status(r, http.StatusOK) render.JSON(w, r, response) diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go new file mode 100644 index 000000000..62f19f304 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -0,0 +1,17 @@ +package groupware + +import ( + "github.com/go-chi/chi/v5" +) + +func (g Groupware) Route(r chi.Router) { + r.Get("/", g.Index) + r.Route("/accounts/{account}", func(r chi.Router) { + r.Get("/", g.GetAccount) + r.Get("/mailboxes", g.GetMailboxes) // ?name=&role=&subcribed= + r.Get("/mailboxes/{id}", g.GetMailboxById) + r.Get("/{mailbox}/messages", g.GetMessages) + r.Get("/identity", g.GetIdentity) + r.Get("/vacation", g.GetVacation) + }) +}