refactored the Session object, refactored the services/groupware directory, and started Swagger documentation implementation

This commit is contained in:
Pascal Bleser
2025-07-30 18:50:36 +02:00
parent 182897c10a
commit 6f593d1bd8
16 changed files with 606 additions and 111 deletions

View File

@@ -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"},
}

View File

@@ -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{

View File

@@ -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)

View File

@@ -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)
}

1
services/groupware/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/swagger.yml

View File

@@ -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 $<

View File

@@ -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"`
}

View File

@@ -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
})
}

View File

@@ -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)
})
}

View File

@@ -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
})
}

View File

@@ -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 {

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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 links target.
Href string `json:"href"`
// A string indicating the links 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 links 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 doesnt, 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}
}

View File

@@ -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)

View File

@@ -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)
})
}