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