groupware: add /bootstrap

* add a GET /accounts/{a}/boostrap URI that delivers the same as GET /
   but also mailboxes for a given account, in case the UI remembers the
   last used account identifier, to avoid an additional roundtrip

 * streamline the use of simpleError()

 * add logging of errors at the calling site

 * add logging of evictions of Sessions from the cache

 * change default Session cache TTL to 5min instead of 30sec
This commit is contained in:
Pascal Bleser
2025-08-21 15:27:45 +02:00
parent 42a4c5c156
commit 675e3e5fdb
17 changed files with 370 additions and 126 deletions
@@ -35,7 +35,7 @@ func DefaultConfig() *config.Config {
MaxBodyValueBytes: -1,
ResponseHeaderTimeout: 10 * time.Second,
SessionCache: config.MailSessionCache{
Ttl: 30 * time.Second,
Ttl: 5 * time.Minute,
FailureTtl: 15 * time.Second,
MaxCapacity: 10_000,
},
@@ -2,6 +2,9 @@ package groupware
import (
"net/http"
"github.com/opencloud-eu/opencloud/pkg/jmap"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
func (g Groupware) GetAccount(w http.ResponseWriter, r *http.Request) {
@@ -19,3 +22,57 @@ func (g Groupware) GetAccounts(w http.ResponseWriter, r *http.Request) {
return response(req.session.Accounts, req.session.State)
})
}
type AccountBootstrapResponse struct {
// The API version.
Version string `json:"version"`
// A list of capabilities of this API version.
Capabilities []string `json:"capabilities"`
// API limits.
Limits IndexLimits `json:"limits"`
// Accounts that are available to the user.
//
// The key of the mapis the identifier.
Accounts map[string]IndexAccount `json:"accounts"`
// Primary accounts for usage types.
PrimaryAccounts IndexPrimaryAccounts `json:"primaryAccounts"`
// Mailboxes.
Mailboxes map[string][]jmap.Mailbox `json:"mailboxes"`
}
// When the request suceeds.
// swagger:response IndexResponse
type SwaggerAccountBootstrapResponse struct {
// in: body
Body struct {
*AccountBootstrapResponse
}
}
func (g Groupware) GetAccountBootstrap(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
mailAccountId := req.GetAccountId()
accountIds := structs.Keys(req.session.Accounts)
resp, jerr := g.jmap.GetIdentitiesAndMailboxes(mailAccountId, accountIds, req.session, req.ctx, req.logger)
if jerr != nil {
return req.errorResponseFromJmap(jerr)
}
return response(AccountBootstrapResponse{
Version: Version,
Capabilities: Capabilities,
Limits: buildIndexLimits(req.session),
Accounts: buildIndexAccount(req.session, resp.Identities),
PrimaryAccounts: buildIndexPrimaryAccounts(req.session),
Mailboxes: map[string][]jmap.Mailbox{
mailAccountId: resp.Mailboxes,
},
}, resp.SessionState)
})
}
@@ -150,66 +150,70 @@ type SwaggerIndexResponse struct {
// 200: IndexResponse
func (g Groupware) Index(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
accountIds := make([]string, len(req.session.Accounts))
i := 0
for k := range req.session.Accounts {
accountIds[i] = k
i++
}
accountIds = structs.Uniq(accountIds)
accountIds := structs.Keys(req.session.Accounts)
identitiesResponse, err := g.jmap.GetIdentities(accountIds, req.session, req.ctx, req.logger)
if err != nil {
return req.errorResponseFromJmap(err)
}
accounts := make(map[string]IndexAccount, len(req.session.Accounts))
for accountId, account := range req.session.Accounts {
indexAccount := IndexAccount{
Name: account.Name,
IsPersonal: account.IsPersonal,
IsReadOnly: account.IsReadOnly,
Capabilities: IndexAccountCapabilities{
Mail: IndexAccountMailCapabilities{
MaxMailboxDepth: account.AccountCapabilities.Mail.MaxMailboxDepth,
MaxSizeMailboxName: account.AccountCapabilities.Mail.MaxSizeMailboxName,
MaxMailboxesPerEmail: account.AccountCapabilities.Mail.MaxMailboxesPerEmail,
MaxSizeAttachmentsPerEmail: account.AccountCapabilities.Mail.MaxSizeAttachmentsPerEmail,
MayCreateTopLevelMailbox: account.AccountCapabilities.Mail.MayCreateTopLevelMailbox,
MaxDelayedSend: account.AccountCapabilities.Submission.MaxDelayedSend,
},
Sieve: IndexAccountSieveCapabilities{
MaxSizeScriptName: account.AccountCapabilities.Sieve.MaxSizeScript,
MaxSizeScript: account.AccountCapabilities.Sieve.MaxSizeScript,
MaxNumberScripts: account.AccountCapabilities.Sieve.MaxNumberScripts,
MaxNumberRedirects: account.AccountCapabilities.Sieve.MaxNumberRedirects,
},
},
}
if identity, ok := identitiesResponse.Identities[accountId]; ok {
indexAccount.Identities = identity
}
accounts[accountId] = indexAccount
}
return response(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,
Blob: req.session.PrimaryAccounts.Blob,
VacationResponse: req.session.PrimaryAccounts.VacationResponse,
Sieve: req.session.PrimaryAccounts.Sieve,
},
Version: Version,
Capabilities: Capabilities,
Limits: buildIndexLimits(req.session),
Accounts: buildIndexAccount(req.session, identitiesResponse.Identities),
PrimaryAccounts: buildIndexPrimaryAccounts(req.session),
}, req.session.State)
})
}
func buildIndexLimits(session *jmap.Session) IndexLimits {
return IndexLimits{
MaxSizeUpload: session.Capabilities.Core.MaxSizeUpload,
MaxConcurrentUpload: session.Capabilities.Core.MaxConcurrentUpload,
MaxSizeRequest: session.Capabilities.Core.MaxSizeRequest,
MaxConcurrentRequests: session.Capabilities.Core.MaxConcurrentRequests,
}
}
func buildIndexPrimaryAccounts(session *jmap.Session) IndexPrimaryAccounts {
return IndexPrimaryAccounts{
Mail: session.PrimaryAccounts.Mail,
Submission: session.PrimaryAccounts.Submission,
Blob: session.PrimaryAccounts.Blob,
VacationResponse: session.PrimaryAccounts.VacationResponse,
Sieve: session.PrimaryAccounts.Sieve,
}
}
func buildIndexAccount(session *jmap.Session, identities map[string][]jmap.Identity) map[string]IndexAccount {
accounts := make(map[string]IndexAccount, len(session.Accounts))
for accountId, account := range session.Accounts {
indexAccount := IndexAccount{
Name: account.Name,
IsPersonal: account.IsPersonal,
IsReadOnly: account.IsReadOnly,
Capabilities: IndexAccountCapabilities{
Mail: IndexAccountMailCapabilities{
MaxMailboxDepth: account.AccountCapabilities.Mail.MaxMailboxDepth,
MaxSizeMailboxName: account.AccountCapabilities.Mail.MaxSizeMailboxName,
MaxMailboxesPerEmail: account.AccountCapabilities.Mail.MaxMailboxesPerEmail,
MaxSizeAttachmentsPerEmail: account.AccountCapabilities.Mail.MaxSizeAttachmentsPerEmail,
MayCreateTopLevelMailbox: account.AccountCapabilities.Mail.MayCreateTopLevelMailbox,
MaxDelayedSend: account.AccountCapabilities.Submission.MaxDelayedSend,
},
Sieve: IndexAccountSieveCapabilities{
MaxSizeScriptName: account.AccountCapabilities.Sieve.MaxSizeScript,
MaxSizeScript: account.AccountCapabilities.Sieve.MaxSizeScript,
MaxNumberScripts: account.AccountCapabilities.Sieve.MaxNumberScripts,
MaxNumberRedirects: account.AccountCapabilities.Sieve.MaxNumberRedirects,
},
},
}
if identity, ok := identities[accountId]; ok {
indexAccount.Identities = identity
}
accounts[accountId] = indexAccount
}
return accounts
}
@@ -411,11 +411,7 @@ type MessageCreation struct {
func (g Groupware) CreateMessage(w http.ResponseWriter, r *http.Request) {
g.respond(w, r, func(req Request) Response {
messageId := chi.URLParam(r, UriParamMessageId)
l := req.logger.With()
l.Str(UriParamMessageId, messageId)
logger := log.From(l)
logger := req.logger
var body MessageCreation
err := req.body(&body)
@@ -63,13 +63,16 @@ func (e GroupwareInitializationError) Unwrap() error {
}
type GroupwareSessionEventListener struct {
logger *log.Logger
sessionCache *ttlcache.Cache[string, cachedSession]
}
func (l GroupwareSessionEventListener) OnSessionOutdated(session *jmap.Session) {
func (l GroupwareSessionEventListener) OnSessionOutdated(session *jmap.Session, newSessionState string) {
// it's enough to remove the session from the cache, as it will be fetched on-demand
// the next time an operation is performed on behalf of the user
l.sessionCache.Delete(session.Username)
l.logger.Trace().Msgf("removed outdated session for user '%v': state %s -> %s", session.Username, session.State, newSessionState)
}
var _ jmap.SessionEventListener = GroupwareSessionEventListener{}
@@ -134,7 +137,28 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) (*Gro
go sessionCache.Start()
}
sessionEventListener := GroupwareSessionEventListener{sessionCache: sessionCache}
if logger.Trace().Enabled() {
sessionCache.OnEviction(func(c context.Context, r ttlcache.EvictionReason, item *ttlcache.Item[string, cachedSession]) {
reason := ""
switch r {
case ttlcache.EvictionReasonDeleted:
reason = "deleted"
case ttlcache.EvictionReasonCapacityReached:
reason = "capacity reached"
case ttlcache.EvictionReasonExpired:
reason = fmt.Sprintf("expired after %vms", item.TTL().Milliseconds())
case ttlcache.EvictionReasonMaxCostExceeded:
reason = "max cost exceeded"
}
if reason == "" {
reason = fmt.Sprintf("unknown (%v)", r)
}
logger.Trace().Msgf("session cache eviction of user '%v': %v", item.Key(), reason)
})
}
sessionEventListener := GroupwareSessionEventListener{sessionCache: sessionCache, logger: logger}
jmapClient.AddSessionEventListener(&sessionEventListener)
return &Groupware{
@@ -388,7 +412,7 @@ func (g Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Err
render.Render(w, r, errorResponses(*error))
}
func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) {
func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) (Response, bool) {
ctx := r.Context()
sl := g.logger.SubloggerWithRequestID(ctx)
logger := &sl
@@ -396,11 +420,11 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
username, ok, err := g.usernameProvider.GetUsername(r, ctx, logger)
if err != nil {
g.serveError(w, r, apiError(errorId(r, ctx), ErrorInvalidAuthentication))
return
return Response{}, false
}
if !ok {
g.serveError(w, r, apiError(errorId(r, ctx), ErrorMissingAuthentication))
return
return Response{}, false
}
logger = log.From(logger.With().Str(logUsername, log.SafeString(username)))
@@ -409,13 +433,13 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
if err != nil {
logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session")
render.Status(r, http.StatusInternalServerError)
return
return Response{}, false
}
if !ok {
// no session = authentication failed
logger.Warn().Err(err).Interface(logQuery, r.URL.Query()).Msg("could not authenticate")
render.Status(r, http.StatusForbidden)
return
return Response{}, false
}
decoratedLogger := session.DecorateLogger(*logger)
@@ -427,6 +451,10 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
}
response := handler(req)
return response, true
}
func (g Groupware) sendResponse(w http.ResponseWriter, r *http.Request, response Response) {
if response.err != nil {
g.log(response.err)
w.Header().Add("Content-Type", ContentTypeJsonApi)
@@ -456,6 +484,14 @@ func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(
}
}
func (g Groupware) respond(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) {
response, ok := g.withSession(w, r, handler)
if !ok {
return
}
g.sendResponse(w, r, response)
}
func (g Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(r Request, w http.ResponseWriter) *Error) {
ctx := r.Context()
sl := g.logger.SubloggerWithRequestID(ctx)
@@ -42,6 +42,7 @@ func (g Groupware) Route(r chi.Router) {
r.Get("/accounts", g.GetAccounts)
r.Route("/accounts/{accountid}", func(r chi.Router) {
r.Get("/", g.GetAccount)
r.Get("/bootstrap", g.GetAccountBootstrap)
r.Get("/identities", g.GetIdentities)
r.Get("/vacation", g.GetVacation)
r.Put("/vacation", g.SetVacation)
@@ -59,7 +59,7 @@ func (l *sessionCacheLoader) Load(c *ttlcache.Cache[string, cachedSession], user
return c.Set(username, failedSession{err: err}, l.errorTtl)
} else {
l.logger.Debug().Str("username", username).Msgf("successfully created session for '%v'", username)
return c.Set(username, succeededSession{session: session}, 0) // 0 = use the TTL configured on the Cache
return c.Set(username, succeededSession{session: session}, ttlcache.DefaultTTL) // use the TTL configured on the Cache
}
}