From d638fba8c2eebbbd0976ee60c32a91966626b223 Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Wed, 16 Apr 2025 15:46:32 +0200
Subject: [PATCH] WIP: initial implementation of the groupware service
---
Makefile | 1 +
opencloud/pkg/command/services.go | 6 +
opencloud/pkg/init/structs.go | 12 +
opencloud/pkg/runtime/service/service.go | 6 +
pkg/config/config.go | 2 +
pkg/config/defaultconfig.go | 2 +
services/groupware/Makefile | 16 +
services/groupware/cmd/groupware/main.go | 19 +
services/groupware/pkg/command/root.go | 27 ++
services/groupware/pkg/command/server.go | 98 +++++
services/groupware/pkg/command/version.go | 26 ++
services/groupware/pkg/config/config.go | 36 ++
services/groupware/pkg/config/debug.go | 9 +
.../pkg/config/defaults/defaultconfig.go | 89 +++++
services/groupware/pkg/config/http.go | 20 +
services/groupware/pkg/config/log.go | 9 +
services/groupware/pkg/config/parser/parse.go | 39 ++
services/groupware/pkg/config/service.go | 6 +
services/groupware/pkg/config/tracing.go | 21 +
services/groupware/pkg/jmap/email.go | 41 ++
services/groupware/pkg/jmap/jmap.go | 360 ++++++++++++++++++
services/groupware/pkg/logging/logging.go | 17 +
services/groupware/pkg/metrics/metrics.go | 34 ++
services/groupware/pkg/server/debug/option.go | 50 +++
services/groupware/pkg/server/debug/server.go | 24 ++
services/groupware/pkg/server/http/option.go | 83 ++++
services/groupware/pkg/server/http/server.go | 67 ++++
.../pkg/service/http/v0/instrument.go | 25 ++
.../groupware/pkg/service/http/v0/logging.go | 25 ++
.../groupware/pkg/service/http/v0/option.go | 52 +++
.../groupware/pkg/service/http/v0/service.go | 95 +++++
31 files changed, 1317 insertions(+)
create mode 100644 services/groupware/Makefile
create mode 100644 services/groupware/cmd/groupware/main.go
create mode 100644 services/groupware/pkg/command/root.go
create mode 100644 services/groupware/pkg/command/server.go
create mode 100644 services/groupware/pkg/command/version.go
create mode 100644 services/groupware/pkg/config/config.go
create mode 100644 services/groupware/pkg/config/debug.go
create mode 100644 services/groupware/pkg/config/defaults/defaultconfig.go
create mode 100644 services/groupware/pkg/config/http.go
create mode 100644 services/groupware/pkg/config/log.go
create mode 100644 services/groupware/pkg/config/parser/parse.go
create mode 100644 services/groupware/pkg/config/service.go
create mode 100644 services/groupware/pkg/config/tracing.go
create mode 100644 services/groupware/pkg/jmap/email.go
create mode 100644 services/groupware/pkg/jmap/jmap.go
create mode 100644 services/groupware/pkg/logging/logging.go
create mode 100644 services/groupware/pkg/metrics/metrics.go
create mode 100644 services/groupware/pkg/server/debug/option.go
create mode 100644 services/groupware/pkg/server/debug/server.go
create mode 100644 services/groupware/pkg/server/http/option.go
create mode 100644 services/groupware/pkg/server/http/server.go
create mode 100644 services/groupware/pkg/service/http/v0/instrument.go
create mode 100644 services/groupware/pkg/service/http/v0/logging.go
create mode 100644 services/groupware/pkg/service/http/v0/option.go
create mode 100644 services/groupware/pkg/service/http/v0/service.go
diff --git a/Makefile b/Makefile
index 22c8924990..4d0aee176e 100644
--- a/Makefile
+++ b/Makefile
@@ -39,6 +39,7 @@ OC_MODULES = \
services/gateway \
services/graph \
services/groups \
+ services/groupware \
services/idm \
services/idp \
services/invitations \
diff --git a/opencloud/pkg/command/services.go b/opencloud/pkg/command/services.go
index e87548979c..9c3602f186 100644
--- a/opencloud/pkg/command/services.go
+++ b/opencloud/pkg/command/services.go
@@ -25,6 +25,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/command"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/command"
+ groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/command"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/command"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/command"
@@ -138,6 +139,11 @@ var svccmds = []register.Command{
cfg.Groups.Commons = cfg.Commons
})
},
+ func(cfg *config.Config) *cli.Command {
+ return ServiceCommand(cfg, cfg.Groupware.Service.Name, groupware.GetCommands(cfg.Groupware), func(c *config.Config) {
+ cfg.Groupware.Commons = cfg.Commons
+ })
+ },
func(cfg *config.Config) *cli.Command {
return ServiceCommand(cfg, cfg.IDM.Service.Name, idm.GetCommands(cfg.IDM), func(c *config.Config) {
cfg.IDM.Commons = cfg.Commons
diff --git a/opencloud/pkg/init/structs.go b/opencloud/pkg/init/structs.go
index 89f836441a..1f2a554054 100644
--- a/opencloud/pkg/init/structs.go
+++ b/opencloud/pkg/init/structs.go
@@ -32,6 +32,7 @@ type OpenCloudConfig struct {
AuthBearer AuthbearerService `yaml:"auth_bearer"`
Users UsersAndGroupsService `yaml:"users"`
Groups UsersAndGroupsService `yaml:"groups"`
+ Groupware GroupwareService `yaml:"groupware"`
Ocdav InsecureService `yaml:"ocdav"`
Ocm OcmService `yaml:"ocm"`
Thumbnails ThumbnailService `yaml:"thumbnails"`
@@ -126,6 +127,17 @@ type GraphService struct {
ServiceAccount ServiceAccount `yaml:"service_account"`
}
+// GroupwareSettings is the configuration for the groupware settings
+type GroupwareSettings struct {
+ WebdavAllowInsecure bool `yaml:"webdav_allow_insecure"`
+ Cs3AllowInsecure bool `yaml:"cs3_allow_insecure"`
+}
+
+// GroupwareService is the configuration for the groupware service
+type GroupwareService struct {
+ Groupware GroupwareSettings
+}
+
// IdmService is the configuration for the IDM service
type IdmService struct {
ServiceUserPasswords ServiceUserPasswordsSettings `yaml:"service_user_passwords"`
diff --git a/opencloud/pkg/runtime/service/service.go b/opencloud/pkg/runtime/service/service.go
index a02bab58cf..86ad3843fd 100644
--- a/opencloud/pkg/runtime/service/service.go
+++ b/opencloud/pkg/runtime/service/service.go
@@ -35,6 +35,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/command"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/command"
+ groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/command"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/command"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/command"
@@ -199,6 +200,11 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) {
cfg.Groups.Commons = cfg.Commons
return groups.Execute(cfg.Groups)
})
+ reg(3, opts.Config.Groupware.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
+ cfg.Groupware.Context = ctx
+ cfg.Groupware.Commons = cfg.Commons
+ return groupware.Execute(cfg.Groupware)
+ })
reg(3, opts.Config.IDM.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
cfg.IDM.Context = ctx
cfg.IDM.Commons = cfg.Commons
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 1e0c68a41a..dad2c2283a 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -19,6 +19,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/config"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/config"
+ groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/config"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/config"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/config"
@@ -100,6 +101,7 @@ type Config struct {
Gateway *gateway.Config `yaml:"gateway"`
Graph *graph.Config `yaml:"graph"`
Groups *groups.Config `yaml:"groups"`
+ Groupware *groupware.Config `yaml:"groupware"`
IDM *idm.Config `yaml:"idm"`
IDP *idp.Config `yaml:"idp"`
Invitations *invitations.Config `yaml:"invitations"`
diff --git a/pkg/config/defaultconfig.go b/pkg/config/defaultconfig.go
index cc94e0bec2..fb2f7e5f66 100644
--- a/pkg/config/defaultconfig.go
+++ b/pkg/config/defaultconfig.go
@@ -19,6 +19,7 @@ import (
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config/defaults"
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults"
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/config/defaults"
+ groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/config/defaults"
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/config/defaults"
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/config/defaults"
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/config/defaults"
@@ -75,6 +76,7 @@ func DefaultConfig() *Config {
Gateway: gateway.DefaultConfig(),
Graph: graph.DefaultConfig(),
Groups: groups.DefaultConfig(),
+ Groupware: groupware.DefaultConfig(),
IDM: idm.DefaultConfig(),
IDP: idp.DefaultConfig(),
Invitations: invitations.DefaultConfig(),
diff --git a/services/groupware/Makefile b/services/groupware/Makefile
new file mode 100644
index 0000000000..b780ba9e92
--- /dev/null
+++ b/services/groupware/Makefile
@@ -0,0 +1,16 @@
+SHELL := bash
+NAME := groupware
+
+ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
+include ../../.bingo/Variables.mk
+endif
+
+include ../../.make/default.mk
+include ../../.make/recursion.mk
+include ../../.make/go.mk
+include ../../.make/release.mk
+include ../../.make/docs.mk
+
+.PHONY: go-generate
+go-generate: $(MOCKERY)
+ $(MOCKERY)
diff --git a/services/groupware/cmd/groupware/main.go b/services/groupware/cmd/groupware/main.go
new file mode 100644
index 0000000000..ef1507eb49
--- /dev/null
+++ b/services/groupware/cmd/groupware/main.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config/defaults"
+)
+
+func main() {
+ cfg := defaults.DefaultConfig()
+ cfg.Context, _ = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
+ if err := command.Execute(cfg); err != nil {
+ os.Exit(1)
+ }
+}
diff --git a/services/groupware/pkg/command/root.go b/services/groupware/pkg/command/root.go
new file mode 100644
index 0000000000..aa13cb6a5a
--- /dev/null
+++ b/services/groupware/pkg/command/root.go
@@ -0,0 +1,27 @@
+package command
+
+import (
+ "os"
+
+ "github.com/opencloud-eu/opencloud/pkg/clihelper"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "github.com/urfave/cli/v2"
+)
+
+// GetCommands provides all commands for this service
+func GetCommands(cfg *config.Config) cli.Commands {
+ return []*cli.Command{
+ Server(cfg),
+ Version(cfg),
+ }
+}
+
+func Execute(cfg *config.Config) error {
+ app := clihelper.DefaultApp(&cli.App{
+ Name: "groupware",
+ Usage: "Groupware service for OpenCloud",
+ Commands: GetCommands(cfg),
+ })
+
+ return app.RunContext(cfg.Context, os.Args)
+}
diff --git a/services/groupware/pkg/command/server.go b/services/groupware/pkg/command/server.go
new file mode 100644
index 0000000000..613f0f3aa3
--- /dev/null
+++ b/services/groupware/pkg/command/server.go
@@ -0,0 +1,98 @@
+package command
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/oklog/run"
+ "github.com/opencloud-eu/opencloud/pkg/config/configlog"
+ "github.com/opencloud-eu/opencloud/pkg/tracing"
+ "github.com/opencloud-eu/opencloud/pkg/version"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config/parser"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/logging"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/server/debug"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/server/http"
+ "github.com/urfave/cli/v2"
+)
+
+// Server is the entrypoint for the server command.
+func Server(cfg *config.Config) *cli.Command {
+ return &cli.Command{
+ Name: "server",
+ Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
+ Category: "server",
+ Before: func(_ *cli.Context) error {
+ return configlog.ReturnFatal(parser.ParseConfig(cfg))
+ },
+ Action: func(c *cli.Context) error {
+ logger := logging.Configure(cfg.Service.Name, cfg.Log)
+
+ traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name)
+ if err != nil {
+ return err
+ }
+
+ var (
+ gr = run.Group{}
+ ctx, cancel = context.WithCancel(c.Context)
+ m = metrics.New()
+ )
+
+ defer cancel()
+
+ m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
+
+ server, err := debug.Server(
+ debug.Logger(logger),
+ debug.Config(cfg),
+ debug.Context(ctx),
+ )
+ if err != nil {
+ logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
+ return err
+ }
+
+ gr.Add(server.ListenAndServe, func(_ error) {
+ _ = server.Shutdown(ctx)
+ cancel()
+ })
+
+ httpServer, err := http.Server(
+ http.Logger(logger),
+ http.Context(ctx),
+ http.Config(cfg),
+ http.Metrics(m),
+ http.Namespace(cfg.HTTP.Namespace),
+ http.TraceProvider(traceProvider),
+ )
+ if err != nil {
+ logger.Info().
+ Err(err).
+ Str("transport", "http").
+ Msg("Failed to initialize server")
+
+ return err
+ }
+
+ gr.Add(httpServer.Run, func(_ error) {
+ if err == nil {
+ logger.Info().
+ Str("transport", "http").
+ Str("server", cfg.Service.Name).
+ Msg("Shutting down server")
+ } else {
+ logger.Error().Err(err).
+ Str("transport", "http").
+ Str("server", cfg.Service.Name).
+ Msg("Shutting down server")
+ }
+
+ cancel()
+ })
+
+ return gr.Run()
+ },
+ }
+}
diff --git a/services/groupware/pkg/command/version.go b/services/groupware/pkg/command/version.go
new file mode 100644
index 0000000000..b335cb2f12
--- /dev/null
+++ b/services/groupware/pkg/command/version.go
@@ -0,0 +1,26 @@
+package command
+
+import (
+ "fmt"
+
+ "github.com/opencloud-eu/opencloud/pkg/version"
+
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "github.com/urfave/cli/v2"
+)
+
+// Version prints the service versions of all running instances.
+func Version(cfg *config.Config) *cli.Command {
+ return &cli.Command{
+ Name: "version",
+ Usage: "print the version of this binary and the running service instances",
+ Category: "info",
+ Action: func(c *cli.Context) error {
+ fmt.Println("Version: " + version.GetString())
+ fmt.Printf("Compiled: %s\n", version.Compiled())
+ fmt.Println("")
+
+ return nil
+ },
+ }
+}
diff --git a/services/groupware/pkg/config/config.go b/services/groupware/pkg/config/config.go
new file mode 100644
index 0000000000..d6ba3f50d4
--- /dev/null
+++ b/services/groupware/pkg/config/config.go
@@ -0,0 +1,36 @@
+package config
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/shared"
+)
+
+// Config combines all available configuration parts.
+type Config struct {
+ Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
+
+ Service Service `yaml:"-"`
+
+ Tracing *Tracing `yaml:"tracing"`
+ Log *Log `yaml:"log"`
+ Debug Debug `yaml:"debug"`
+
+ HTTP HTTP `yaml:"http"`
+
+ Mail Mail `yaml:"mail"`
+
+ Context context.Context `yaml:"-"`
+}
+
+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"`
+ 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"`
+}
diff --git a/services/groupware/pkg/config/debug.go b/services/groupware/pkg/config/debug.go
new file mode 100644
index 0000000000..9215b21837
--- /dev/null
+++ b/services/groupware/pkg/config/debug.go
@@ -0,0 +1,9 @@
+package config
+
+// Debug defines the available debug configuration.
+type Debug struct {
+ Addr string `yaml:"addr" env:"GROUPWARE_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
+ Token string `yaml:"token" env:"GROUPWARE_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
+ Pprof bool `yaml:"pprof" env:"GROUPWARE_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
+ Zpages bool `yaml:"zpages" env:"GROUPWARE_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
+}
diff --git a/services/groupware/pkg/config/defaults/defaultconfig.go b/services/groupware/pkg/config/defaults/defaultconfig.go
new file mode 100644
index 0000000000..69b7aa7d41
--- /dev/null
+++ b/services/groupware/pkg/config/defaults/defaultconfig.go
@@ -0,0 +1,89 @@
+package defaults
+
+import (
+ "strings"
+
+ "github.com/opencloud-eu/opencloud/pkg/shared"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+)
+
+// FullDefaultConfig returns a fully initialized default configuration
+func FullDefaultConfig() *config.Config {
+ cfg := DefaultConfig()
+ EnsureDefaults(cfg)
+ Sanitize(cfg)
+ return cfg
+}
+
+// DefaultConfig returns a basic default configuration
+func DefaultConfig() *config.Config {
+ return &config.Config{
+ Debug: config.Debug{
+ Addr: "127.0.0.1:9202",
+ Token: "",
+ Pprof: false,
+ Zpages: false,
+ },
+ Mail: config.Mail{
+ Master: config.MasterAuth{
+ Username: "master",
+ Password: "secret",
+ },
+ JmapUrl: "https://stalwart.opencloud.test/jmap",
+ RevaGateway: shared.DefaultRevaConfig().Address,
+ CS3AllowInsecure: false,
+ },
+ HTTP: config.HTTP{
+ Addr: "127.0.0.1:9276",
+ Root: "/groupware",
+ Namespace: "eu.opencloud.web",
+ CORS: config.CORS{
+ AllowedOrigins: []string{"*"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
+ AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Cache-Control"},
+ AllowCredentials: true,
+ },
+ },
+ Service: config.Service{
+ Name: "groupware",
+ },
+ }
+}
+
+// EnsureDefaults adds default values to the configuration if they are not set yet
+func EnsureDefaults(cfg *config.Config) {
+ // provide with defaults for shared logging, since we need a valid destination address for "envdecode".
+ if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
+ cfg.Log = &config.Log{
+ Level: cfg.Commons.Log.Level,
+ Pretty: cfg.Commons.Log.Pretty,
+ Color: cfg.Commons.Log.Color,
+ File: cfg.Commons.Log.File,
+ }
+ } else if cfg.Log == nil {
+ cfg.Log = &config.Log{}
+ }
+ // provide with defaults for shared tracing, since we need a valid destination address for "envdecode".
+ if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil {
+ cfg.Tracing = &config.Tracing{
+ Enabled: cfg.Commons.Tracing.Enabled,
+ Type: cfg.Commons.Tracing.Type,
+ Endpoint: cfg.Commons.Tracing.Endpoint,
+ Collector: cfg.Commons.Tracing.Collector,
+ }
+ } else if cfg.Tracing == nil {
+ cfg.Tracing = &config.Tracing{}
+ }
+
+ if cfg.Commons != nil {
+ cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
+ }
+
+}
+
+// Sanitize sanitized the configuration
+func Sanitize(cfg *config.Config) {
+ if cfg.HTTP.Root != "/" {
+ cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
+ }
+}
diff --git a/services/groupware/pkg/config/http.go b/services/groupware/pkg/config/http.go
new file mode 100644
index 0000000000..2907f26601
--- /dev/null
+++ b/services/groupware/pkg/config/http.go
@@ -0,0 +1,20 @@
+package config
+
+import "github.com/opencloud-eu/opencloud/pkg/shared"
+
+// CORS defines the available cors configuration.
+type CORS struct {
+ AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;GROUPWARE_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
+ AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;GROUPWARE_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
+ AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;GROUPWARE_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
+ AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;GROUPWARE_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"1.0.0"`
+}
+
+// HTTP defines the available http configuration.
+type HTTP struct {
+ Addr string `yaml:"addr" env:"GROUPWARE_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
+ TLS shared.HTTPServiceTLS `yaml:"tls"`
+ Root string `yaml:"root" env:"GROUPWARE_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
+ Namespace string `yaml:"-"`
+ CORS CORS `yaml:"cors"`
+}
diff --git a/services/groupware/pkg/config/log.go b/services/groupware/pkg/config/log.go
new file mode 100644
index 0000000000..4e3cea917f
--- /dev/null
+++ b/services/groupware/pkg/config/log.go
@@ -0,0 +1,9 @@
+package config
+
+// Log defines the available log configuration.
+type Log struct {
+ Level string `mapstructure:"level" env:"OC_LOG_LEVEL;THUMBNAILS_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
+ Pretty bool `mapstructure:"pretty" env:"OC_LOG_PRETTY;THUMBNAILS_LOG_PRETTY" desc:"Activates pretty log output." introductionVersion:"1.0.0"`
+ Color bool `mapstructure:"color" env:"OC_LOG_COLOR;THUMBNAILS_LOG_COLOR" desc:"Activates colorized log output." introductionVersion:"1.0.0"`
+ File string `mapstructure:"file" env:"OC_LOG_FILE;THUMBNAILS_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set." introductionVersion:"1.0.0"`
+}
diff --git a/services/groupware/pkg/config/parser/parse.go b/services/groupware/pkg/config/parser/parse.go
new file mode 100644
index 0000000000..e02236f566
--- /dev/null
+++ b/services/groupware/pkg/config/parser/parse.go
@@ -0,0 +1,39 @@
+package parser
+
+import (
+ "errors"
+
+ occfg "github.com/opencloud-eu/opencloud/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config/defaults"
+
+ "github.com/opencloud-eu/opencloud/pkg/config/envdecode"
+)
+
+// ParseConfig loads configuration from known paths.
+func ParseConfig(cfg *config.Config) error {
+ err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
+ if err != nil {
+ return err
+ }
+
+ defaults.EnsureDefaults(cfg)
+
+ // load all env variables relevant to the config in the current context.
+ if err := envdecode.Decode(cfg); err != nil {
+ // no environment variable set for this config is an expected "error"
+ if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
+ return err
+ }
+ }
+
+ // sanitize config
+ defaults.Sanitize(cfg)
+
+ return Validate(cfg)
+}
+
+// Validate can validate the configuration
+func Validate(_ *config.Config) error {
+ return nil
+}
diff --git a/services/groupware/pkg/config/service.go b/services/groupware/pkg/config/service.go
new file mode 100644
index 0000000000..d1eac383f0
--- /dev/null
+++ b/services/groupware/pkg/config/service.go
@@ -0,0 +1,6 @@
+package config
+
+// Service defines the available service configuration.
+type Service struct {
+ Name string `yaml:"-"`
+}
diff --git a/services/groupware/pkg/config/tracing.go b/services/groupware/pkg/config/tracing.go
new file mode 100644
index 0000000000..375b4024db
--- /dev/null
+++ b/services/groupware/pkg/config/tracing.go
@@ -0,0 +1,21 @@
+package config
+
+import "github.com/opencloud-eu/opencloud/pkg/tracing"
+
+// Tracing defines the available tracing configuration.
+type Tracing struct {
+ Enabled bool `yaml:"enabled" env:"OC_TRACING_ENABLED;ANTIVIRUS_TRACING_ENABLED" desc:"Activates tracing." introductionVersion:"1.0.0"`
+ Type string `yaml:"type" env:"OC_TRACING_TYPE;ANTIVIRUS_TRACING_TYPE" desc:"The type of tracing. Defaults to '', which is the same as 'jaeger'. Allowed tracing types are 'jaeger' and '' as of now." introductionVersion:"1.0.0"`
+ Endpoint string `yaml:"endpoint" env:"OC_TRACING_ENDPOINT;ANTIVIRUS_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent." introductionVersion:"1.0.0"`
+ Collector string `yaml:"collector" env:"OC_TRACING_COLLECTOR;ANTIVIRUS_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset." introductionVersion:"1.0.0"`
+}
+
+// Convert Tracing to the tracing package's Config struct.
+func (t Tracing) Convert() tracing.Config {
+ return tracing.Config{
+ Enabled: t.Enabled,
+ Type: t.Type,
+ Endpoint: t.Endpoint,
+ Collector: t.Collector,
+ }
+}
diff --git a/services/groupware/pkg/jmap/email.go b/services/groupware/pkg/jmap/email.go
new file mode 100644
index 0000000000..8bed85f418
--- /dev/null
+++ b/services/groupware/pkg/jmap/email.go
@@ -0,0 +1,41 @@
+package jmap
+
+import "time"
+
+type Email struct {
+ From string
+ Subject string
+ 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/jmap.go b/services/groupware/pkg/jmap/jmap.go
new file mode 100644
index 0000000000..d44fc262ba
--- /dev/null
+++ b/services/groupware/pkg/jmap/jmap.go
@@ -0,0 +1,360 @@
+package jmap
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "log/slog"
+ "net/http"
+)
+
+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
+}
+
+func New(client *http.Client, ctx context.Context, username string, password string, url string, accountId string) JmapClient {
+ return JmapClient{
+ client: client,
+ ctx: ctx,
+ username: username,
+ password: password,
+ url: url,
+ accountId: accountId,
+ }
+}
+
+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
+ })
+}
+
+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"}
+
+ */
+ 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 {
+ var data JmapCommandResponse
+ jsonErr := json.Unmarshal(*body, &data)
+ if jsonErr != nil {
+ panic(jsonErr)
+ }
+ 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}
+ })
+}
+
+type Emails struct {
+ Emails []Email
+ State string
+}
+
+func (jmap *JmapClient) EmailQuery(mailboxId string) Emails {
+ cmd := make([][]any, 4)
+ cmd[0] = []any{
+ "Email/query",
+ map[string]any{
+ "accountId": jmap.accountId,
+ "filter": map[string]any{
+ "inMailbox": mailboxId,
+ },
+ "sort": []map[string]any{
+ {
+ "isAscending": false,
+ "property": "receivedAt",
+ },
+ },
+ "collapseThreads": true,
+ "position": 0,
+ "limit": 30,
+ "calculateTotal": true,
+ },
+ "0",
+ }
+ cmd[1] = []any{
+ "Email/get",
+ map[string]any{
+ "accountId": jmap.accountId,
+ "#ids": map[string]any{
+ "resultOf": "0",
+ "name": "Email/query",
+ "path": "/ids",
+ },
+ "properties": []string{"threadId"},
+ },
+ "1",
+ }
+ cmd[2] = []any{
+ "Thread/get",
+ map[string]any{
+ "accountId": jmap.accountId,
+ "#ids": map[string]any{
+ "resultOf": "1",
+ "name": "Email/get",
+ "path": "/list/*/threadId",
+ },
+ },
+ "2",
+ }
+ cmd[3] = []any{
+ "Email/get",
+ map[string]any{
+ "accountId": jmap.accountId,
+ "#ids": map[string]any{
+ "resultOf": "2",
+ "name": "Thread/get",
+ "path": "/list/*/emailIds",
+ },
+ "properties": []string{
+ "threadId",
+ "mailboxIds",
+ "keywords",
+ "hasAttachment",
+ "from",
+ "subject",
+ "receivedAt",
+ "size",
+ "preview",
+ },
+ },
+ "3",
+ }
+
+ commandCtx := context.WithValue(jmap.ctx, "operation", "GetMailboxes")
+ return command(jmap.client, commandCtx, jmap.url, jmap.username, jmap.password, &cmd, func(body *[]byte) Emails {
+ var data JmapCommandResponse
+ jsonErr := json.Unmarshal(*body, &data)
+ if jsonErr != nil {
+ panic(jsonErr)
+ }
+ matches := make([][]any, 1)
+ for _, elem := range data.MethodResponses {
+ if elem[0] == "Email/get" && elem[2] == "3" {
+ matches = append(matches, elem)
+ }
+ }
+ /*
+ 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}
+ })
+}
diff --git a/services/groupware/pkg/logging/logging.go b/services/groupware/pkg/logging/logging.go
new file mode 100644
index 0000000000..c8756ebcc3
--- /dev/null
+++ b/services/groupware/pkg/logging/logging.go
@@ -0,0 +1,17 @@
+package logging
+
+import (
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+)
+
+// Configure initializes a service-specific logger instance.
+func Configure(name string, cfg *config.Log) log.Logger {
+ return log.NewLogger(
+ log.Name(name),
+ log.Level(cfg.Level),
+ log.Pretty(cfg.Pretty),
+ log.Color(cfg.Color),
+ log.File(cfg.File),
+ )
+}
diff --git a/services/groupware/pkg/metrics/metrics.go b/services/groupware/pkg/metrics/metrics.go
new file mode 100644
index 0000000000..7d9fa58ec9
--- /dev/null
+++ b/services/groupware/pkg/metrics/metrics.go
@@ -0,0 +1,34 @@
+package metrics
+
+import "github.com/prometheus/client_golang/prometheus"
+
+var (
+ // Namespace defines the namespace for the defines metrics.
+ Namespace = "opencloud"
+
+ // Subsystem defines the subsystem for the defines metrics.
+ Subsystem = "groupware"
+)
+
+// Metrics defines the available metrics of this service.
+type Metrics struct {
+ BuildInfo *prometheus.GaugeVec
+}
+
+// New initializes the available metrics.
+func New() *Metrics {
+ m := &Metrics{
+ BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Namespace: Namespace,
+ Subsystem: Subsystem,
+ Name: "build_info",
+ Help: "Build information",
+ }, []string{"version"}),
+ }
+
+ _ = prometheus.Register(
+ m.BuildInfo,
+ )
+
+ return m
+}
diff --git a/services/groupware/pkg/server/debug/option.go b/services/groupware/pkg/server/debug/option.go
new file mode 100644
index 0000000000..64ff971d09
--- /dev/null
+++ b/services/groupware/pkg/server/debug/option.go
@@ -0,0 +1,50 @@
+package debug
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+)
+
+// Option defines a single option function.
+type Option func(o *Options)
+
+// Options defines the available options for this package.
+type Options struct {
+ Logger log.Logger
+ Context context.Context
+ Config *config.Config
+}
+
+// newOptions initializes the available default options.
+func newOptions(opts ...Option) Options {
+ opt := Options{}
+
+ for _, o := range opts {
+ o(&opt)
+ }
+
+ return opt
+}
+
+// Logger provides a function to set the logger option.
+func Logger(val log.Logger) Option {
+ return func(o *Options) {
+ o.Logger = val
+ }
+}
+
+// Context provides a function to set the context option.
+func Context(val context.Context) Option {
+ return func(o *Options) {
+ o.Context = val
+ }
+}
+
+// Config provides a function to set the config option.
+func Config(val *config.Config) Option {
+ return func(o *Options) {
+ o.Config = val
+ }
+}
diff --git a/services/groupware/pkg/server/debug/server.go b/services/groupware/pkg/server/debug/server.go
new file mode 100644
index 0000000000..3f54c66012
--- /dev/null
+++ b/services/groupware/pkg/server/debug/server.go
@@ -0,0 +1,24 @@
+package debug
+
+import (
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/handlers"
+ "github.com/opencloud-eu/opencloud/pkg/service/debug"
+ "github.com/opencloud-eu/opencloud/pkg/version"
+)
+
+// Server initializes the debug service and server.
+func Server(opts ...Option) (*http.Server, error) {
+ options := newOptions(opts...)
+
+ readyHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
+ WithLogger(options.Logger)
+
+ return debug.NewService(
+ debug.Logger(options.Logger),
+ debug.Name(options.Config.Service.Name),
+ debug.Version(version.GetString()),
+ debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
+ ), nil
+}
diff --git a/services/groupware/pkg/server/http/option.go b/services/groupware/pkg/server/http/option.go
new file mode 100644
index 0000000000..eb54b7288c
--- /dev/null
+++ b/services/groupware/pkg/server/http/option.go
@@ -0,0 +1,83 @@
+package http
+
+import (
+ "context"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics"
+ "github.com/urfave/cli/v2"
+ "go.opentelemetry.io/otel/trace"
+ "go.opentelemetry.io/otel/trace/noop"
+)
+
+// Option defines a single option function.
+type Option func(o *Options)
+
+// Options defines the available options for this package.
+type Options struct {
+ Namespace string
+ Logger log.Logger
+ Context context.Context
+ Config *config.Config
+ Metrics *metrics.Metrics
+ Flags []cli.Flag
+ TraceProvider trace.TracerProvider
+}
+
+// newOptions initializes the available default options.
+func newOptions(opts ...Option) Options {
+ opt := Options{}
+
+ for _, o := range opts {
+ o(&opt)
+ }
+
+ return opt
+}
+
+// Logger provides a function to set the logger option.
+func Logger(val log.Logger) Option {
+ return func(o *Options) {
+ o.Logger = val
+ }
+}
+
+// Context provides a function to set the context option.
+func Context(val context.Context) Option {
+ return func(o *Options) {
+ o.Context = val
+ }
+}
+
+// Config provides a function to set the config option.
+func Config(val *config.Config) Option {
+ return func(o *Options) {
+ o.Config = val
+ }
+}
+
+// Metrics provides a function to set the metrics option.
+func Metrics(val *metrics.Metrics) Option {
+ return func(o *Options) {
+ o.Metrics = val
+ }
+}
+
+// Namespace provides a function to set the Namespace option.
+func Namespace(val string) Option {
+ return func(o *Options) {
+ o.Namespace = val
+ }
+}
+
+// TraceProvider provides a function to configure the trace provider
+func TraceProvider(traceProvider trace.TracerProvider) Option {
+ return func(o *Options) {
+ if traceProvider != nil {
+ o.TraceProvider = traceProvider
+ } else {
+ o.TraceProvider = noop.NewTracerProvider()
+ }
+ }
+}
diff --git a/services/groupware/pkg/server/http/server.go b/services/groupware/pkg/server/http/server.go
new file mode 100644
index 0000000000..67c9073917
--- /dev/null
+++ b/services/groupware/pkg/server/http/server.go
@@ -0,0 +1,67 @@
+package http
+
+import (
+ "fmt"
+
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/opencloud-eu/opencloud/pkg/cors"
+ opencloudmiddleware "github.com/opencloud-eu/opencloud/pkg/middleware"
+ "github.com/opencloud-eu/opencloud/pkg/service/http"
+ "github.com/opencloud-eu/opencloud/pkg/version"
+ svc "github.com/opencloud-eu/opencloud/services/groupware/pkg/service/http/v0"
+ "go-micro.dev/v4"
+)
+
+// Server initializes the http service and server.
+func Server(opts ...Option) (http.Service, error) {
+ options := newOptions(opts...)
+
+ service, err := http.NewService(
+ http.TLSConfig(options.Config.HTTP.TLS),
+ http.Logger(options.Logger),
+ http.Name(options.Config.Service.Name),
+ http.Version(version.GetString()),
+ http.Namespace(options.Config.HTTP.Namespace),
+ http.Address(options.Config.HTTP.Addr),
+ http.Context(options.Context),
+ http.TraceProvider(options.TraceProvider),
+ )
+ if err != nil {
+ options.Logger.Error().
+ Err(err).
+ Msg("Error initializing http service")
+ return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
+ }
+
+ handle := svc.NewService(
+ svc.Logger(options.Logger),
+ svc.Config(options.Config),
+ svc.Middleware(
+ middleware.RealIP,
+ middleware.RequestID,
+ opencloudmiddleware.Cors(
+ cors.Logger(options.Logger),
+ cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
+ cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
+ cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
+ cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
+ ),
+ opencloudmiddleware.Version(
+ options.Config.Service.Name,
+ version.GetString(),
+ ),
+ opencloudmiddleware.Logger(options.Logger),
+ ),
+ )
+
+ {
+ handle = svc.NewInstrument(handle, options.Metrics)
+ handle = svc.NewLogging(handle, options.Logger)
+ }
+
+ if err := micro.RegisterHandler(service.Server(), handle); err != nil {
+ return http.Service{}, err
+ }
+
+ return service, nil
+}
diff --git a/services/groupware/pkg/service/http/v0/instrument.go b/services/groupware/pkg/service/http/v0/instrument.go
new file mode 100644
index 0000000000..d3b17817fe
--- /dev/null
+++ b/services/groupware/pkg/service/http/v0/instrument.go
@@ -0,0 +1,25 @@
+package svc
+
+import (
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics"
+)
+
+// NewInstrument returns a service that instruments metrics.
+func NewInstrument(next Service, metrics *metrics.Metrics) Service {
+ return instrument{
+ next: next,
+ metrics: metrics,
+ }
+}
+
+type instrument struct {
+ next Service
+ metrics *metrics.Metrics
+}
+
+// ServeHTTP implements the Service interface.
+func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ i.next.ServeHTTP(w, r)
+}
diff --git a/services/groupware/pkg/service/http/v0/logging.go b/services/groupware/pkg/service/http/v0/logging.go
new file mode 100644
index 0000000000..c21734ce11
--- /dev/null
+++ b/services/groupware/pkg/service/http/v0/logging.go
@@ -0,0 +1,25 @@
+package svc
+
+import (
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+)
+
+// NewLogging returns a service that logs messages.
+func NewLogging(next Service, logger log.Logger) Service {
+ return logging{
+ next: next,
+ logger: logger,
+ }
+}
+
+type logging struct {
+ next Service
+ logger log.Logger
+}
+
+// ServeHTTP implements the Service interface.
+func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ l.next.ServeHTTP(w, r)
+}
diff --git a/services/groupware/pkg/service/http/v0/option.go b/services/groupware/pkg/service/http/v0/option.go
new file mode 100644
index 0000000000..d39a415d14
--- /dev/null
+++ b/services/groupware/pkg/service/http/v0/option.go
@@ -0,0 +1,52 @@
+package svc
+
+import (
+ "net/http"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// Option defines a single option function.
+type Option func(o *Options)
+
+// Options defines the available options for this package.
+type Options struct {
+ Logger log.Logger
+ Config *config.Config
+ Middleware []func(http.Handler) http.Handler
+ TraceProvider trace.TracerProvider
+}
+
+// newOptions initializes the available default options.
+func newOptions(opts ...Option) Options {
+ opt := Options{}
+
+ for _, o := range opts {
+ o(&opt)
+ }
+
+ return opt
+}
+
+// Logger provides a function to set the logger option.
+func Logger(val log.Logger) Option {
+ return func(o *Options) {
+ o.Logger = val
+ }
+}
+
+// Config provides a function to set the config option.
+func Config(val *config.Config) Option {
+ return func(o *Options) {
+ o.Config = val
+ }
+}
+
+// Middleware provides a function to set the middleware option.
+func Middleware(val ...func(http.Handler) http.Handler) Option {
+ return func(o *Options) {
+ o.Middleware = val
+ }
+}
diff --git a/services/groupware/pkg/service/http/v0/service.go b/services/groupware/pkg/service/http/v0/service.go
new file mode 100644
index 0000000000..fcdbc26975
--- /dev/null
+++ b/services/groupware/pkg/service/http/v0/service.go
@@ -0,0 +1,95 @@
+package svc
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/render"
+ "github.com/riandyrn/otelchi"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/tracing"
+ "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
+ "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)
+}
+
+// NewService returns a service implementation for Service.
+func NewService(opts ...Option) Service {
+ options := newOptions(opts...)
+
+ m := chi.NewMux()
+ m.Use(options.Middleware...)
+
+ m.Use(
+ otelchi.Middleware(
+ "groupware",
+ otelchi.WithChiRoutes(m),
+ otelchi.WithTracerProvider(options.TraceProvider),
+ otelchi.WithPropagators(tracing.GetPropagator()),
+ ),
+ )
+
+ svc := Groupware{
+ config: options.Config,
+ mux: m,
+ logger: options.Logger,
+ }
+
+ m.Route(options.Config.HTTP.Root, func(r chi.Router) {
+ r.Get("/", svc.WellDefined)
+ r.Get("/ping", svc.Ping)
+ })
+
+ _ = chi.Walk(m, func(method string, route string, _ http.Handler, middlewares ...func(http.Handler) http.Handler) error {
+ options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
+ return nil
+ })
+
+ return svc
+}
+
+// Thumbnails implements the business logic for Service.
+type Groupware struct {
+ config *config.Config
+ logger log.Logger
+ mux *chi.Mux
+ httpClient *http.Client
+}
+
+// ServeHTTP implements the Service interface.
+func (s Groupware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.mux.ServeHTTP(w, r)
+}
+
+type IndexResponse struct {
+ AccountId string
+}
+
+func (IndexResponse) Render(w http.ResponseWriter, r *http.Request) error {
+ return nil
+}
+
+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())
+
+ 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]})
+}