From da9ed5f44b2e93cb407ac9c9f21685de16b68feb Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Fri, 2 May 2025 17:09:25 +0200
Subject: [PATCH] WIP: restructure the Jmap client, and implement the
/me/messages Graph API endpoint with it
---
services/graph/pkg/config/config.go | 15 +
.../pkg/config/defaults/defaultconfig.go | 10 +
services/graph/pkg/service/v0/groupware.go | 149 ++++++++
services/graph/pkg/service/v0/messages.go | 83 +++++
services/graph/pkg/service/v0/service.go | 3 +
services/groupware/pkg/config/config.go | 9 +-
.../pkg/config/defaults/defaultconfig.go | 12 +-
services/groupware/pkg/jmap/email.go | 31 --
.../pkg/jmap/http_jmap_api_client.go | 148 ++++++++
services/groupware/pkg/jmap/jmap.go | 348 +++++-------------
.../groupware/pkg/jmap/jmap_api_client.go | 11 +
services/groupware/pkg/jmap/jmap_model.go | 30 ++
services/groupware/pkg/jmap/jmap_test.go | 293 +++++++++++++++
services/groupware/pkg/jmap/jmap_tools.go | 118 ++++++
.../pkg/jmap/jmap_well_known_client.go | 9 +
...eva_context_http_jmap_username_provider.go | 25 ++
.../groupware/pkg/service/http/v0/service.go | 80 ++--
17 files changed, 1048 insertions(+), 326 deletions(-)
create mode 100644 services/graph/pkg/service/v0/groupware.go
create mode 100644 services/graph/pkg/service/v0/messages.go
create mode 100644 services/groupware/pkg/jmap/http_jmap_api_client.go
create mode 100644 services/groupware/pkg/jmap/jmap_api_client.go
create mode 100644 services/groupware/pkg/jmap/jmap_model.go
create mode 100644 services/groupware/pkg/jmap/jmap_test.go
create mode 100644 services/groupware/pkg/jmap/jmap_tools.go
create mode 100644 services/groupware/pkg/jmap/jmap_well_known_client.go
create mode 100644 services/groupware/pkg/jmap/reva_context_http_jmap_username_provider.go
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index 6386025a8..f7d374c89 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -36,6 +36,8 @@ type Config struct {
Keycloak Keycloak `yaml:"keycloak"`
ServiceAccount ServiceAccount `yaml:"service_account"`
+ Mail Mail `yaml:"mail"`
+
Context context.Context `yaml:"-"`
Metadata Metadata `yaml:"metadata_config"`
@@ -177,3 +179,16 @@ type Store struct {
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;GRAPH_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;GRAPH_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
+
+type MasterAuth struct {
+ Username string `yaml:"username" env:"OC_JMAP_MASTER_USERNAME;GROUPWARE_JMAP_MASTER_USERNAME"`
+ Password string `yaml:"password" env:"OC_JMAP_MASTER_PASSWORD;GROUPWARE_JMAP_MASTER_PASSWORD"`
+}
+
+type Mail struct {
+ Master MasterAuth `yaml:"master"`
+ BaseUrl string `yaml:"base_url" env:"OC_JMAP_BASE_URL;GROUPWARE_BASE_URL"`
+ JmapUrl string `yaml:"jmap_url" env:"OC_JMAP_JMAP_URL;GROUPWARE_JMAP_URL"`
+ Timeout time.Duration `yaml:"timeout" env:"OC_JMAP_TIMEOUT"`
+ ContextCacheTTL time.Duration `yaml:"context_cache_ttl" env:"OC_JMAP_CONTEXT_CACHE_TTL"`
+}
diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go
index 0a97411a8..f9df962ec 100644
--- a/services/graph/pkg/config/defaults/defaultconfig.go
+++ b/services/graph/pkg/config/defaults/defaultconfig.go
@@ -135,6 +135,16 @@ func DefaultConfig() *config.Config {
Nodes: []string{"127.0.0.1:9233"},
Database: "graph",
},
+ Mail: config.Mail{
+ Master: config.MasterAuth{
+ Username: "master",
+ Password: "admin",
+ },
+ BaseUrl: "https://stalwart.opencloud.test",
+ JmapUrl: "https://stalwart.opencloud.test/jmap",
+ Timeout: time.Duration(3 * time.Second),
+ ContextCacheTTL: time.Duration(1 * time.Hour),
+ },
}
}
diff --git a/services/graph/pkg/service/v0/groupware.go b/services/graph/pkg/service/v0/groupware.go
new file mode 100644
index 000000000..2ee385813
--- /dev/null
+++ b/services/graph/pkg/service/v0/groupware.go
@@ -0,0 +1,149 @@
+package svc
+
+import (
+ "context"
+ "crypto/tls"
+ "net/http"
+ "time"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/graph/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/jmap"
+
+ "github.com/go-chi/render"
+
+ "github.com/jellydator/ttlcache/v3"
+)
+
+type Groupware struct {
+ logger *log.Logger
+ jmapClient jmap.JmapClient
+ contextCache *ttlcache.Cache[string, jmap.JmapContext]
+ usernameProvider jmap.HttpJmapUsernameProvider // we also need it for ourselves for now
+}
+
+type Message struct {
+ Id string `json:"id"`
+ CreatedDateTime time.Time `json:"createdDateTime"`
+ ReceivedDateTime time.Time `json:"receivedDateTime"`
+ HasAttachments bool `json:"hasAttachments"`
+ InternetMessageId string `json:"InternetMessageId"`
+ Subject string `json:"subject"`
+}
+
+func NewGroupware(logger *log.Logger, config *config.Config) *Groupware {
+ baseUrl := config.Mail.BaseUrl
+ jmapUrl := config.Mail.JmapUrl
+ masterUsername := config.Mail.Master.Username
+ masterPassword := config.Mail.Master.Password
+
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.ResponseHeaderTimeout = time.Duration(config.Mail.Timeout)
+ tlsConfig := &tls.Config{InsecureSkipVerify: true}
+ tr.TLSClientConfig = tlsConfig
+ c := *http.DefaultClient
+ c.Transport = tr
+
+ jmapUsernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider()
+
+ api := jmap.NewHttpJmapApiClient(
+ baseUrl,
+ jmapUrl,
+ &c,
+ jmapUsernameProvider,
+ masterUsername,
+ masterPassword,
+ )
+
+ jmapClient := jmap.NewJmapClient(api, api)
+
+ loader := ttlcache.LoaderFunc[string, jmap.JmapContext](
+ func(c *ttlcache.Cache[string, jmap.JmapContext], key string) *ttlcache.Item[string, jmap.JmapContext] {
+ jmapContext, err := jmapClient.FetchJmapContext(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.ContextCacheTTL)
+ return item
+ },
+ )
+
+ contextCache := ttlcache.New(
+ ttlcache.WithTTL[string, jmap.JmapContext](
+ config.Mail.ContextCacheTTL,
+ ),
+ ttlcache.WithDisableTouchOnHit[string, jmap.JmapContext](),
+ ttlcache.WithLoader(loader),
+ )
+ go contextCache.Start()
+
+ return &Groupware{
+ logger: logger,
+ jmapClient: jmapClient,
+ contextCache: contextCache,
+ usernameProvider: jmapUsernameProvider,
+ }
+}
+
+func pickInbox(folders jmap.JmapFolders) string {
+ for _, folder := range folders.Folders {
+ if folder.Role == "inbox" {
+ return folder.Id
+ }
+ }
+ return ""
+}
+
+func (g Groupware) context(ctx context.Context, logger *log.Logger) (jmap.JmapContext, error) {
+ username, err := g.usernameProvider.GetUsername(ctx, logger)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to retrieve username")
+ return jmap.JmapContext{}, err
+ }
+
+ item := g.contextCache.Get(username)
+ return item.Value(), nil
+}
+
+func (g Groupware) GetMessages(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ logger := g.logger.SubloggerWithRequestID(ctx)
+
+ jmapContext, err := g.context(ctx, &logger)
+ if err != nil {
+ logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("failed to determine Jmap context")
+ errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ ctx = context.WithValue(ctx, jmap.ContextAccountId, jmapContext.AccountId)
+
+ logger.Debug().Msg("fetching folders")
+ folders, err := g.jmapClient.GetMailboxes(jmapContext, ctx, &logger)
+ if err != nil {
+ logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("could not retrieve mailboxes")
+ errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ inboxId := pickInbox(folders)
+ logger.Debug().Str("mailboxId", inboxId).Msg("fetching emails from inbox")
+ emails, err := g.jmapClient.EmailQuery(jmapContext, ctx, &logger, inboxId)
+ if err != nil {
+ logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("could not retrieve emails from inbox")
+ errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ messages := make([]Message, 0, len(emails.Emails))
+ for _, email := range emails.Emails {
+ message := Message{Id: "todo", Subject: email.Subject} // TODO more email fields
+ messages = append(messages, message)
+ }
+
+ render.Status(r, http.StatusOK)
+ render.JSON(w, r, messages)
+}
diff --git a/services/graph/pkg/service/v0/messages.go b/services/graph/pkg/service/v0/messages.go
new file mode 100644
index 000000000..8a5e6afa8
--- /dev/null
+++ b/services/graph/pkg/service/v0/messages.go
@@ -0,0 +1,83 @@
+package svc
+
+import (
+ "net/http"
+ "slices"
+ "strings"
+
+ "github.com/CiscoM31/godata"
+ "github.com/go-chi/render"
+ "github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
+ "github.com/opencloud-eu/opencloud/services/graph/pkg/identity"
+ revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
+ libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// GetMessages implements the Service interface.
+func (g Graph) GetMessages(w http.ResponseWriter, r *http.Request) {
+ logger := g.logger.SubloggerWithRequestID(r.Context())
+ logger.Debug().Msg("calling get messages in /me")
+ sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
+
+ odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
+ if err != nil {
+ logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get messages: query error")
+ errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ u, ok := revactx.ContextGetUser(r.Context())
+ if !ok {
+ logger.Debug().Msg("could not get messages: user not in context")
+ errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
+ return
+ }
+
+ exp, err := identity.GetExpandValues(odataReq.Query)
+ if err != nil {
+ logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get messages: $expand error")
+ errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ var me *libregraph.User
+ // We can just return the user from context unless we need to expand the group memberships
+ if !slices.Contains(exp, "memberOf") {
+ me = identity.CreateUserModelFromCS3(u)
+ } else {
+ var err error
+ logger.Debug().Msg("calling get user on backend")
+ me, err = g.identityBackend.GetUser(r.Context(), u.GetId().GetOpaqueId(), odataReq)
+ if err != nil {
+ logger.Debug().Err(err).Interface("user", u).Msg("could not get user from backend")
+ errorcode.RenderError(w, r, err)
+ return
+ }
+ if me.MemberOf == nil {
+ me.MemberOf = []libregraph.Group{}
+ }
+ }
+
+ // expand appRoleAssignments if requested
+ if slices.Contains(exp, appRoleAssignments) {
+ var err error
+ me.AppRoleAssignments, err = g.fetchAppRoleAssignments(r.Context(), me.GetId())
+ if err != nil {
+ logger.Debug().Err(err).Str("userid", me.GetId()).Msg("could not get appRoleAssignments for self")
+ errorcode.RenderError(w, r, err)
+ return
+ }
+ }
+
+ preferedLanguage, _, err := getUserLanguage(r.Context(), g.valueService, me.GetId())
+ if err != nil {
+ logger.Error().Err(err).Msg("could not get user language")
+ errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not get user language")
+ return
+ }
+
+ me.PreferredLanguage = &preferedLanguage
+
+ render.Status(r, http.StatusOK)
+ render.JSON(w, r, me)
+}
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index f130de6dc..47eb96148 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -202,6 +202,8 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
natskv: options.NatsKeyValue,
}
+ gw := NewGroupware(&options.Logger, options.Config)
+
if err := setIdentityBackends(options, &svc); err != nil {
return svc, err
}
@@ -318,6 +320,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX))
r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX))
})
+ r.Get("/messages", gw.GetMessages)
})
r.Route("/users", func(r chi.Router) {
r.Get("/", svc.GetUsers)
diff --git a/services/groupware/pkg/config/config.go b/services/groupware/pkg/config/config.go
index d6ba3f50d..bfcec4a64 100644
--- a/services/groupware/pkg/config/config.go
+++ b/services/groupware/pkg/config/config.go
@@ -2,6 +2,7 @@ package config
import (
"context"
+ "time"
"github.com/opencloud-eu/opencloud/pkg/shared"
)
@@ -29,8 +30,8 @@ type MasterAuth struct {
}
type Mail struct {
- Master MasterAuth `yaml:"master"`
- JmapUrl string `yaml:"jmap_url" env:"OC_JMAP_URL;GROUPWARE_JMAP_URL"`
- CS3AllowInsecure bool `yaml:"cs3_allow_insecure" env:"OC_INSECURE;GROUPWARE_CS3SOURCE_INSECURE" desc:"Ignore untrusted SSL certificates when connecting to the CS3 source." introductionVersion:"1.0.0"`
- RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
+ Master MasterAuth `yaml:"master"`
+ BaseUrl string `yaml:"base_url" env:"OC_JMAP_BASE_URL;GROUPWARE_BASE_URL"`
+ JmapUrl string `yaml:"jmap_url" env:"OC_JMAP_JMAP_URL;GROUPWARE_JMAP_URL"`
+ Timeout time.Duration `yaml:"timeout" env:"OC_JMAP_TIMEOUT"`
}
diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go
index 69b7aa7d4..d227ba36b 100644
--- a/services/groupware/pkg/config/defaults/defaultconfig.go
+++ b/services/groupware/pkg/config/defaults/defaultconfig.go
@@ -3,7 +3,6 @@ package defaults
import (
"strings"
- "github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
)
@@ -26,12 +25,11 @@ func DefaultConfig() *config.Config {
},
Mail: config.Mail{
Master: config.MasterAuth{
- Username: "master",
- Password: "secret",
+ Username: "masteradmin",
+ Password: "admin",
},
- JmapUrl: "https://stalwart.opencloud.test/jmap",
- RevaGateway: shared.DefaultRevaConfig().Address,
- CS3AllowInsecure: false,
+ BaseUrl: "https://stalwart.opencloud.test",
+ JmapUrl: "https://stalwart.opencloud.test/jmap",
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9276",
@@ -79,6 +77,8 @@ func EnsureDefaults(cfg *config.Config) {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
+ // TODO p.bleser add Mail here
+
}
// Sanitize sanitized the configuration
diff --git a/services/groupware/pkg/jmap/email.go b/services/groupware/pkg/jmap/email.go
index 8bed85f41..0c230dfbb 100644
--- a/services/groupware/pkg/jmap/email.go
+++ b/services/groupware/pkg/jmap/email.go
@@ -8,34 +8,3 @@ type Email struct {
HasAttachments bool
Received time.Time
}
-
-func NewEmail(elem map[string]any) Email {
- fromList := elem["from"].([]any)
- from := fromList[0].(map[string]any)
- var subject string
- var value any = elem["subject"]
- if value != nil {
- subject = value.(string)
- } else {
- subject = ""
- }
- var hasAttachments bool
- hasAttachmentsAny := elem["hasAttachments"]
- if hasAttachmentsAny != nil {
- hasAttachments = hasAttachmentsAny.(bool)
- } else {
- hasAttachments = false
- }
-
- received, receivedErr := time.ParseInLocation(time.RFC3339, elem["receivedAt"].(string), time.UTC)
- if receivedErr != nil {
- panic(receivedErr)
- }
-
- return Email{
- From: from["email"].(string),
- Subject: subject,
- HasAttachments: hasAttachments,
- Received: received,
- }
-}
diff --git a/services/groupware/pkg/jmap/http_jmap_api_client.go b/services/groupware/pkg/jmap/http_jmap_api_client.go
new file mode 100644
index 000000000..050b2feb8
--- /dev/null
+++ b/services/groupware/pkg/jmap/http_jmap_api_client.go
@@ -0,0 +1,148 @@
+package jmap
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "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)
+}
+
+type HttpJmapApiClient struct {
+ baseurl string
+ jmapurl string
+ client *http.Client
+ usernameProvider HttpJmapUsernameProvider
+ masterUser string
+ masterPassword string
+}
+
+/*
+func bearer(req *http.Request, token string) {
+ req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
+}
+*/
+
+func NewHttpJmapApiClient(baseurl string, jmapurl string, client *http.Client, usernameProvider HttpJmapUsernameProvider, masterUser string, masterPassword string) *HttpJmapApiClient {
+ return &HttpJmapApiClient{
+ baseurl: baseurl,
+ jmapurl: jmapurl,
+ client: client,
+ usernameProvider: usernameProvider,
+ masterUser: masterUser,
+ masterPassword: masterPassword,
+ }
+}
+
+func (h *HttpJmapApiClient) auth(logger *log.Logger, ctx context.Context, req *http.Request) error {
+ username, err := h.usernameProvider.GetUsername(ctx, logger)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to find username")
+ }
+ masterUsername := username + "%" + h.masterUser
+ req.SetBasicAuth(masterUsername, h.masterPassword)
+ return nil
+}
+
+func (h *HttpJmapApiClient) authWithUsername(logger *log.Logger, username string, req *http.Request) error {
+ masterUsername := username + "%" + h.masterUser
+ req.SetBasicAuth(masterUsername, h.masterPassword)
+ return nil
+}
+
+func (h *HttpJmapApiClient) GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error) {
+ wellKnownUrl := 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 WellKnownJmap{}, err
+ }
+ h.authWithUsername(logger, username, req)
+
+ res, err := h.client.Do(req)
+ if err != nil {
+ logger.Error().Err(err).Msgf("failed to perform GET %v", wellKnownUrl)
+ return WellKnownJmap{}, err
+ }
+ if res.StatusCode != 200 {
+ logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 200")
+ return WellKnownJmap{}, fmt.Errorf("HTTP response status is %v", res.Status)
+ }
+ if res.Body != nil {
+ defer func(Body io.ReadCloser) {
+ err := Body.Close()
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to close response body")
+ }
+ }(res.Body)
+ }
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to read response body")
+ return WellKnownJmap{}, err
+ }
+
+ var data WellKnownJmap
+ 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 WellKnownJmap{}, err
+ }
+
+ return data, nil
+}
+
+func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error) {
+ jmapUrl := h.jmapurl
+
+ bodyBytes, marshalErr := json.Marshal(request)
+ if marshalErr != nil {
+ logger.Error().Err(marshalErr).Msg("failed to marshall JSON payload")
+ return nil, marshalErr
+ }
+
+ req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
+ if reqErr != nil {
+ logger.Error().Err(reqErr).Msgf("failed to create GET request for %v", jmapUrl)
+ return nil, reqErr
+ }
+ req.Header.Add("Content-Type", "application/json")
+ req.Header.Add("User-Agent", "OpenCloud/"+version.GetString())
+ h.auth(logger, ctx, req)
+
+ res, err := h.client.Do(req)
+ if err != nil {
+ logger.Error().Err(err).Msgf("failed to perform GET %v", jmapUrl)
+ return nil, 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)
+ }
+ if res.Body != nil {
+ defer func(Body io.ReadCloser) {
+ err := Body.Close()
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to close response body")
+ }
+ }(res.Body)
+ }
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to read response body")
+ return nil, err
+ }
+
+ return body, nil
+}
diff --git a/services/groupware/pkg/jmap/jmap.go b/services/groupware/pkg/jmap/jmap.go
index d44fc262b..01206a121 100644
--- a/services/groupware/pkg/jmap/jmap.go
+++ b/services/groupware/pkg/jmap/jmap.go
@@ -1,243 +1,95 @@
package jmap
import (
- "bytes"
"context"
"encoding/json"
"fmt"
- "io"
- "log"
- "log/slog"
- "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
)
-type WellKnownJmap struct {
- ApiUrl string `json:"apiUrl"`
- PrimaryAccounts map[string]string `json:"primaryAccounts"`
-}
-
-/*
-func bearer(req *http.Request, token string) {
- req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
-}
-*/
-
-func fetch[T any](client *http.Client, url string, username string, password string, mapper func(body *[]byte) T) T {
- req, reqErr := http.NewRequest(http.MethodGet, url, nil)
- if reqErr != nil {
- panic(reqErr)
- }
- req.SetBasicAuth(username, password)
-
- res, getErr := client.Do(req)
- if getErr != nil {
- panic(getErr)
- }
- if res.StatusCode != 200 {
- panic(fmt.Sprintf("HTTP status code not 200: %d", res.StatusCode))
- }
- if res.Body != nil {
- defer func(Body io.ReadCloser) {
- err := Body.Close()
- if err != nil {
- log.Fatal(err)
- }
- }(res.Body)
- }
-
- body, readErr := io.ReadAll(res.Body)
- if readErr != nil {
- log.Fatal(readErr)
- }
-
- return mapper(&body)
-}
-
-func simpleCommand(cmd string, params map[string]any) [][]any {
- jmap := make([][]any, 1)
- jmap[0] = make([]any, 3)
- jmap[0][0] = cmd
- jmap[0][1] = params
- jmap[0][2] = "0"
- return jmap
-}
-
const (
JmapCore = "urn:ietf:params:jmap:core"
JmapMail = "urn:ietf:params:jmap:mail"
)
-func command[T any](client *http.Client, ctx context.Context, url string, username string, password string, methodCalls *[][]any, mapper func(body *[]byte) T) T {
- jmapWrapper := map[string]any{
- "using": []string{JmapCore, JmapMail},
- "methodCalls": methodCalls,
- }
-
- /*
- {
- "using":[
- "urn:ietf:params:jmap:core",
- "urn:ietf:params:jmap:mail"
- ],
- "methodCalls":[
- [
- "Identity/get", {
- "accountId": "cp"
- }, "0"
- ]
- ]
- }
- */
-
- bodyBytes, marshalErr := json.Marshal(jmapWrapper)
- if marshalErr != nil {
- panic(marshalErr)
- }
-
- req, reqErr := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
- if reqErr != nil {
- panic(reqErr)
- }
- req.SetBasicAuth(username, password)
- req.Header.Add("Content-Type", "application/json")
-
- slog.Info("jmap", "url", url, "username", username)
- res, postErr := client.Do(req)
- if postErr != nil {
- panic(postErr)
- }
- if res.StatusCode != 200 {
- panic(fmt.Sprintf("HTTP status code not 200: %d", res.StatusCode))
- }
- if res.Body != nil {
- defer func(Body io.ReadCloser) {
- err := Body.Close()
- if err != nil {
- log.Fatal(err)
- }
- }(res.Body)
- }
-
- body, readErr := io.ReadAll(res.Body)
- if readErr != nil {
- log.Fatal(readErr)
- }
-
- if slog.Default().Enabled(ctx, slog.LevelDebug) {
- slog.Debug(ctx.Value("operation").(string) + " response: " + string(body))
- }
-
- return mapper(&body)
-}
-
-type JmapFolder struct {
- Id string
- Name string
- Role string
- TotalEmails int
- UnreadEmails int
- TotalThreads int
- UnreadThreads int
-}
-type JmapFolders struct {
- Folders []JmapFolder
- state string
-}
-
-type JmapCommandResponse struct {
- MethodResponses [][]any `json:"methodResponses"`
- SessionState string `json:"sessionState"`
-}
-
type JmapClient struct {
- client *http.Client
- username string
- password string
- url string
- accountId string
- ctx context.Context
+ wellKnown JmapWellKnownClient
+ api JmapApiClient
}
-func New(client *http.Client, ctx context.Context, username string, password string, url string, accountId string) JmapClient {
+func NewJmapClient(wellKnown JmapWellKnownClient, api JmapApiClient) JmapClient {
return JmapClient{
- client: client,
- ctx: ctx,
- username: username,
- password: password,
- url: url,
- accountId: accountId,
+ wellKnown: wellKnown,
+ api: api,
}
}
-func (jmap *JmapClient) FetchWellKnown() WellKnownJmap {
- return fetch(jmap.client, jmap.url+"/.well-known/jmap", jmap.username, jmap.password, func(body *[]byte) WellKnownJmap {
- var data WellKnownJmap
- jsonErr := json.Unmarshal(*body, &data)
- if jsonErr != nil {
- panic(jsonErr)
- }
-
- /*
- u, urlErr := url.Parse(data.ApiUrl)
- if urlErr != nil {
- panic(urlErr)
- }
- jmap.url = jmap.url + u.Path
- */
- jmap.accountId = data.PrimaryAccounts[JmapMail]
- return data
- })
+type JmapContext struct {
+ AccountId string
+ JmapUrl string
}
-func (jmap *JmapClient) GetMailboxes() JmapFolders {
- /*
- {"methodResponses":
- [["Mailbox/get",
- {"accountId":"cs","state":"n","list":
- [{"id":"a","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true,"totalEmails":0,"unreadEmails":0,"totalThreads":0,"unreadThreads":0,"myRights":{"mayReadItems":true,"mayAddItems":true,"mayRemoveItems":true,"maySetSeen":true,"maySetKeywords":true,"mayCreateChild":true,"mayRename":true,"mayDelete":true,"maySubmit":true}},{"id":"b","name":"Deleted Items","parentId":null,"role":"trash","sortOrder":0,"isSubscribed":true,"totalEmails":0,"unreadEmails":0,"totalThreads":0,"unreadThreads":0,"myRights":{"mayReadItems":true,"mayAddItems":true,"mayRemoveItems":true,"maySetSeen":true,"maySetKeywords":true,"mayCreateChild":true,"mayRename":true,"mayDelete":true,"maySubmit":true}},{"id":"c","name":"Junk Mail","parentId":null,"role":"junk","sortOrder":0,"isSubscribed":true,"totalEmails":0,"unreadEmails":0,"totalThreads":0,"unreadThreads":0,"myRights":{"mayReadItems":true,"mayAddItems":true,"mayRemoveItems":true,"maySetSeen":true,"maySetKeywords":true,"mayCreateChild":true,"mayRename":true,"mayDelete":true,"maySubmit":true}},{"id":"d","name":"Drafts","parentId":null,"role":"drafts","sortOrder":0,"isSubscribed":true,"totalEmails":0,"unreadEmails":0,"totalThreads":0,"unreadThreads":0,"myRights":{"mayReadItems":true,"mayAddItems":true,"mayRemoveItems":true,"maySetSeen":true,"maySetKeywords":true,"mayCreateChild":true,"mayRename":true,"mayDelete":true,"maySubmit":true}},{"id":"e","name":"Sent Items","parentId":null,"role":"sent","sortOrder":0,"isSubscribed":true,"totalEmails":0,"unreadEmails":0,"totalThreads":0,"unreadThreads":0,"myRights":{"mayReadItems":true,"mayAddItems":true,"mayRemoveItems":true,"maySetSeen":true,"maySetKeywords":true,"mayCreateChild":true,"mayRename":true,"mayDelete":true,"maySubmit":true}}],"notFound":[]},"0"]],"sessionState":"3e25b2a0"}
+func NewJmapContext(wellKnown WellKnownJmap) (JmapContext, error) {
+ // TODO validate
+ return JmapContext{
+ AccountId: wellKnown.PrimaryAccounts[JmapMail],
+ JmapUrl: wellKnown.ApiUrl,
+ }, nil
+}
- */
- cmd := simpleCommand("Mailbox/get", map[string]any{"accountId": jmap.accountId})
- commandCtx := context.WithValue(jmap.ctx, "operation", "GetMailboxes")
- return command(jmap.client, commandCtx, jmap.url, jmap.username, jmap.password, &cmd, func(body *[]byte) JmapFolders {
+func (j *JmapClient) FetchJmapContext(username string, logger *log.Logger) (JmapContext, error) {
+ wk, err := j.wellKnown.GetWellKnown(username, logger)
+ if err != nil {
+ return JmapContext{}, err
+ }
+ return NewJmapContext(wk)
+}
+
+type ContextKey int
+
+const (
+ ContextAccountId ContextKey = iota
+ ContextOperationId
+)
+
+func (j *JmapClient) validate(jmapContext JmapContext) error {
+ if jmapContext.AccountId == "" {
+ return fmt.Errorf("AccountId not set")
+ }
+ return nil
+}
+
+func (j *JmapClient) GetMailboxes(jc JmapContext, ctx context.Context, logger *log.Logger) (JmapFolders, error) {
+ if err := j.validate(jc); err != nil {
+ return JmapFolders{}, err
+ }
+
+ logger.Info().Str("command", "Mailbox/get").Str("accountId", jc.AccountId).Msg("GetMailboxes")
+ cmd := simpleCommand("Mailbox/get", map[string]any{"accountId": jc.AccountId})
+ commandCtx := context.WithValue(ctx, ContextOperationId, "GetMailboxes")
+ return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (JmapFolders, error) {
var data JmapCommandResponse
- jsonErr := json.Unmarshal(*body, &data)
- if jsonErr != nil {
- panic(jsonErr)
+ err := json.Unmarshal(*body, &data)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to deserialize body JSON payload")
+ var zero JmapFolders
+ return zero, err
}
- first := data.MethodResponses[0]
- params := first[1]
- payload := params.(map[string]any)
- state := payload["state"].(string)
- list := payload["list"].([]any)
- folders := make([]JmapFolder, len(list))
- for i, a := range list {
- item := a.(map[string]any)
- folders[i] = JmapFolder{
- Id: item["id"].(string),
- Name: item["name"].(string),
- Role: item["role"].(string),
- TotalEmails: int(item["totalEmails"].(float64)),
- UnreadEmails: int(item["unreadEmails"].(float64)),
- TotalThreads: int(item["totalThreads"].(float64)),
- UnreadThreads: int(item["unreadThreads"].(float64)),
- }
- }
- return JmapFolders{Folders: folders, state: state}
+ return parseMailboxGetResponse(data)
})
}
-type Emails struct {
- Emails []Email
- State string
-}
+func (j *JmapClient) EmailQuery(jc JmapContext, ctx context.Context, logger *log.Logger, mailboxId string) (Emails, error) {
+ if err := j.validate(jc); err != nil {
+ return Emails{}, err
+ }
-func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
cmd := make([][]any, 4)
cmd[0] = []any{
"Email/query",
map[string]any{
- "accountId": jmap.accountId,
+ "accountId": jc.AccountId,
"filter": map[string]any{
"inMailbox": mailboxId,
},
@@ -257,7 +109,7 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
cmd[1] = []any{
"Email/get",
map[string]any{
- "accountId": jmap.accountId,
+ "accountId": jc.AccountId,
"#ids": map[string]any{
"resultOf": "0",
"name": "Email/query",
@@ -270,7 +122,7 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
cmd[2] = []any{
"Thread/get",
map[string]any{
- "accountId": jmap.accountId,
+ "accountId": jc.AccountId,
"#ids": map[string]any{
"resultOf": "1",
"name": "Email/get",
@@ -282,7 +134,7 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
cmd[3] = []any{
"Email/get",
map[string]any{
- "accountId": jmap.accountId,
+ "accountId": jc.AccountId,
"#ids": map[string]any{
"resultOf": "2",
"name": "Thread/get",
@@ -303,58 +155,36 @@ func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
"3",
}
- commandCtx := context.WithValue(jmap.ctx, "operation", "GetMailboxes")
- return command(jmap.client, commandCtx, jmap.url, jmap.username, jmap.password, &cmd, func(body *[]byte) Emails {
+ commandCtx := context.WithValue(ctx, ContextOperationId, "EmailQuery")
+ return command(j.api, logger, commandCtx, &cmd, func(body *[]byte) (Emails, error) {
var data JmapCommandResponse
- jsonErr := json.Unmarshal(*body, &data)
- if jsonErr != nil {
- panic(jsonErr)
+ err := json.Unmarshal(*body, &data)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to unmarshal response payload")
+ return Emails{}, err
}
- matches := make([][]any, 1)
- for _, elem := range data.MethodResponses {
- if elem[0] == "Email/get" && elem[2] == "3" {
- matches = append(matches, elem)
+ first := retrieveResponseMatch(&data, 3, "Email/get", "3")
+ if first == nil {
+ return Emails{Emails: []Email{}, State: data.SessionState}, nil
+ }
+ if len(first) != 3 {
+ return Emails{}, fmt.Errorf("wrong Email/get response payload size, expecting a length of 3 but it is %v", len(first))
+ }
+
+ payload := first[1].(map[string]any)
+ list, listExists := payload["list"].([]any)
+ if !listExists {
+ return Emails{}, fmt.Errorf("wrong Email/get response payload size, expecting a length of 3 but it is %v", len(first))
+ }
+
+ emails := make([]Email, 0, len(list))
+ for _, elem := range list {
+ email, err := mapEmail(elem.(map[string]any))
+ if err != nil {
+ return Emails{}, err
}
+ emails = append(emails, email)
}
- /*
- matches := lo.Filter(data.MethodResponses, func(elem []any, index int) bool {
- return elem[0] == "Email/get" && elem[2] == "3"
- })
- */
- payload := matches[0][1].(map[string]any)
- list := payload["list"].([]any)
-
- /*
- {
- "threadId": "cc",
- "mailboxIds": {
- "a": true
- },
- "keywords": {},
- "hasAttachment": false,
- "from": [
- {
- "name": null,
- "email": "root@nsa.gov"
- }
- ],
- "subject": "Hello 5",
- "receivedAt": "2025-04-10T13:07:27Z",
- "size": 47,
- "preview": "Hi <3",
- "id": "iiaaaaaa"
- },
- */
-
- emails := make([]Email, len(list))
- for i, elem := range list {
- emails[i] = NewEmail(elem.(map[string]any))
- }
- /*
- emails := lo.Map(list, func(elem any, _ int) Email {
- return NewEmail(elem.(map[string]any))
- })
- */
- return Emails{Emails: emails, State: data.SessionState}
+ return Emails{Emails: emails, State: data.SessionState}, nil
})
}
diff --git a/services/groupware/pkg/jmap/jmap_api_client.go b/services/groupware/pkg/jmap/jmap_api_client.go
new file mode 100644
index 000000000..1a054742c
--- /dev/null
+++ b/services/groupware/pkg/jmap/jmap_api_client.go
@@ -0,0 +1,11 @@
+package jmap
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+type JmapApiClient interface {
+ Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error)
+}
diff --git a/services/groupware/pkg/jmap/jmap_model.go b/services/groupware/pkg/jmap/jmap_model.go
new file mode 100644
index 000000000..7262889e6
--- /dev/null
+++ b/services/groupware/pkg/jmap/jmap_model.go
@@ -0,0 +1,30 @@
+package jmap
+
+type WellKnownJmap struct {
+ ApiUrl string `json:"apiUrl"`
+ PrimaryAccounts map[string]string `json:"primaryAccounts"`
+}
+
+type JmapFolder struct {
+ Id string
+ Name string
+ Role string
+ TotalEmails int
+ UnreadEmails int
+ TotalThreads int
+ UnreadThreads int
+}
+type JmapFolders struct {
+ Folders []JmapFolder
+ state string
+}
+
+type JmapCommandResponse struct {
+ MethodResponses [][]any `json:"methodResponses"`
+ SessionState string `json:"sessionState"`
+}
+
+type Emails struct {
+ Emails []Email
+ State string
+}
diff --git a/services/groupware/pkg/jmap/jmap_test.go b/services/groupware/pkg/jmap/jmap_test.go
new file mode 100644
index 000000000..ae9f1c496
--- /dev/null
+++ b/services/groupware/pkg/jmap/jmap_test.go
@@ -0,0 +1,293 @@
+package jmap
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "testing"
+ "time"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/stretchr/testify/require"
+)
+
+const mails1 = `{"methodResponses": [
+["Email/query",{
+ "accountId":"j",
+ "queryState":"sqcakzewfqdk7oay",
+ "canCalculateChanges":true,
+ "position":0,
+ "ids":["fmaaaabh"],
+ "total":1
+},"0"],
+["Email/get",{
+ "accountId":"j",
+ "state":"sqcakzewfqdk7oay",
+ "list":[
+ {"threadId":"bl","id":"fmaaaabh"}
+ ],"notFound":[]
+},"1"],
+["Thread/get",{
+ "accountId":"j",
+ "state":"sqcakzewfqdk7oay",
+ "list":[
+ {"id":"bl","emailIds":["fmaaaabh"]}
+ ],"notFound":[]
+},"2"],
+["Email/get",{
+ "accountId":"j",
+ "state":"sqcakzewfqdk7oay",
+ "list":[
+ {"threadId":"bl","mailboxIds":{"a":true},"keywords":{},"hasAttachment":false,"from":[{"name":"current generally","email":"current.generally"}],"subject":"eros auctor proin","receivedAt":"2025-04-30T09:47:44Z","size":15423,"preview":"Lorem ipsum dolor sit amet consectetur adipiscing elit sed urna tristique himenaeos eu a mattis laoreet aliquet enim. Magnis est facilisis nibh nisl vitae nisi mauris nostra velit donec erat pellentesque sagittis ligula turpis suscipit ultricies. Morbi ...","id":"fmaaaabh"}
+ ],"notFound":[]
+},"3"]
+],"sessionState":"3e25b2a0"
+}`
+
+const mailboxes = `{"methodResponses": [
+ ["Mailbox/get", {
+ "accountId":"cs",
+ "state":"n",
+ "list": [
+ {
+ "id":"a",
+ "name":"Inbox",
+ "parentId":null,
+ "role":"inbox",
+ "sortOrder":0,
+ "isSubscribed":true,
+ "totalEmails":0,
+ "unreadEmails":0,
+ "totalThreads":0,
+ "unreadThreads":0,
+ "myRights":{
+ "mayReadItems":true,
+ "mayAddItems":true,
+ "mayRemoveItems":true,
+ "maySetSeen":true,
+ "maySetKeywords":true,
+ "mayCreateChild":true,
+ "mayRename":true,
+ "mayDelete":true,
+ "maySubmit":true
+ }
+ },{
+ "id":"b",
+ "name":"Deleted Items",
+ "parentId":null,
+ "role":"trash",
+ "sortOrder":0,
+ "isSubscribed":true,
+ "totalEmails":0,
+ "unreadEmails":0,
+ "totalThreads":0,
+ "unreadThreads":0,
+ "myRights":{
+ "mayReadItems":true,
+ "mayAddItems":true,
+ "mayRemoveItems":true,
+ "maySetSeen":true,
+ "maySetKeywords":true,
+ "mayCreateChild":true,
+ "mayRename":true,
+ "mayDelete":true,
+ "maySubmit":true
+ }
+ },{
+ "id":"c",
+ "name":"Junk Mail",
+ "parentId":null,
+ "role":"junk",
+ "sortOrder":0,
+ "isSubscribed":true,
+ "totalEmails":0,
+ "unreadEmails":0,
+ "totalThreads":0,
+ "unreadThreads":0,
+ "myRights":{
+ "mayReadItems":true,
+ "mayAddItems":true,
+ "mayRemoveItems":true,
+ "maySetSeen":true,
+ "maySetKeywords":true,
+ "mayCreateChild":true,
+ "mayRename":true,
+ "mayDelete":true,
+ "maySubmit":true
+ }
+ },{
+ "id":"d",
+ "name":"Drafts",
+ "parentId":null,
+ "role":"drafts",
+ "sortOrder":0,
+ "isSubscribed":true,
+ "totalEmails":0,
+ "unreadEmails":0,
+ "totalThreads":0,
+ "unreadThreads":0,
+ "myRights":{
+ "mayReadItems":true,
+ "mayAddItems":true,
+ "mayRemoveItems":true,
+ "maySetSeen":true,
+ "maySetKeywords":true,
+ "mayCreateChild":true,
+ "mayRename":true,
+ "mayDelete":true,
+ "maySubmit":true
+ }
+ },{
+ "id":"e",
+ "name":"Sent Items",
+ "parentId":null,
+ "role":"sent",
+ "sortOrder":0,
+ "isSubscribed":true,
+ "totalEmails":0,
+ "unreadEmails":0,
+ "totalThreads":0,
+ "unreadThreads":0,
+ "myRights":{
+ "mayReadItems":true,
+ "mayAddItems":true,
+ "mayRemoveItems":true,
+ "maySetSeen":true,
+ "maySetKeywords":true,
+ "mayCreateChild":true,
+ "mayRename":true,
+ "mayDelete":true,
+ "maySubmit":true
+ }
+ }
+ ],
+ "notFound":[]
+ },"0"]
+], "sessionState":"3e25b2a0"
+}`
+const mails2 = `{"methodResponses":[
+ ["Email/query",{
+ "accountId":"j",
+ "queryState":"sqcakzewfqdk7oay",
+ "canCalculateChanges":true,
+ "position":0,
+ "ids":["fmaaaabh"],
+ "total":1
+ }, "0"],
+ ["Email/get", {
+ "accountId":"j",
+ "state":"sqcakzewfqdk7oay",
+ "list":[
+ {
+ "threadId":"bl",
+ "id":"fmaaaabh"
+ }
+ ],
+ "notFound":[]
+ }, "1"],
+ ["Thread/get",{
+ "accountId":"j",
+ "state":"sqcakzewfqdk7oay",
+ "list":[
+ {
+ "id":"bl",
+ "emailIds":["fmaaaabh"]
+ }
+ ],
+ "notFound":[]
+ }, "2 "],
+ ["Email/get",{
+ "accountId":"j",
+ "state":"sqcakzewfqdk7oay",
+ "list":[
+ {
+ "threadId":"bl",
+ "mailboxIds":{"a":true},
+ "keywords":{},
+ "hasAttachment":false,
+ "from":[
+ {"name":"current generally", "email":"current.generally@example.com"}
+ ],
+ "subject":"eros auctor proin",
+ "receivedAt":"2025-04-30T09:47:44Z",
+ "size":15423,
+ "preview":"Lorem ipsum dolor sit amet consectetur adipiscing elit sed urna tristique himenaeos eu a mattis laoreet aliquet enim. Magnis est facilisis nibh nisl vitae nisi mauris nostra velit donec erat pellentesque sagittis ligula turpis suscipit ultricies. Morbi ...",
+ "id":"fmaaaabh"
+ }
+ ],
+ "notFound":[]
+ }, "3"]
+ ], "sessionState":"3e25b2a0"
+ }`
+
+type TestJmapWellKnownClient struct {
+ t *testing.T
+}
+
+func NewTestJmapWellKnownClient(t *testing.T) JmapWellKnownClient {
+ return &TestJmapWellKnownClient{t: t}
+}
+
+func (t *TestJmapWellKnownClient) GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error) {
+ return WellKnownJmap{
+ ApiUrl: "test://",
+ PrimaryAccounts: map[string]string{JmapMail: generateRandomString(2 + seededRand.Intn(10))},
+ }, nil
+}
+
+type TestJmapApiClient struct {
+ t *testing.T
+}
+
+func NewTestJmapApiClient(t *testing.T) JmapApiClient {
+ return &TestJmapApiClient{t: t}
+}
+
+func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, request map[string]any) ([]byte, error) {
+ methodCalls := request["methodCalls"].(*[][]any)
+ command := (*methodCalls)[0][0].(string)
+ switch command {
+ case "Mailbox/get":
+ return []byte(mailboxes), nil
+ case "Email/query":
+ return []byte(mails1), nil
+ default:
+ require.Fail(t.t, "unsupported jmap command: %v", command)
+ return nil, fmt.Errorf("unsupported jmap command: %v", command)
+ }
+}
+
+const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
+
+func generateRandomString(length int) string {
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[seededRand.Intn(len(charset))]
+ }
+ return string(b)
+}
+
+func TestRequests(t *testing.T) {
+ require := require.New(t)
+ apiClient := NewTestJmapApiClient(t)
+ wkClient := NewTestJmapWellKnownClient(t)
+ logger := log.NopLogger()
+ ctx := context.Background()
+ client := NewJmapClient(wkClient, apiClient)
+
+ jc := JmapContext{AccountId: "123", JmapUrl: "test://"}
+
+ folders, err := client.GetMailboxes(jc, ctx, &logger)
+ require.NoError(err)
+ require.Len(folders.Folders, 5)
+
+ emails, err := client.EmailQuery(jc, ctx, &logger, "Inbox")
+ require.NoError(err)
+ require.Len(emails.Emails, 1)
+
+ email := emails.Emails[0]
+ require.Equal("eros auctor proin", email.Subject)
+ require.Equal(false, email.HasAttachments)
+}
diff --git a/services/groupware/pkg/jmap/jmap_tools.go b/services/groupware/pkg/jmap/jmap_tools.go
new file mode 100644
index 000000000..ee9a999b4
--- /dev/null
+++ b/services/groupware/pkg/jmap/jmap_tools.go
@@ -0,0 +1,118 @@
+package jmap
+
+import (
+ "context"
+ "time"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+func command[T any](api JmapApiClient,
+ logger *log.Logger,
+ ctx context.Context,
+ methodCalls *[][]any,
+ mapper func(body *[]byte) (T, error)) (T, error) {
+ body := map[string]any{
+ "using": []string{JmapCore, JmapMail},
+ "methodCalls": methodCalls,
+ }
+
+ /*
+ {
+ "using":[
+ "urn:ietf:params:jmap:core",
+ "urn:ietf:params:jmap:mail"
+ ],
+ "methodCalls":[
+ [
+ "Identity/get", {
+ "accountId": "cp"
+ }, "0"
+ ]
+ ]
+ }
+ */
+
+ responseBody, err := api.Command(ctx, logger, body)
+ if err != nil {
+ var zero T
+ return zero, err
+ }
+ return mapper(&responseBody)
+}
+
+func simpleCommand(cmd string, params map[string]any) [][]any {
+ jmap := make([][]any, 1)
+ jmap[0] = make([]any, 3)
+ jmap[0][0] = cmd
+ jmap[0][1] = params
+ jmap[0][2] = "0"
+ return jmap
+}
+
+func mapFolder(item map[string]any) JmapFolder {
+ return JmapFolder{
+ Id: item["id"].(string),
+ Name: item["name"].(string),
+ Role: item["role"].(string),
+ TotalEmails: int(item["totalEmails"].(float64)),
+ UnreadEmails: int(item["unreadEmails"].(float64)),
+ TotalThreads: int(item["totalThreads"].(float64)),
+ UnreadThreads: int(item["unreadThreads"].(float64)),
+ }
+}
+
+func parseMailboxGetResponse(data JmapCommandResponse) (JmapFolders, error) {
+ first := data.MethodResponses[0]
+ params := first[1]
+ payload := params.(map[string]any)
+ state := payload["state"].(string)
+ list := payload["list"].([]any)
+ folders := make([]JmapFolder, 0, len(list))
+ for _, a := range list {
+ item := a.(map[string]any)
+ folder := mapFolder(item)
+ folders = append(folders, folder)
+ }
+ return JmapFolders{Folders: folders, state: state}, nil
+}
+
+func mapEmail(elem map[string]any) (Email, error) {
+ fromList := elem["from"].([]any)
+ from := fromList[0].(map[string]any)
+ var subject string
+ var value any = elem["subject"]
+ if value != nil {
+ subject = value.(string)
+ } else {
+ subject = ""
+ }
+ var hasAttachments bool
+ hasAttachmentsAny := elem["hasAttachments"]
+ if hasAttachmentsAny != nil {
+ hasAttachments = hasAttachmentsAny.(bool)
+ } else {
+ hasAttachments = false
+ }
+
+ received, err := time.ParseInLocation(time.RFC3339, elem["receivedAt"].(string), time.UTC)
+ if err != nil {
+ return Email{}, err
+ }
+
+ return Email{
+ From: from["email"].(string),
+ Subject: subject,
+ HasAttachments: hasAttachments,
+ Received: received,
+ }, nil
+}
+
+func retrieveResponseMatch(data *JmapCommandResponse, length int, operation string, tag string) []any {
+ for _, elem := range data.MethodResponses {
+ if len(elem) == length && elem[0] == operation && elem[2] == tag {
+ return elem
+ }
+ }
+ return nil
+}
diff --git a/services/groupware/pkg/jmap/jmap_well_known_client.go b/services/groupware/pkg/jmap/jmap_well_known_client.go
new file mode 100644
index 000000000..c96ca0bc1
--- /dev/null
+++ b/services/groupware/pkg/jmap/jmap_well_known_client.go
@@ -0,0 +1,9 @@
+package jmap
+
+import (
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+type JmapWellKnownClient interface {
+ GetWellKnown(username string, logger *log.Logger) (WellKnownJmap, error)
+}
diff --git a/services/groupware/pkg/jmap/reva_context_http_jmap_username_provider.go b/services/groupware/pkg/jmap/reva_context_http_jmap_username_provider.go
new file mode 100644
index 000000000..fcc3b7a3a
--- /dev/null
+++ b/services/groupware/pkg/jmap/reva_context_http_jmap_username_provider.go
@@ -0,0 +1,25 @@
+package jmap
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
+)
+
+type RevaContextHttpJmapUsernameProvider struct {
+}
+
+func NewRevaContextHttpJmapUsernameProvider() RevaContextHttpJmapUsernameProvider {
+ return RevaContextHttpJmapUsernameProvider{}
+}
+
+func (r RevaContextHttpJmapUsernameProvider) GetUsername(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")
+ }
+ return u.GetUsername(), nil
+}
diff --git a/services/groupware/pkg/service/http/v0/service.go b/services/groupware/pkg/service/http/v0/service.go
index 2174e2eb5..bd00a89e7 100644
--- a/services/groupware/pkg/service/http/v0/service.go
+++ b/services/groupware/pkg/service/http/v0/service.go
@@ -1,7 +1,9 @@
package svc
import (
+ "crypto/tls"
"net/http"
+ "time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@@ -13,14 +15,6 @@ import (
"github.com/opencloud-eu/opencloud/services/groupware/pkg/jmap"
)
-/*
-type contextKey string
-
-const (
- keyContextKey contextKey = "key"
-)
-*/
-
// Service defines the service handlers.
type Service interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
@@ -42,11 +36,7 @@ func NewService(opts ...Option) Service {
),
)
- svc := Groupware{
- config: options.Config,
- mux: m,
- logger: options.Logger,
- }
+ svc := NewGroupware(options.Config, &options.Logger, m)
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Get("/", svc.WellDefined)
@@ -61,17 +51,29 @@ func NewService(opts ...Option) Service {
return svc
}
-// Thumbnails implements the business logic for Service.
type Groupware struct {
- config *config.Config
- logger log.Logger
- mux *chi.Mux
- httpClient *http.Client
+ jmapClient jmap.JmapClient
+ usernameProvider jmap.HttpJmapUsernameProvider
+ config *config.Config
+ logger *log.Logger
+ mux *chi.Mux
}
-// ServeHTTP implements the Service interface.
-func (s Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- s.mux.ServeHTTP(w, r)
+func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux) *Groupware {
+ usernameProvider := jmap.NewRevaContextHttpJmapUsernameProvider()
+ httpApiClient := httpApiClient(config, usernameProvider)
+ jmapClient := jmap.NewJmapClient(httpApiClient, httpApiClient)
+ return &Groupware{
+ jmapClient: jmapClient,
+ usernameProvider: usernameProvider,
+ config: config,
+ mux: mux,
+ logger: logger,
+ }
+}
+
+func (g Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ g.mux.ServeHTTP(w, r)
}
type IndexResponse struct {
@@ -87,10 +89,36 @@ func (g Groupware) Ping(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
-func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
- //logger := g.logger.SubloggerWithRequestID(r.Context())
+func httpApiClient(config *config.Config, usernameProvider jmap.HttpJmapUsernameProvider) *jmap.HttpJmapApiClient {
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.ResponseHeaderTimeout = time.Duration(10) * time.Second
+ tlsConfig := &tls.Config{InsecureSkipVerify: true}
+ tr.TLSClientConfig = tlsConfig
+ c := *http.DefaultClient
+ c.Transport = tr
- client := jmap.New(g.httpClient, r.Context(), "alan", "demo", "https://stalwart.opencloud.test/jmap", "cs")
- wellKnown := client.FetchWellKnown()
- _ = render.Render(w, r, IndexResponse{AccountId: wellKnown.PrimaryAccounts[jmap.JmapMail]})
+ api := jmap.NewHttpJmapApiClient(
+ config.Mail.BaseUrl,
+ config.Mail.JmapUrl,
+ &c,
+ usernameProvider,
+ config.Mail.Master.Username,
+ config.Mail.Master.Password,
+ )
+ return api
+}
+func (g Groupware) WellDefined(w http.ResponseWriter, r *http.Request) {
+ logger := g.logger.SubloggerWithRequestID(r.Context())
+ username, err := g.usernameProvider.GetUsername(r.Context(), &logger)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ jmapContext, err := g.jmapClient.FetchJmapContext(username, &logger)
+ if err != nil {
+ return
+ }
+
+ _ = render.Render(w, r, IndexResponse{AccountId: jmapContext.AccountId})
}