WIP: initial implementation of the groupware service

This commit is contained in:
Pascal Bleser
2025-04-16 15:46:32 +02:00
parent 0435d5679d
commit d638fba8c2
31 changed files with 1317 additions and 0 deletions

View File

@@ -39,6 +39,7 @@ OC_MODULES = \
services/gateway \
services/graph \
services/groups \
services/groupware \
services/idm \
services/idp \
services/invitations \

View File

@@ -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

View File

@@ -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"`

View File

@@ -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

View File

@@ -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"`

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

@@ -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()
},
}
}

View File

@@ -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
},
}
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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, "/")
}
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

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

View File

@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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