From 6f593d1bd8e4add294c828640fa32f0086863ea6 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Wed, 30 Jul 2025 18:50:36 +0200
Subject: [PATCH] refactored the Session object, refactored the
services/groupware directory, and started Swagger documentation
implementation
---
pkg/jmap/jmap.go | 44 +++--
pkg/jmap/jmap_model.go | 165 ++++++++++++++++--
pkg/jmap/jmap_test.go | 18 +-
pkg/jmap/jmap_tools.go | 4 +
services/groupware/.gitignore | 1 +
services/groupware/Makefile | 13 ++
.../groupware/pkg/groupware/groupware_api.go | 43 +++++
.../pkg/groupware/groupware_api_account.go | 15 ++
.../pkg/groupware/groupware_api_identity.go | 12 ++
.../pkg/groupware/groupware_api_index.go | 111 ++++++++++++
...{groupware.go => groupware_api_mailbox.go} | 96 ++++++----
.../pkg/groupware/groupware_api_vacation.go | 36 ++++
.../groupware/pkg/groupware/groupware_docs.go | 26 +++
.../pkg/groupware/groupware_error.go | 97 ++++++----
...are_lowlevel.go => groupware_framework.go} | 19 +-
.../pkg/groupware/groupware_route.go | 17 ++
16 files changed, 606 insertions(+), 111 deletions(-)
create mode 100644 services/groupware/.gitignore
create mode 100644 services/groupware/pkg/groupware/groupware_api.go
create mode 100644 services/groupware/pkg/groupware/groupware_api_account.go
create mode 100644 services/groupware/pkg/groupware/groupware_api_identity.go
create mode 100644 services/groupware/pkg/groupware/groupware_api_index.go
rename services/groupware/pkg/groupware/{groupware.go => groupware_api_mailbox.go} (56%)
create mode 100644 services/groupware/pkg/groupware/groupware_api_vacation.go
create mode 100644 services/groupware/pkg/groupware/groupware_docs.go
rename services/groupware/pkg/groupware/{groupware_lowlevel.go => groupware_framework.go} (92%)
create mode 100644 services/groupware/pkg/groupware/groupware_route.go
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)
+ })
+}