From d544efdec7fc7604ca8cd3b15ed137acb7fa4a84 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Mon, 7 Jul 2025 10:37:43 +0200 Subject: [PATCH] Groupware and jmap: cleanup and API documentation --- pkg/jmap/jmap.go | 86 +++++-- pkg/jmap/jmap_api.go | 2 + pkg/jmap/jmap_http.go | 61 ++++- pkg/jmap/jmap_model.go | 10 +- pkg/jmap/jmap_reva.go | 20 +- pkg/jmap/jmap_test.go | 10 +- .../pkg/service/v0/groupware/groupware.go | 214 +++--------------- .../service/v0/groupware/groupware_tools.go | 176 ++++++++++++++ .../groupware/pkg/service/http/v0/service.go | 2 +- 9 files changed, 355 insertions(+), 226 deletions(-) create mode 100644 services/graph/pkg/service/v0/groupware/groupware_tools.go diff --git a/pkg/jmap/jmap.go b/pkg/jmap/jmap.go index e247feea43..f3e0d8d886 100644 --- a/pkg/jmap/jmap.go +++ b/pkg/jmap/jmap.go @@ -3,6 +3,7 @@ package jmap import ( "context" "fmt" + "io" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/rs/zerolog" @@ -11,6 +12,11 @@ import ( type Client struct { wellKnown WellKnownClient api ApiClient + io.Closer +} + +func (j *Client) Close() error { + return j.api.Close() } func NewClient(wellKnown WellKnownClient, api ApiClient) Client { @@ -20,10 +26,32 @@ func NewClient(wellKnown WellKnownClient, api ApiClient) Client { } } +// Cached user related information +// +// This information is typically retrieved once (or at least for a certain period of time) from the +// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of +// retrieving it over and over again. +// +// This is really only needed due to the Graph API limitations, since ideally, the account ID should +// be passed as a request parameter by the UI, in order to support a user having multiple accounts. +// +// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making +// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something +// we would want to query before every single JMAP request. On the other hand, that then also creates +// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node. +// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case +// the Session needs to be removed from the cache. +// +// The Username is only here for convenience, it could just as well be passed as a separate parameter +// 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 + Username string + // The identifier of the account to use when performing JMAP operations with Stalwart AccountId string - JmapUrl string + // The base URL to use for JMAP operations towards Stalwart + JmapUrl string } const ( @@ -34,26 +62,44 @@ const ( logFetchBodies = "fetch-bodies" logOffset = "offset" logLimit = "limit" + + emailSortByReceivedAt = "receivedAt" + emailSortBySize = "size" + emailSortByFrom = "from" + emailSortByTo = "to" + emailSortBySubject = "subject" + emailSortBySentAt = "sentAt" + emailSortByHasKeyword = "hasKeyword" + emailSortByAllInThreadHaveKeyword = "allInThreadHaveKeyword" + emailSortBySomeInThreadHaveKeyword = "someInThreadHaveKeyword" ) +// 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(), } } +var ( + errWellKnownResponseHasNoUsername = fmt.Errorf("well-known response has no username") + errWellKnownResponseHasJmapMailPrimaryAccount = fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail) + errWellKnownResponseHasNoApiUrl = fmt.Errorf("well-known response has no API URL") +) + +// Create a new Session from a WellKnownResponse. func NewSession(wellKnownResponse WellKnownResponse) (Session, error) { username := wellKnownResponse.Username if username == "" { - return Session{}, fmt.Errorf("well-known response has no username") + return Session{}, errWellKnownResponseHasNoUsername } accountId := wellKnownResponse.PrimaryAccounts[JmapMail] if accountId == "" { - return Session{}, fmt.Errorf("PrimaryAccounts in well-known response has no entry for %v", JmapMail) + return Session{}, errWellKnownResponseHasJmapMailPrimaryAccount } apiUrl := wellKnownResponse.ApiUrl if apiUrl == "" { - return Session{}, fmt.Errorf("well-known response has no API URL") + return Session{}, errWellKnownResponseHasNoApiUrl } return Session{ Username: username, @@ -62,6 +108,7 @@ func NewSession(wellKnownResponse WellKnownResponse) (Session, error) { }, nil } +// Retrieve JMAP well-known data from the Stalwart server and create a Session from that. func (j *Client) FetchSession(username string, logger *log.Logger) (Session, error) { wk, err := j.wellKnown.GetWellKnown(username, logger) if err != nil { @@ -79,9 +126,10 @@ func (j *Client) loggerParams(operation string, session *Session, logger *log.Lo 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 := NewRequest(NewInvocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0")) + cmd, err := request(invocation(IdentityGet, IdentityGetCommand{AccountId: session.AccountId}, "0")) if err != nil { return IdentityGetResponse{}, err } @@ -92,9 +140,10 @@ func (j *Client) GetIdentity(session *Session, ctx context.Context, logger *log. }) } -func (j *Client) GetVacation(session *Session, ctx context.Context, logger *log.Logger) (VacationResponseGetResponse, error) { - logger = j.logger("GetVacation", session, logger) - cmd, err := NewRequest(NewInvocation(VacationResponseGet, VacationResponseGetCommand{AccountId: session.AccountId}, "0")) +// 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")) if err != nil { return VacationResponseGetResponse{}, err } @@ -105,9 +154,10 @@ func (j *Client) GetVacation(session *Session, ctx context.Context, logger *log. }) } -func (j *Client) GetMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) { - logger = j.logger("GetMailboxes", session, logger) - cmd, err := NewRequest(NewInvocation(MailboxGet, MailboxGetCommand{AccountId: session.AccountId}, "0")) +// 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")) if err != nil { return MailboxGetResponse{}, err } @@ -118,6 +168,10 @@ func (j *Client) GetMailboxes(session *Session, ctx context.Context, logger *log }) } +func (j *Client) GetAllMailboxes(session *Session, ctx context.Context, logger *log.Logger) (MailboxGetResponse, error) { + return j.GetMailbox(session, ctx, logger, nil) +} + type Emails struct { Emails []Email State string @@ -127,17 +181,17 @@ func (j *Client) GetEmails(session *Session, ctx context.Context, logger *log.Lo logger = j.loggerParams("GetEmails", session, logger, func(z zerolog.Context) zerolog.Context { return z.Bool(logFetchBodies, fetchBodies).Int(logOffset, offset).Int(logLimit, limit) }) - cmd, err := NewRequest( - NewInvocation(EmailQuery, EmailQueryCommand{ + cmd, err := request( + invocation(EmailQuery, EmailQueryCommand{ AccountId: session.AccountId, Filter: &Filter{InMailbox: mailboxId}, - Sort: []Sort{{Property: "receivedAt", IsAscending: false}}, + Sort: []Sort{{Property: emailSortByReceivedAt, IsAscending: false}}, CollapseThreads: true, Position: offset, Limit: limit, CalculateTotal: false, }, "0"), - NewInvocation(EmailGet, EmailGetCommand{ + invocation(EmailGet, EmailGetCommand{ AccountId: session.AccountId, FetchAllBodyValues: fetchBodies, MaxBodyValueBytes: maxBodyValueBytes, diff --git a/pkg/jmap/jmap_api.go b/pkg/jmap/jmap_api.go index 22a59b731c..31e379d425 100644 --- a/pkg/jmap/jmap_api.go +++ b/pkg/jmap/jmap_api.go @@ -2,12 +2,14 @@ package jmap import ( "context" + "io" "github.com/opencloud-eu/opencloud/pkg/log" ) type ApiClient interface { Command(ctx context.Context, logger *log.Logger, session *Session, request Request) ([]byte, error) + io.Closer } type WellKnownClient interface { diff --git a/pkg/jmap/jmap_http.go b/pkg/jmap/jmap_http.go index 72f550e181..f7e52e0d73 100644 --- a/pkg/jmap/jmap_http.go +++ b/pkg/jmap/jmap_http.go @@ -7,13 +7,15 @@ import ( "fmt" "io" "net/http" + "path" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/version" ) type HttpJmapUsernameProvider interface { - GetUsername(ctx context.Context, logger *log.Logger) (string, error) + // Provide the username for JMAP operations. + GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, error) } type HttpJmapApiClient struct { @@ -26,6 +28,11 @@ type HttpJmapApiClient struct { userAgent string } +var ( + _ ApiClient = &HttpJmapApiClient{} + _ WellKnownClient = &HttpJmapApiClient{} +) + /* func bearer(req *http.Request, token string) { req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token))) @@ -44,10 +51,27 @@ func NewHttpJmapApiClient(baseurl string, jmapurl string, client *http.Client, u } } +func (h *HttpJmapApiClient) Close() error { + h.client.CloseIdleConnections() + return nil +} + +type AuthenticationError struct { + Err error +} + +func (e AuthenticationError) Error() string { + return fmt.Sprintf("failed to find user for authentication: %v", e.Err.Error()) +} +func (e AuthenticationError) Unwrap() error { + return e.Err +} + func (h *HttpJmapApiClient) auth(logger *log.Logger, ctx context.Context, req *http.Request) error { - username, err := h.usernameProvider.GetUsername(ctx, logger) + username, err := h.usernameProvider.GetUsername(req, ctx, logger) if err != nil { logger.Error().Err(err).Msg("failed to find username") + return AuthenticationError{Err: err} } masterUsername := username + "%" + h.masterUser req.SetBasicAuth(masterUsername, h.masterPassword) @@ -60,13 +84,28 @@ func (h *HttpJmapApiClient) authWithUsername(_ *log.Logger, username string, req return nil } +type HttpError struct { + Method string + Url string + Username string + Op string + Err error +} + +func (e HttpError) Error() string { + return fmt.Sprintf("HTTP error for method=%v url='%v' username='%v' while %v: %v", e.Method, e.Url, e.Username, e.Op, e.Err.Error()) +} +func (e HttpError) Unwrap() error { + return e.Err +} + func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) { - wellKnownUrl := h.baseurl + "/.well-known/jmap" + wellKnownUrl := path.Join(h.baseurl, ".well-known", "jmap") req, err := http.NewRequest(http.MethodGet, wellKnownUrl, nil) if err != nil { logger.Error().Err(err).Msgf("failed to create GET request for %v", wellKnownUrl) - return WellKnownResponse{}, err + return WellKnownResponse{}, HttpError{Op: "creating request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} } h.authWithUsername(logger, username, req) req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation @@ -74,11 +113,11 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W res, err := h.client.Do(req) if err != nil { logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl) - return WellKnownResponse{}, err + return WellKnownResponse{}, HttpError{Op: "performing request", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} } if res.StatusCode != 200 { logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200") - return WellKnownResponse{}, fmt.Errorf("HTTP response status is %v", res.Status) + return WellKnownResponse{}, HttpError{Op: "processing response", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: fmt.Errorf("status is %v", res.Status)} } if res.Body != nil { defer func(Body io.ReadCloser) { @@ -92,14 +131,14 @@ func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (W body, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") - return WellKnownResponse{}, err + return WellKnownResponse{}, HttpError{Op: "reading response body", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} } var data WellKnownResponse err = json.Unmarshal(body, &data) if err != nil { logger.Error().Str("url", wellKnownUrl).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response") - return WellKnownResponse{}, err + return WellKnownResponse{}, HttpError{Op: "reading decoding response JSON payload", Method: http.MethodGet, Url: wellKnownUrl, Username: username, Err: err} } return data, nil @@ -129,11 +168,11 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses res, err := h.client.Do(req) if err != nil { logger.Error().Err(err).Msgf("failed to perform GET %v", jmapUrl) - return nil, err + return nil, HttpError{Op: "performing request", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err} } if res.StatusCode < 200 || res.StatusCode > 299 { logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx") - return nil, fmt.Errorf("HTTP response status is %v", res.Status) + return nil, HttpError{Op: "processing response", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: fmt.Errorf("status is %v", res.Status)} } if res.Body != nil { defer func(Body io.ReadCloser) { @@ -147,7 +186,7 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses body, err := io.ReadAll(res.Body) if err != nil { logger.Error().Err(err).Msg("failed to read response body") - return nil, err + return nil, HttpError{Op: "reading response body", Method: http.MethodPost, Url: jmapUrl, Username: session.Username, Err: err} } return body, nil diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 4bfffd9201..d0c974b3c2 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -57,7 +57,8 @@ type Mailbox struct { } type MailboxGetCommand struct { - AccountId string `json:"accountId"` + AccountId string `json:"accountId"` + Ids []string `json:"ids,omitempty"` } type Filter struct { @@ -79,6 +80,8 @@ type Filter struct { type Sort struct { Property string `json:"property,omitempty"` IsAscending bool `json:"isAscending,omitempty"` + Keyword string `json:"keyword,omitempty"` + Collation string `json:"collation,omitempty"` } type EmailQueryCommand struct { @@ -169,7 +172,7 @@ type Invocation struct { Tag string } -func NewInvocation(command Command, parameters any, tag string) Invocation { +func invocation(command Command, parameters any, tag string) Invocation { return Invocation{ Command: command, Parameters: parameters, @@ -183,7 +186,7 @@ type Request struct { CreatedIds map[string]string `json:"createdIds,omitempty"` } -func NewRequest(methodCalls ...Invocation) (Request, error) { +func request(methodCalls ...Invocation) (Request, error) { return Request{ Using: []string{JmapCore, JmapMail}, MethodCalls: methodCalls, @@ -241,7 +244,6 @@ type EmailCreate struct { ReceivedAt time.Time `json:"receivedAt,omitzero"` SentAt time.Time `json:"sentAt,omitzero"` BodyStructure EmailBodyStructure `json:"bodyStructure,omitempty"` - //BodyStructure map[string]any `json:"bodyStructure,omitempty"` } type EmailSetCommand struct { diff --git a/pkg/jmap/jmap_reva.go b/pkg/jmap/jmap_reva.go index 4f9611b28c..a2254c1939 100644 --- a/pkg/jmap/jmap_reva.go +++ b/pkg/jmap/jmap_reva.go @@ -3,24 +3,30 @@ package jmap import ( "context" "fmt" + "net/http" "github.com/opencloud-eu/opencloud/pkg/log" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" ) -// implements HttpJmapUsernameProvider -type RevaContextHttpJmapUsernameProvider struct { +// HttpJmapUsernameProvider implementation that uses Reva's enrichment of the Context +// to retrieve the current username. +type revaContextHttpJmapUsernameProvider struct { } -func NewRevaContextHttpJmapUsernameProvider() RevaContextHttpJmapUsernameProvider { - return RevaContextHttpJmapUsernameProvider{} +var _ HttpJmapUsernameProvider = revaContextHttpJmapUsernameProvider{} + +func NewRevaContextHttpJmapUsernameProvider() HttpJmapUsernameProvider { + return revaContextHttpJmapUsernameProvider{} } -func (r RevaContextHttpJmapUsernameProvider) GetUsername(ctx context.Context, logger *log.Logger) (string, error) { +var errUserNotInContext = fmt.Errorf("user not in context") + +func (r revaContextHttpJmapUsernameProvider) GetUsername(req *http.Request, ctx context.Context, logger *log.Logger) (string, error) { u, ok := revactx.ContextGetUser(ctx) if !ok { - logger.Error().Msg("could not get user: user not in context") - return "", fmt.Errorf("user not in context") + logger.Error().Msg("could not get user: user not in reva context") + return "", errUserNotInContext } return u.GetUsername(), nil } diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index cd4f209a60..d9e2cb4380 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -22,6 +22,10 @@ func NewTestJmapWellKnownClient(t *testing.T) WellKnownClient { return &TestJmapWellKnownClient{t: t} } +func (t *TestJmapWellKnownClient) Close() error { + return nil +} + func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownResponse, error) { return WellKnownResponse{ Username: generateRandomString(8), @@ -38,6 +42,10 @@ func NewTestJmapApiClient(t *testing.T) ApiClient { return &TestJmapApiClient{t: t} } +func (t TestJmapApiClient) Close() error { + return nil +} + func serveTestFile(t *testing.T, name string) ([]byte, error) { cwd, _ := os.Getwd() p := filepath.Join(cwd, "testdata", name) @@ -89,7 +97,7 @@ func TestRequests(t *testing.T) { session := Session{AccountId: "123", JmapUrl: "test://"} - folders, err := client.GetMailboxes(&session, ctx, &logger) + folders, err := client.GetAllMailboxes(&session, ctx, &logger) require.NoError(err) require.Len(folders.List, 5) diff --git a/services/graph/pkg/service/v0/groupware/groupware.go b/services/graph/pkg/service/v0/groupware/groupware.go index f8a682084e..2e760e37ce 100644 --- a/services/graph/pkg/service/v0/groupware/groupware.go +++ b/services/graph/pkg/service/v0/groupware/groupware.go @@ -4,10 +4,9 @@ import ( "context" "crypto/tls" "fmt" + "io" "net/http" - "net/url" - "strconv" - "strings" + "sync/atomic" "time" "github.com/opencloud-eu/opencloud/pkg/jmap" @@ -32,8 +31,11 @@ type Groupware struct { usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now defaultEmailLimit int maxBodyValueBytes int + io.Closer } +var _ io.Closer = Groupware{} + type ItemBody struct { Content string `json:"content"` ContentType string `json:"contentType"` // text|html @@ -103,24 +105,11 @@ func NewGroupware(logger *log.Logger, config *config.Config) *Groupware { jmapClient := jmap.NewClient(api, api) - loader := ttlcache.LoaderFunc[string, jmap.Session]( - func(c *ttlcache.Cache[string, jmap.Session], key string) *ttlcache.Item[string, jmap.Session] { - jmapContext, err := jmapClient.FetchSession(key, logger) - if err != nil { - logger.Error().Err(err).Str("username", key).Msg("failed to retrieve well-known") - return nil - } - item := c.Set(key, jmapContext, config.Mail.SessionCacheTTL) - return item - }, - ) - sessionCache := ttlcache.New( ttlcache.WithTTL[string, jmap.Session]( config.Mail.SessionCacheTTL, ), ttlcache.WithDisableTouchOnHit[string, jmap.Session](), - ttlcache.WithLoader(loader), ) go sessionCache.Start() @@ -134,23 +123,33 @@ func NewGroupware(logger *log.Logger, config *config.Config) *Groupware { } } -func pickInbox(folders []jmap.Mailbox) string { - for _, folder := range folders { - if folder.Role == "inbox" { - return folder.Id - } - } - return "" +func (g Groupware) Close() error { + g.sessionCache.Stop() + return nil } -func (g Groupware) session(ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) { - username, err := g.usernameProvider.GetUsername(ctx, logger) +func (g Groupware) session(req *http.Request, ctx context.Context, logger *log.Logger) (jmap.Session, bool, error) { + username, err := g.usernameProvider.GetUsername(req, ctx, logger) if err != nil { logger.Error().Err(err).Msg("failed to retrieve username") return jmap.Session{}, false, err } - item := g.sessionCache.Get(username) + fetchErrRef := atomic.Value{} + item, _ := g.sessionCache.GetOrSetFunc(username, func() jmap.Session { + jmapContext, err := g.jmapClient.FetchSession(username, logger) + if err != nil { + fetchErrRef.Store(err) + logger.Error().Err(err).Str("username", username).Msg("failed to retrieve well-known") + return jmap.Session{} + } + return jmapContext + }) + p := fetchErrRef.Load() + if p != nil { + err = p.(error) + return jmap.Session{}, false, err + } if item != nil { return item.Value(), true, nil } else { @@ -161,7 +160,7 @@ func (g Groupware) session(ctx context.Context, logger *log.Logger) (jmap.Sessio func (g Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error)) { ctx := r.Context() logger := g.logger.SubloggerWithRequestID(ctx) - session, ok, err := g.session(ctx, &logger) + session, ok, err := g.session(r, ctx, &logger) if err != nil { logger.Error().Err(err).Interface(logQuery, r.URL.Query()).Msg("failed to determine JMAP session") errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error()) @@ -194,7 +193,7 @@ func (g Groupware) GetIdentity(w http.ResponseWriter, r *http.Request) { func (g Groupware) GetVacation(w http.ResponseWriter, r *http.Request) { g.withSession(w, r, func(r *http.Request, ctx context.Context, logger log.Logger, session *jmap.Session) (any, error) { - return g.jmapClient.GetVacation(session, ctx, &logger) + return g.jmapClient.GetVacationResponse(session, ctx, &logger) }) } @@ -209,7 +208,7 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { logger = log.Logger{Logger: logger.With().Int("$top", limit).Logger()} } - mailboxGetResponse, err := g.jmapClient.GetMailboxes(session, ctx, &logger) + mailboxGetResponse, err := g.jmapClient.GetAllMailboxes(session, ctx, &logger) if err != nil { return nil, err } @@ -236,160 +235,3 @@ func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) { return Messages{Context: odataContext.String(), Value: messages}, nil }) } - -func mapContentType(jmap string) string { - switch jmap { - case "text/html": - return "html" - case "text/plain": - return "text" - default: - return jmap - } -} - -func foldBody(email jmap.Email) *ItemBody { - if email.BodyValues != nil { - if len(email.HtmlBody) > 0 { - pick := email.HtmlBody[0] - content, ok := email.BodyValues[pick.PartId] - if ok { - return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)} - } - } - if len(email.TextBody) > 0 { - pick := email.TextBody[0] - content, ok := email.BodyValues[pick.PartId] - if ok { - return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)} - } - } - } - return nil -} - -func firstOf[T any](ary []T) T { - if len(ary) > 0 { - return ary[0] - } - var nothing T - return nothing -} - -func emailAddress(j jmap.EmailAddress) EmailAddress { - return EmailAddress{Address: j.Email, Name: j.Name} -} - -func emailAddresses(j []jmap.EmailAddress) []EmailAddress { - result := make([]EmailAddress, len(j)) - for i := 0; i < len(j); i++ { - result[i] = emailAddress(j[i]) - } - return result -} - -func hasKeyword(j jmap.Email, kw string) bool { - value, ok := j.Keywords[kw] - if ok { - return value - } - return false -} - -func categories(j jmap.Email) []string { - categories := []string{} - for k, v := range j.Keywords { - if v && !strings.HasPrefix(k, jmap.JmapKeywordPrefix) { - categories = append(categories, k) - } - } - return categories -} - -/* -func toEdmBinary(value int) string { - return fmt.Sprintf("%X", value) -} -*/ - -// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0 -func message(email jmap.Email, state string) Message { - body := foldBody(email) - importance := "" // omit "normal" as it is expected to be the default - if hasKeyword(email, jmap.JmapKeywordFlagged) { - importance = "high" - } - - mailboxId := "" - for k, v := range email.MailboxIds { - if v { - // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients? - // e.g. do we need to include tenant/sharding/cluster information? - mailboxId = k - break - } - } - - // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients? - // e.g. do we need to include tenant/sharding/cluster information? - id := email.Id - // for this one too: - messageId := firstOf(email.MessageId) - // as well as this one: - threadId := email.ThreadId - - categories := categories(email) - - var from *EmailAddress = nil - if len(email.From) > 0 { - e := emailAddress(email.From[0]) - from = &e - } - - // TODO how to map JMAP state to an OData Etag? - etag := state - - weblink, err := url.JoinPath("/groupware/mail", id) - if err != nil { - weblink = "" - } - - return Message{ - Etag: etag, - Id: id, - Subject: email.Subject, - CreatedDateTime: email.ReceivedAt, - ReceivedDateTime: email.ReceivedAt, - SentDateTime: email.SentAt, - HasAttachments: email.HasAttachments, - InternetMessageId: messageId, - BodyPreview: email.Preview, - Body: body, - From: from, - ToRecipients: emailAddresses(email.To), - CcRecipients: emailAddresses(email.Cc), - BccRecipients: emailAddresses(email.Bcc), - ReplyTo: emailAddresses(email.ReplyTo), - IsRead: hasKeyword(email, jmap.JmapKeywordSeen), - IsDraft: hasKeyword(email, jmap.JmapKeywordDraft), - Importance: importance, - ParentFolderId: mailboxId, - Categories: categories, - ConversationId: threadId, - WebLink: weblink, - // ConversationIndex: toEdmBinary(email.ThreadIndex), - } // TODO more email fields -} - -func parseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) { - str := r.URL.Query().Get(param) - if str == "" { - return defaultValue, false, nil - } - - value, err := strconv.ParseInt(str, 10, 0) - if err != nil { - return defaultValue, false, nil - } - return int(value), true, nil -} diff --git a/services/graph/pkg/service/v0/groupware/groupware_tools.go b/services/graph/pkg/service/v0/groupware/groupware_tools.go new file mode 100644 index 0000000000..4fa069c7ad --- /dev/null +++ b/services/graph/pkg/service/v0/groupware/groupware_tools.go @@ -0,0 +1,176 @@ +package groupware + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/opencloud-eu/opencloud/pkg/jmap" +) + +func pickInbox(folders []jmap.Mailbox) string { + for _, folder := range folders { + if folder.Role == "inbox" { + return folder.Id + } + } + return "" +} + +func mapContentType(jmap string) string { + switch jmap { + case "text/html": + return "html" + case "text/plain": + return "text" + default: + return jmap + } +} + +func foldBody(email jmap.Email) *ItemBody { + if email.BodyValues != nil { + if len(email.HtmlBody) > 0 { + pick := email.HtmlBody[0] + content, ok := email.BodyValues[pick.PartId] + if ok { + return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)} + } + } + if len(email.TextBody) > 0 { + pick := email.TextBody[0] + content, ok := email.BodyValues[pick.PartId] + if ok { + return &ItemBody{Content: content.Value, ContentType: mapContentType(pick.Type)} + } + } + } + return nil +} + +func firstOf[T any](ary []T) T { + if len(ary) > 0 { + return ary[0] + } + var nothing T + return nothing +} + +func emailAddress(j jmap.EmailAddress) EmailAddress { + return EmailAddress{Address: j.Email, Name: j.Name} +} + +func emailAddresses(j []jmap.EmailAddress) []EmailAddress { + result := make([]EmailAddress, len(j)) + for i := 0; i < len(j); i++ { + result[i] = emailAddress(j[i]) + } + return result +} + +func hasKeyword(j jmap.Email, kw string) bool { + value, ok := j.Keywords[kw] + if ok { + return value + } + return false +} + +func categories(j jmap.Email) []string { + categories := []string{} + for k, v := range j.Keywords { + if v && !strings.HasPrefix(k, jmap.JmapKeywordPrefix) { + categories = append(categories, k) + } + } + return categories +} + +/* +func toEdmBinary(value int) string { + return fmt.Sprintf("%X", value) +} +*/ + +// https://learn.microsoft.com/en-us/graph/api/resources/message?view=graph-rest-1.0 +func message(email jmap.Email, state string) Message { + body := foldBody(email) + importance := "" // omit "normal" as it is expected to be the default + if hasKeyword(email, jmap.JmapKeywordFlagged) { + importance = "high" + } + + mailboxId := "" + for k, v := range email.MailboxIds { + if v { + // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients? + // e.g. do we need to include tenant/sharding/cluster information? + mailboxId = k + break + } + } + + // TODO how to map JMAP short identifiers (e.g. 'a') to something uniquely addressable for the clients? + // e.g. do we need to include tenant/sharding/cluster information? + id := email.Id + // for this one too: + messageId := firstOf(email.MessageId) + // as well as this one: + threadId := email.ThreadId + + categories := categories(email) + + var from *EmailAddress = nil + if len(email.From) > 0 { + e := emailAddress(email.From[0]) + from = &e + } + + // TODO how to map JMAP state to an OData Etag? + etag := state + + weblink, err := url.JoinPath("/groupware/mail", id) + if err != nil { + weblink = "" + } + + return Message{ + Etag: etag, + Id: id, + Subject: email.Subject, + CreatedDateTime: email.ReceivedAt, + ReceivedDateTime: email.ReceivedAt, + SentDateTime: email.SentAt, + HasAttachments: email.HasAttachments, + InternetMessageId: messageId, + BodyPreview: email.Preview, + Body: body, + From: from, + ToRecipients: emailAddresses(email.To), + CcRecipients: emailAddresses(email.Cc), + BccRecipients: emailAddresses(email.Bcc), + ReplyTo: emailAddresses(email.ReplyTo), + IsRead: hasKeyword(email, jmap.JmapKeywordSeen), + IsDraft: hasKeyword(email, jmap.JmapKeywordDraft), + Importance: importance, + ParentFolderId: mailboxId, + Categories: categories, + ConversationId: threadId, + WebLink: weblink, + // ConversationIndex: toEdmBinary(email.ThreadIndex), + } // TODO more email fields +} + +func parseNumericParam(r *http.Request, param string, defaultValue int) (int, bool, error) { + str := r.URL.Query().Get(param) + if str == "" { + return defaultValue, false, nil + } + + value, err := strconv.ParseInt(str, 10, 0) + if err != nil { + return defaultValue, false, nil + } + return int(value), true, nil +} diff --git a/services/groupware/pkg/service/http/v0/service.go b/services/groupware/pkg/service/http/v0/service.go index 4f026ab369..2ce6835036 100644 --- a/services/groupware/pkg/service/http/v0/service.go +++ b/services/groupware/pkg/service/http/v0/service.go @@ -109,7 +109,7 @@ func httpApiClient(config *config.Config, usernameProvider jmap.HttpJmapUsername } func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) - username, err := g.usernameProvider.GetUsername(r.Context(), &logger) + username, err := g.usernameProvider.GetUsername(r, r.Context(), &logger) if err != nil { w.WriteHeader(http.StatusInternalServerError) return