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