diff --git a/changelog/unreleased/enhancement-mail-notifications-grouping.md b/changelog/unreleased/enhancement-mail-notifications-grouping.md
new file mode 100644
index 00000000000..85de9f3e234
--- /dev/null
+++ b/changelog/unreleased/enhancement-mail-notifications-grouping.md
@@ -0,0 +1,6 @@
+Enhancement: Part IV: Grouping of mail notifications
+
+Part IV: Mail notifications can now be grouped on a daily or weekly basis
+
+https://github.com/owncloud/ocis/pull/10838
+https://github.com/owncloud/ocis/issues/10793
diff --git a/services/notifications/README.md b/services/notifications/README.md
index 4e70e81ad64..af93508e05d 100644
--- a/services/notifications/README.md
+++ b/services/notifications/README.md
@@ -46,6 +46,35 @@ Custom email templates referenced via `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` must a
The `templates/html` subfolder contains a default HTML template provided by ocis. When using a custom HTML template, hosted images can either be linked with standard HTML code like ```
``` or embedded as a CID source ```
```. In the latter case, image files must be located in the `templates/html/img` subfolder. Supported embedded image types are png, jpeg, and gif.
Consider that embedding images via a CID resource may not be fully supported in all email web clients.
+## Sending Grouped Emails
+
+The `notification` service can initiate sending emails based on events stored in the configured store that are grouped into a `daily` or `weekly` bucket. These groups contain events that get populated e.g. when the user configures `daily` or `weekly` email notifications in his personal settings in the web UI. If a user does not define any of the named groups for notification events, no event is stored.
+
+Grouped events are stored for the TTL defined in `OCIS_PERSISTENT_STORE_TTL`. This TTL can either be configured globally or individually for the notification service via the `NOTIFICATIONS_STORE_TTL` envvar.
+
+Grouped events that have passed the TTL are removed automatically without further notice or sending!
+
+To initiate sending grouped emails like via a cron job, use the `ocis notifications send-email` command. Note that the command mandatory requires at least one option which is `--daily` or `--weekly`. Note that both options can be used together.
+
+### Storing
+
+The `notifications` service persists information via the configured store in `NOTIFICATIONS_STORE`. Possible stores are:
+- `memory`: Basic in-memory store. Will not survive a restart. This is not recommended for this service.
+- `redis-sentinel`: Stores data in a configured Redis Sentinel cluster.
+- `nats-js-kv`: Stores data using key-value-store feature of [nats jetstream](https://docs.nats.io/nats-concepts/jetstream/key-value-store). This is the default value.
+- `noop`: Stores nothing. Useful for testing. Not recommended in production environments.
+
+Other store types may work but are not supported currently.
+
+Note: The service can only be scaled if not using `memory` store and the stores are configured identically over all instances!
+
+Note that if you have used one of the deprecated stores, you should reconfigure to one of the supported ones as the deprecated stores will be removed in a later version.
+
+Store specific notes:
+- When using `redis-sentinel`, the Redis master to use is configured via e.g. `OCIS_CACHE_STORE_NODES` in the form of `:/` like `10.10.0.200:26379/mymaster`.
+- When using `nats-js-kv` it is recommended to set `OCIS_CACHE_STORE_NODES` to the same value as `OCIS_EVENTS_ENDPOINT`. That way the cache uses the same nats instance as the event bus.
+- When using the `nats-js-kv` store, it is possible to set `OCIS_CACHE_DISABLE_PERSISTENCE` to instruct nats to not persist cache data on disc.
+
## Translations
The `notifications` service has embedded translations sourced via transifex to provide a basic set of translated languages. These embedded translations are available for all deployment scenarios.
diff --git a/services/notifications/pkg/command/root.go b/services/notifications/pkg/command/root.go
index 2833e622edb..d61b299dc1c 100644
--- a/services/notifications/pkg/command/root.go
+++ b/services/notifications/pkg/command/root.go
@@ -15,6 +15,7 @@ func GetCommands(cfg *config.Config) cli.Commands {
Server(cfg),
// interaction with this service
+ SendEmail(cfg),
// infos about this service
Health(cfg),
diff --git a/services/notifications/pkg/command/send_email.go b/services/notifications/pkg/command/send_email.go
new file mode 100644
index 00000000000..8da57ce8445
--- /dev/null
+++ b/services/notifications/pkg/command/send_email.go
@@ -0,0 +1,57 @@
+package command
+
+import (
+ "github.com/cs3org/reva/v2/pkg/events"
+ "github.com/cs3org/reva/v2/pkg/events/stream"
+ "github.com/owncloud/ocis/v2/services/notifications/pkg/config"
+ "github.com/pkg/errors"
+ "github.com/urfave/cli/v2"
+)
+
+// SendEmail triggers the sending of grouped email notifications for daily or weekly emails.
+func SendEmail(cfg *config.Config) *cli.Command {
+ return &cli.Command{
+ Name: "send-email",
+ Usage: "Send grouped email notifications with daily or weekly interval. Specify at least one of the flags '--daily' or '--weekly'.",
+ Flags: []cli.Flag{
+ &cli.BoolFlag{
+ Name: "daily",
+ Aliases: []string{"d"},
+ Usage: "Sends grouped daily email notifications.",
+ },
+ &cli.BoolFlag{
+ Name: "weekly",
+ Aliases: []string{"w"},
+ Usage: "Sends grouped weekly email notifications.",
+ },
+ },
+ Action: func(c *cli.Context) error {
+ daily := c.Bool("daily")
+ weekly := c.Bool("weekly")
+ if !daily && !weekly {
+ return errors.New("at least one of '--daily' or '--weekly' must be set")
+ }
+ s, err := stream.NatsFromConfig(cfg.Service.Name, false, stream.NatsConfig(cfg.Notifications.Events))
+ if err != nil {
+ return err
+ }
+ if daily {
+ err = events.Publish(c.Context, s, events.SendEmailsEvent{
+ Interval: "daily",
+ })
+ if err != nil {
+ return err
+ }
+ }
+ if weekly {
+ err = events.Publish(c.Context, s, events.SendEmailsEvent{
+ Interval: "weekly",
+ })
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ }
+}
diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go
index 8aa1e1bec75..309bc603f0d 100644
--- a/services/notifications/pkg/command/server.go
+++ b/services/notifications/pkg/command/server.go
@@ -3,6 +3,10 @@ package command
import (
"context"
"fmt"
+ "github.com/cs3org/reva/v2/pkg/store"
+ ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
+ microstore "go-micro.dev/v4/store"
+ "reflect"
"github.com/oklog/run"
"github.com/urfave/cli/v2"
@@ -81,7 +85,14 @@ func Server(cfg *config.Config) *cli.Command {
events.SpaceUnshared{},
events.SpaceMembershipExpired{},
events.ScienceMeshInviteTokenGenerated{},
+ events.SendEmailsEvent{},
}
+ registeredEvents := make(map[string]events.Unmarshaller)
+ for _, e := range evs {
+ typ := reflect.TypeOf(e)
+ registeredEvents[typ.String()] = e
+ }
+
client, err := stream.NatsFromConfig(cfg.Service.Name, false, stream.NatsConfig(cfg.Notifications.Events))
if err != nil {
return err
@@ -109,7 +120,21 @@ func Server(cfg *config.Config) *cli.Command {
logger.Fatal().Err(err).Str("addr", cfg.Notifications.RevaGateway).Msg("could not get reva gateway selector")
}
valueService := settingssvc.NewValueService("com.owncloud.api.settings", grpcClient)
- svc := service.NewEventsNotifier(evts, channel, logger, gatewaySelector, valueService, cfg.ServiceAccount.ServiceAccountID, cfg.ServiceAccount.ServiceAccountSecret, cfg.Notifications.EmailTemplatePath, cfg.Notifications.DefaultLanguage, cfg.WebUIURL, cfg.Notifications.TranslationPath)
+ historyClient := ehsvc.NewEventHistoryService("com.owncloud.api.eventhistory", grpcClient)
+
+ notificationStore := store.Create(
+ store.Store(cfg.Store.Store),
+ store.TTL(cfg.Store.TTL),
+ microstore.Nodes(cfg.Store.Nodes...),
+ microstore.Database(cfg.Store.Database),
+ microstore.Table(cfg.Store.Table),
+ store.Authentication(cfg.Store.AuthUsername, cfg.Store.AuthPassword),
+ )
+
+ svc := service.NewEventsNotifier(evts, channel, logger, gatewaySelector, valueService,
+ cfg.ServiceAccount.ServiceAccountID, cfg.ServiceAccount.ServiceAccountSecret,
+ cfg.Notifications.EmailTemplatePath, cfg.Notifications.DefaultLanguage, cfg.WebUIURL,
+ cfg.Notifications.TranslationPath, cfg.Notifications.SMTP.Sender, notificationStore, historyClient, registeredEvents)
gr.Add(svc.Run, func(error) {
cancel()
diff --git a/services/notifications/pkg/config/config.go b/services/notifications/pkg/config/config.go
index 280a6954701..08f41f260ee 100644
--- a/services/notifications/pkg/config/config.go
+++ b/services/notifications/pkg/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"context"
+ "time"
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
)
@@ -24,6 +25,8 @@ type Config struct {
ServiceAccount ServiceAccount `yaml:"service_account"`
Context context.Context `yaml:"-"`
+
+ Store Store `yaml:"store"`
}
// Notifications defines the config options for the notifications service.
@@ -65,3 +68,14 @@ type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OCIS_SERVICE_ACCOUNT_ID;NOTIFICATIONS_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"5.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OCIS_SERVICE_ACCOUNT_SECRET;NOTIFICATIONS_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"5.0"`
}
+
+// Store configures the store to use
+type Store struct {
+ Store string `yaml:"store" env:"OCIS_PERSISTENT_STORE;NOTIFICATIONS_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"7.1"`
+ Nodes []string `yaml:"nodes" env:"OCIS_PERSISTENT_STORE_NODES;NOTIFICATIONS_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"7.1"`
+ Database string `yaml:"database" env:"NOTIFICATIONS_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"7.1"`
+ Table string `yaml:"table" env:"NOTIFICATIONS_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"7.1"`
+ TTL time.Duration `yaml:"ttl" env:"OCIS_PERSISTENT_STORE_TTL;NOTIFICATIONS_STORE_TTL" desc:"Time to live for notifications in the store. Defaults to '336h' (2 weeks). See the Environment Variable Types description for more details." introductionVersion:"7.1"`
+ AuthUsername string `yaml:"username" env:"OCIS_PERSISTENT_STORE_AUTH_USERNAME;NOTIFICATIONS_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"7.1"`
+ AuthPassword string `yaml:"password" env:"OCIS_PERSISTENT_STORE_AUTH_PASSWORD;NOTIFICATIONS_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"7.1"`
+}
diff --git a/services/notifications/pkg/config/defaults/defaultconfig.go b/services/notifications/pkg/config/defaults/defaultconfig.go
index 3a5ff261922..69b94c91d78 100644
--- a/services/notifications/pkg/config/defaults/defaultconfig.go
+++ b/services/notifications/pkg/config/defaults/defaultconfig.go
@@ -4,6 +4,7 @@ import (
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
"github.com/owncloud/ocis/v2/ocis-pkg/structs"
"github.com/owncloud/ocis/v2/services/notifications/pkg/config"
+ "time"
)
// FullDefaultConfig returns a fully initialized default configuration
@@ -40,6 +41,13 @@ func DefaultConfig() *config.Config {
},
RevaGateway: shared.DefaultRevaConfig().Address,
},
+ Store: config.Store{
+ Store: "nats-js-kv",
+ Nodes: []string{"127.0.0.1:9233"},
+ Database: "notifications",
+ Table: "",
+ TTL: 336 * time.Hour,
+ },
}
}
diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go
index 097ff3631e7..73ff67fe90b 100644
--- a/services/notifications/pkg/email/composer.go
+++ b/services/notifications/pkg/email/composer.go
@@ -3,6 +3,7 @@ package email
import (
"bytes"
"embed"
+ "github.com/pkg/errors"
"strings"
"text/template"
@@ -61,6 +62,65 @@ func NewHTMLTemplate(mt MessageTemplate, locale, defaultLocale string, translati
return mt, nil
}
+// NewGroupedTextTemplate replace the body message template placeholders with the translated template
+func NewGroupedTextTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (GroupedMessageTemplate, error) {
+ if len(mts) != len(mtsVars) {
+ return gmt, errors.New("number of templates does not match number of variables")
+ }
+
+ var err error
+ t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
+ gmt.Subject, err = composeMessage(t.Get(gmt.Subject), vars)
+ if err != nil {
+ return gmt, err
+ }
+ gmt.Greeting, err = composeMessage(t.Get(gmt.Greeting), vars)
+ if err != nil {
+ return gmt, err
+ }
+
+ bodyParts := make([]string, 0, len(mtsVars))
+ for i, mt := range mts {
+ bodyPart, err := composeMessage(t.Get(mt.MessageBody), mtsVars[i])
+ if err != nil {
+ return gmt, err
+ }
+ bodyParts = append(bodyParts, bodyPart)
+ }
+ gmt.MessageBody = strings.Join(bodyParts, "\n\n\n")
+ return gmt, nil
+}
+
+// NewGroupedHTMLTemplate replace the body message template placeholders with the translated template
+func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (GroupedMessageTemplate, error) {
+ if len(mts) != len(mtsVars) {
+ return gmt, errors.New("number of templates does not match number of variables")
+ }
+
+ var err error
+ t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
+ gmt.Subject, err = composeMessage(t.Get(gmt.Subject), vars)
+ if err != nil {
+ return gmt, err
+ }
+ gmt.Greeting, err = composeMessage(newlineToBr(t.Get(gmt.Greeting)), vars)
+ if err != nil {
+ return gmt, err
+ }
+
+ bodyParts := make([]string, 0, len(mtsVars))
+ for i, mt := range mts {
+ bodyPart, err := composeMessage(t.Get(mt.MessageBody), mtsVars[i])
+ if err != nil {
+ return gmt, err
+ }
+ bodyParts = append(bodyParts, bodyPart)
+ }
+ gmt.MessageBody = strings.Join(bodyParts, "
")
+
+ return gmt, nil
+}
+
// composeMessage renders the message based on template
func composeMessage(tmpl string, vars map[string]string) (string, error) {
tpl, err := template.New("").Parse(replacePlaceholders(tmpl))
diff --git a/services/notifications/pkg/email/email.go b/services/notifications/pkg/email/email.go
index e0d488e1fb2..968fa78de50 100644
--- a/services/notifications/pkg/email/email.go
+++ b/services/notifications/pkg/email/email.go
@@ -67,6 +67,53 @@ func RenderEmailTemplate(mt MessageTemplate, locale, defaultLocale string, email
}, nil
}
+// RenderGroupedEmailTemplate is responsible to prepare a message which than can be used to notify the user via email.
+func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, emailTemplatePath string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (*channels.Message, error) {
+ textMt, err := NewGroupedTextTemplate(gmt, vars, locale, defaultLocale, translationPath, mts, mtsVars)
+ if err != nil {
+ return nil, err
+ }
+ tpl, err := parseTemplate(emailTemplatePath, gmt.textTemplate)
+ if err != nil {
+ return nil, err
+ }
+ textBody, err := groupedEmailTemplate(tpl, textMt)
+ if err != nil {
+ return nil, err
+ }
+
+ escapedMtsVars := make([]map[string]string, 0, len(mtsVars))
+ for _, m := range mtsVars {
+ escapedMtsVars = append(escapedMtsVars, escapeStringMap(m))
+ }
+ htmlMt, err := NewGroupedHTMLTemplate(gmt, escapeStringMap(vars), locale, defaultLocale, translationPath, mts, escapedMtsVars)
+ if err != nil {
+ return nil, err
+ }
+ htmlTpl, err := parseTemplate(emailTemplatePath, gmt.htmlTemplate)
+ if err != nil {
+ return nil, err
+ }
+ htmlBody, err := groupedEmailTemplate(htmlTpl, htmlMt)
+ if err != nil {
+ return nil, err
+ }
+ var data map[string][]byte
+ if emailTemplatePath != "" {
+ data, err = readImages(emailTemplatePath)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return &channels.Message{
+ Subject: textMt.Subject,
+ TextBody: textBody,
+ HTMLBody: htmlBody,
+ AttachInline: data,
+ }, nil
+}
+
// emailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]interface{}{
@@ -80,6 +127,18 @@ func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) {
return str, err
}
+// groupedEmailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
+func groupedEmailTemplate(tpl *template.Template, gmt GroupedMessageTemplate) (string, error) {
+ str, err := executeTemplate(tpl, map[string]interface{}{
+ "Greeting": template.HTML(strings.TrimSpace(gmt.Greeting)), // #nosec G203
+ "MessageBody": template.HTML(strings.TrimSpace(gmt.MessageBody)), // #nosec G203
+ })
+ if err != nil {
+ return "", err
+ }
+ return str, err
+}
+
func parseTemplate(emailTemplatePath string, file string) (*template.Template, error) {
if emailTemplatePath != "" {
return template.ParseFiles(filepath.Join(emailTemplatePath, file))
diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go
index a7aa0703062..c0d940f3a1d 100644
--- a/services/notifications/pkg/email/templates.go
+++ b/services/notifications/pkg/email/templates.go
@@ -2,12 +2,17 @@ package email
import "github.com/owncloud/ocis/v2/ocis-pkg/l10n"
+const (
+ _textTemplate = "templates/text/email.text.tmpl"
+ _htmlTemplate = "templates/html/email.html.tmpl"
+)
+
// the available templates
var (
// Shares
ShareCreated = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// ShareCreated email template, Subject field (resolves directly)
Subject: l10n.Template(`{ShareSharer} shared '{ShareFolder}' with you`),
// ShareCreated email template, resolves via {{ .Greeting }}
@@ -19,8 +24,8 @@ var (
}
ShareExpired = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// ShareExpired email template, Subject field (resolves directly)
Subject: l10n.Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`),
// ShareExpired email template, resolves via {{ .Greeting }}
@@ -33,8 +38,8 @@ Even though this share has been revoked you still might have access through othe
// Spaces templates
SharedSpace = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// SharedSpace email template, Subject field (resolves directly)
Subject: l10n.Template("{SpaceSharer} invited you to join {SpaceName}"),
// SharedSpace email template, resolves via {{ .Greeting }}
@@ -46,8 +51,8 @@ Even though this share has been revoked you still might have access through othe
}
UnsharedSpace = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// UnsharedSpace email template, Subject field (resolves directly)
Subject: l10n.Template(`{SpaceSharer} removed you from {SpaceName}`),
// UnsharedSpace email template, resolves via {{ .Greeting }}
@@ -61,8 +66,8 @@ You might still have access through your other groups or direct membership.`),
}
MembershipExpired = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// MembershipExpired email template, Subject field (resolves directly)
Subject: l10n.Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`),
// MembershipExpired email template, resolves via {{ .Greeting }}
@@ -74,8 +79,8 @@ Even though this membership has expired you still might have access through othe
}
ScienceMeshInviteTokenGenerated = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// ScienceMeshInviteTokenGenerated email template, Subject field (resolves directly)
Subject: l10n.Template(`ScienceMesh: {InitiatorName} wants to collaborate with you`),
// ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting }}
@@ -91,8 +96,8 @@ Alternatively, you can visit your federation settings and use the following deta
}
ScienceMeshInviteTokenGeneratedWithoutShareLink = MessageTemplate{
- textTemplate: "templates/text/email.text.tmpl",
- htmlTemplate: "templates/html/email.html.tmpl",
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject field (resolves directly)
Subject: l10n.Template(`ScienceMesh: {InitiatorName} wants to collaborate with you`),
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves via {{ .Greeting }}
@@ -103,6 +108,16 @@ Please visit your federation settings and use the following details:
Token: {Token}
ProviderDomain: {ProviderDomain}`),
}
+
+ Grouped = GroupedMessageTemplate{
+ textTemplate: _textTemplate,
+ htmlTemplate: _htmlTemplate,
+ // Grouped email template, Subject field (resolves directly)
+ Subject: l10n.Template(`Report`), // TODO find meaningful subject
+ // Grouped email template, resolves via {{ .Greeting }}
+ Greeting: l10n.Template(`Hi {DisplayName},`),
+ MessageBody: "", // is generated using the GroupedTemplates
+ }
)
// holds the information to turn the raw template into a parseable go template
@@ -118,6 +133,7 @@ var _placeholders = map[string]string{
"{ShareSharerMail}": "{{ .ShareSharerMail }}",
"{ProviderDomain}": "{{ .ProviderDomain }}",
"{Token}": "{{ .Token }}",
+ "{DisplayName}": "{{ .DisplayName }}",
}
// MessageTemplate is the data structure for the email
@@ -132,3 +148,15 @@ type MessageTemplate struct {
MessageBody string
CallToAction string
}
+
+// GroupedMessageTemplate is the data structure for the email
+type GroupedMessageTemplate struct {
+ // textTemplate represent the path to text plain .tmpl file
+ textTemplate string
+ // htmlTemplate represent the path to html .tmpl file
+ htmlTemplate string
+ // The fields below represent the placeholders for the translatable templates
+ Subject string
+ Greeting string
+ MessageBody string
+}
diff --git a/services/notifications/pkg/service/filter.go b/services/notifications/pkg/service/filter.go
index cde3fd19a87..ac0194ed7a4 100644
--- a/services/notifications/pkg/service/filter.go
+++ b/services/notifications/pkg/service/filter.go
@@ -28,6 +28,7 @@ func (nf notificationFilter) execute(ctx context.Context, users []*user.User, se
enabled, err := getSetting(ctx, nf.valueClient, userId, settingId)
if err != nil {
nf.log.Error().Err(err).Str("userId", userId).Str("settingId", settingId).Msg("cannot get user event setting")
+ filteredUsers = append(filteredUsers, u)
continue
}
if enabled {
diff --git a/services/notifications/pkg/service/filter_test.go b/services/notifications/pkg/service/filter_test.go
index c910ead6691..a0ba9c44366 100644
--- a/services/notifications/pkg/service/filter_test.go
+++ b/services/notifications/pkg/service/filter_test.go
@@ -30,17 +30,17 @@ func TestNotificationFilter_execute(t *testing.T) {
GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
return nil, errors.New("no connection to ValueService")
},
- }, args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User(nil)},
+ }, args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}},
{"no setting in ValueService response", settings.MockValueService{
GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
return &settings.GetValueResponse{}, nil
},
- }, args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User(nil)},
+ }, args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}},
{"ValueService nil response", settings.MockValueService{
GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
return nil, nil
},
- }, args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User(nil)},
+ }, args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}},
{"Event enabled", setupMockValueService(true), args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}},
{"Event disabled", setupMockValueService(false), args{users: []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, settingId: "bar", ctx: context.TODO()}, []*user.User(nil)},
}
diff --git a/services/notifications/pkg/service/job.go b/services/notifications/pkg/service/job.go
new file mode 100644
index 00000000000..56c60d9ebc0
--- /dev/null
+++ b/services/notifications/pkg/service/job.go
@@ -0,0 +1,161 @@
+package service
+
+import (
+ "context"
+ "github.com/cs3org/reva/v2/pkg/events"
+ "github.com/owncloud/ocis/v2/ocis-pkg/l10n"
+ ehmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/eventhistory/v0"
+ "github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
+ "github.com/owncloud/ocis/v2/services/notifications/pkg/email"
+ "github.com/rs/zerolog"
+)
+
+func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEvent, eventId string) {
+ logger := s.logger.With().
+ Str("event", "SendEmailsEvent").
+ Str("eventId", eventId).
+ Logger()
+
+ if sendEmailsEvent.Interval != _intervalDaily && sendEmailsEvent.Interval != _intervalWeekly {
+ logger.Error().Str("interval", sendEmailsEvent.Interval).Msg("unsupported email sending interval")
+ return
+ }
+
+ keys, err := s.userEventStore.listKeys(sendEmailsEvent.Interval)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not get list of keys")
+ return
+ }
+
+ ctx := context.Background()
+
+ jobs := make(chan string, 10)
+ go func() {
+ for _, key := range keys {
+ jobs <- key
+ }
+ close(jobs)
+ }()
+
+ for job := range jobs {
+ go s.createGroupedMail(ctx, logger, job)
+ }
+}
+
+func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Logger, key string) {
+ userEvents, err := s.userEventStore.pop(ctx, key)
+ if err != nil {
+ logger.Error().Err(err).Str("key", key).Msg("could not pop user events")
+ return
+ }
+
+ var mts []email.MessageTemplate
+ var mtsVars []map[string]string
+ locale := l10n.MustGetUserLocale(ctx, userEvents.User.GetId().GetOpaqueId(), "", s.valueService)
+
+ for _, e := range userEvents.Events {
+ switch te := s.unwrapEvent(logger, e).(type) {
+ case events.SpaceShared:
+ logger := logger.With().
+ Str("event", "SpaceShared").
+ Str("eventId", te.ID.OpaqueId).
+ Logger()
+
+ executant, spaceName, shareLink, _, err := s.prepareSpaceShared(logger, te)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not prepare vars for grouped email")
+ continue
+ }
+
+ mts = append(mts, email.SharedSpace)
+ mtsVars = append(mtsVars, map[string]string{
+ "SpaceSharer": executant.GetDisplayName(),
+ "SpaceName": spaceName,
+ "ShareLink": shareLink,
+ })
+ case events.SpaceUnshared:
+ logger := logger.With().
+ Str("event", "SpaceUnshared").
+ Str("eventId", te.ID.OpaqueId).
+ Logger()
+
+ executant, spaceName, shareLink, _, err := s.prepareSpaceUnshared(logger, te)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not prepare vars for grouped email")
+ continue
+ }
+ mts = append(mts, email.UnsharedSpace)
+ mtsVars = append(mtsVars, map[string]string{
+ "SpaceSharer": executant.GetDisplayName(),
+ "SpaceName": spaceName,
+ "ShareLink": shareLink,
+ })
+ case events.SpaceMembershipExpired:
+ mts = append(mts, email.MembershipExpired)
+ mtsVars = append(mtsVars, map[string]string{
+ "SpaceName": te.SpaceName,
+ "ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
+ })
+ case events.ShareCreated:
+ logger := logger.With().
+ Str("event", "ShareCreated").
+ Str("eventId", te.ItemID.OpaqueId).
+ Logger()
+
+ owner, shareFolder, shareLink, _, err := s.prepareShareCreated(logger, te)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not prepare vars for grouped email")
+ continue
+ }
+ mts = append(mts, email.ShareCreated)
+ mtsVars = append(mtsVars, map[string]string{
+ "ShareSharer": owner.GetDisplayName(),
+ "ShareFolder": shareFolder,
+ "ShareLink": shareLink,
+ })
+ case events.ShareExpired:
+ logger := logger.With().
+ Str("event", "ShareCreated").
+ Str("eventId", te.ItemID.OpaqueId).
+ Logger()
+
+ shareFolder, _, err := s.prepareShareExpired(logger, te)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not prepare vars for grouped email")
+ continue
+ }
+ mts = append(mts, email.ShareExpired)
+ mtsVars = append(mtsVars, map[string]string{
+ "ShareFolder": shareFolder,
+ "ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
+ })
+ }
+ }
+
+ rendered, err := email.RenderGroupedEmailTemplate(email.Grouped, map[string]string{
+ "DisplayName": userEvents.User.GetDisplayName(),
+ }, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, mts, mtsVars)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not render template")
+ return
+ }
+ rendered.Sender = s.defaultEmailSender
+ rendered.Recipient = []string{userEvents.User.GetMail()}
+ s.send(ctx, []*channels.Message{rendered})
+}
+
+func (s eventsNotifier) unwrapEvent(logger zerolog.Logger, e *ehmsg.Event) any {
+ etype, ok := s.registeredEvents[e.GetType()]
+ if !ok {
+ logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("event not registered")
+ return nil
+ }
+
+ ue, err := etype.Unmarshal(e.GetEvent())
+ if err != nil {
+ logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("failed to umarshal event")
+ return nil
+ }
+
+ return ue
+}
diff --git a/services/notifications/pkg/service/persistence.go b/services/notifications/pkg/service/persistence.go
new file mode 100644
index 00000000000..d362d355f98
--- /dev/null
+++ b/services/notifications/pkg/service/persistence.go
@@ -0,0 +1,116 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+ "github.com/owncloud/ocis/v2/ocis-pkg/log"
+ v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/eventhistory/v0"
+ ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
+ "github.com/pkg/errors"
+ "go-micro.dev/v4/store"
+)
+
+type userEventStore struct {
+ log log.Logger
+ store store.Store
+ historyClient ehsvc.EventHistoryService
+}
+
+type userEventIds struct {
+ User *user.User `json:"user"`
+ EventIds []string `json:"event_ids"`
+}
+
+type userEvents struct {
+ User *user.User
+ Events []*v0.Event
+}
+
+const (
+ _intervalDaily = "daily"
+ _intervalWeekly = "weekly"
+)
+
+func newUserEventStore(l log.Logger, s store.Store, hc ehsvc.EventHistoryService) *userEventStore {
+ return &userEventStore{log: l, store: s, historyClient: hc}
+}
+
+func (s *userEventStore) persist(interval string, eventId string, users []*user.User) []*user.User {
+ var errorUsers []*user.User
+ for _, u := range users {
+ key := interval + u.Id.OpaqueId
+
+ // Note: This is not thread safe and can result in missing events
+ records, err := s.store.Read(key)
+ if err != nil && err != store.ErrNotFound {
+ s.log.Error().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot read record")
+ errorUsers = append(errorUsers, u)
+ continue
+ }
+ var record userEventIds
+ if len(records) == 0 {
+ record = userEventIds{}
+ } else {
+ if err = json.Unmarshal(records[0].Value, &record); err != nil {
+ s.log.Warn().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot unmarshal json")
+ errorUsers = append(errorUsers, u)
+ continue
+ }
+ }
+ record.User = u
+ record.EventIds = append(record.EventIds, eventId)
+ b, err := json.Marshal(record)
+ if err != nil {
+ s.log.Warn().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot marshal record")
+ errorUsers = append(errorUsers, u)
+ continue
+ }
+ err = s.store.Write(&store.Record{
+ Key: key,
+ Value: b,
+ })
+ if err != nil {
+ s.log.Error().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot write record")
+ errorUsers = append(errorUsers, u)
+ continue
+ }
+
+ }
+ return errorUsers
+}
+
+func (s *userEventStore) listKeys(interval string) ([]string, error) {
+ return s.store.List(store.ListPrefix(interval))
+}
+
+func (s *userEventStore) pop(ctx context.Context, key string) (*userEvents, error) {
+ records, err := s.store.Read(key)
+ if err != nil && err != store.ErrNotFound {
+ return nil, errors.New("cannot get records")
+ }
+ if len(records) == 0 {
+ return nil, errors.New("no records found")
+ }
+ var record userEventIds
+ err = json.Unmarshal(records[0].Value, &record)
+ if err != nil {
+ s.log.Warn().Err(err).Str("key", key).Msg("cannot unmarshal json")
+ return nil, err
+ }
+
+ res, err := s.historyClient.GetEvents(ctx, &ehsvc.GetEventsRequest{Ids: record.EventIds})
+ if err != nil {
+ s.log.Error().Err(err).Strs("eventIds", record.EventIds).Msg("cannot get events")
+ return nil, err
+ }
+ err = s.store.Delete(key)
+ if err != nil {
+ s.log.Error().Err(err).Strs("eventIds", record.EventIds).Msg("cannot delete records")
+ return nil, err
+ }
+ return &userEvents{
+ User: record.User,
+ Events: res.GetEvents(),
+ }, nil
+}
diff --git a/services/notifications/pkg/service/sciencemesh.go b/services/notifications/pkg/service/sciencemesh.go
index 98d01f71a63..a1c260414a5 100644
--- a/services/notifications/pkg/service/sciencemesh.go
+++ b/services/notifications/pkg/service/sciencemesh.go
@@ -71,7 +71,7 @@ func (s eventsNotifier) handleScienceMeshInviteTokenGenerated(e events.ScienceMe
msgENV,
)
if err != nil {
- s.logger.Error().Err(err).Msg("building the message has failed")
+ logger.Error().Err(err).Msg("building the message has failed")
return
}
diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go
index 4016249e9a1..acf4545c523 100644
--- a/services/notifications/pkg/service/service.go
+++ b/services/notifications/pkg/service/service.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
+ ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
+ "go-micro.dev/v4/store"
"net/url"
"os"
"os/signal"
@@ -50,7 +52,10 @@ func NewEventsNotifier(
logger log.Logger,
gatewaySelector pool.Selectable[gateway.GatewayAPIClient],
valueService settingssvc.ValueService,
- serviceAccountID, serviceAccountSecret, emailTemplatePath, defaultLanguage, ocisURL, translationPath string) Service {
+ serviceAccountID, serviceAccountSecret, emailTemplatePath, defaultLanguage, ocisURL, translationPath, emailSender string,
+ store store.Store,
+ historyClient ehsvc.EventHistoryService,
+ registeredEvents map[string]events.Unmarshaller) Service {
return eventsNotifier{
logger: logger,
@@ -63,9 +68,13 @@ func NewEventsNotifier(
serviceAccountSecret: serviceAccountSecret,
emailTemplatePath: emailTemplatePath,
defaultLanguage: defaultLanguage,
+ defaultEmailSender: emailSender,
ocisURL: ocisURL,
translationPath: translationPath,
filter: newNotificationFilter(logger, valueService),
+ splitter: newIntervalSplitter(logger, valueService),
+ userEventStore: newUserEventStore(logger, store, historyClient),
+ registeredEvents: registeredEvents,
}
}
@@ -79,10 +88,14 @@ type eventsNotifier struct {
emailTemplatePath string
translationPath string
defaultLanguage string
+ defaultEmailSender string
ocisURL string
serviceAccountID string
serviceAccountSecret string
filter *notificationFilter
+ splitter *intervalSplitter
+ userEventStore *userEventStore
+ registeredEvents map[string]events.Unmarshaller
}
func (s eventsNotifier) Run() error {
@@ -95,17 +108,19 @@ func (s eventsNotifier) Run() error {
go func() {
switch e := evt.Event.(type) {
case events.SpaceShared:
- s.handleSpaceShared(e)
+ s.handleSpaceShared(e, evt.ID)
case events.SpaceUnshared:
- s.handleSpaceUnshared(e)
+ s.handleSpaceUnshared(e, evt.ID)
case events.SpaceMembershipExpired:
- s.handleSpaceMembershipExpired(e)
+ s.handleSpaceMembershipExpired(e, evt.ID)
case events.ShareCreated:
- s.handleShareCreated(e)
+ s.handleShareCreated(e, evt.ID)
case events.ShareExpired:
- s.handleShareExpired(e)
+ s.handleShareExpired(e, evt.ID)
case events.ScienceMeshInviteTokenGenerated:
s.handleScienceMeshInviteTokenGenerated(e)
+ case events.SendEmailsEvent:
+ s.sendGroupedEmailsJob(e, evt.ID)
}
}()
case <-s.signals:
@@ -135,8 +150,8 @@ func (s eventsNotifier) render(ctx context.Context, template email.MessageTempla
return messageList, nil
}
-func (s eventsNotifier) send(ctx context.Context, recipientList []*channels.Message) {
- for _, r := range recipientList {
+func (s eventsNotifier) send(ctx context.Context, emails []*channels.Message) {
+ for _, r := range emails {
err := s.channel.SendMessage(ctx, r)
if err != nil {
s.logger.Error().Err(err).Str("event", "SendEmail").Msg("failed to send a message")
diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go
index e2ab94e1328..9ba58acb4ab 100644
--- a/services/notifications/pkg/service/service_test.go
+++ b/services/notifications/pkg/service/service_test.go
@@ -2,6 +2,7 @@ package service_test
import (
"context"
+ "github.com/cs3org/reva/v2/pkg/store"
settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
"time"
@@ -88,12 +89,14 @@ var _ = Describe("Notifications", func() {
}
})
- DescribeTable("Sending notifications",
+ DescribeTable("Sending userEventIds",
func(tc testChannel, ev events.Event) {
cfg := defaults.FullDefaultConfig()
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
- evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "", "", "", "", "", "")
+ evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
+ "", "", "", "", "", "",
+ store.Create(), nil, nil)
go evts.Run()
ch <- ev
@@ -301,12 +304,14 @@ var _ = Describe("Notifications X-Site Scripting", func() {
}
})
- DescribeTable("Sending notifications",
+ DescribeTable("Sending userEventIds",
func(tc testChannel, ev events.Event) {
cfg := defaults.FullDefaultConfig()
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
- evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "", "", "", "", "", "")
+ evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
+ "", "", "", "", "", "",
+ store.Create(), nil, nil)
go evts.Run()
ch <- ev
diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go
index 1413b3e0496..1c3b53c0fb6 100644
--- a/services/notifications/pkg/service/shares.go
+++ b/services/notifications/pkg/service/shares.go
@@ -1,75 +1,95 @@
package service
import (
+ "context"
+ user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
+ "github.com/rs/zerolog"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
-func (s eventsNotifier) handleShareCreated(e events.ShareCreated) {
+func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string) {
logger := s.logger.With().
Str("event", "ShareCreated").
Str("itemid", e.ItemID.OpaqueId).
Logger()
- gatewayClient, err := s.gatewaySelector.Next()
+ owner, shareFolder, shareLink, ctx, err := s.prepareShareCreated(logger, e)
if err != nil {
- logger.Error().Err(err).Msg("could not select next gateway client")
+ logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
- ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
+ granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
+ filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareCreated)
+
+ recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
+ if recipientsInstant == nil {
+ return
+ }
+
+ sharerDisplayName := owner.GetDisplayName()
+ emails, err := s.render(ctx, email.ShareCreated,
+ "ShareGrantee",
+ map[string]string{
+ "ShareSharer": sharerDisplayName,
+ "ShareFolder": shareFolder,
+ "ShareLink": shareLink,
+ }, recipientsInstant, sharerDisplayName)
if err != nil {
- logger.Error().Err(err).Msg("Could not impersonate service user")
+ logger.Error().Err(err).Msg("could not get render the email")
return
}
+ s.send(ctx, emails)
+}
+
+func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.ShareCreated) (owner *user.User, shareFolder, shareLink string, ctx context.Context, err error) {
+ gatewayClient, err := s.gatewaySelector.Next()
+ if err != nil {
+ logger.Error().Err(err).Msg("could not select next gateway client")
+ return owner, shareFolder, shareLink, ctx, err
+ }
+
+ ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not get service user context")
+ return owner, shareFolder, shareLink, ctx, err
+ }
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().
Err(err).
Msg("could not stat resource")
- return
+ return owner, shareFolder, shareLink, ctx, err
}
+ shareFolder = resourceInfo.Name
- shareLink, err := urlJoinPath(s.ocisURL, "files/shares/with-me")
+ shareLink, err = urlJoinPath(s.ocisURL, "files/shares/with-me")
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
- return
+ return owner, shareFolder, shareLink, ctx, err
}
- owner, err := utils.GetUser(e.Sharer, gatewayClient)
+ owner, err = utils.GetUserWithContext(ctx, e.Sharer, gatewayClient)
if err != nil {
- logger.Error().Err(err).Msg("Could not get user")
- return
- }
-
- granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
- filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareCreated)
- if filteredGrantees == nil {
- return
+ logger.Error().
+ Err(err).
+ Msg("could not get user")
+ return owner, shareFolder, shareLink, ctx, err
}
- sharerDisplayName := owner.GetDisplayName()
- recipientList, err := s.render(ctx, email.ShareCreated,
- "ShareGrantee",
- map[string]string{
- "ShareSharer": sharerDisplayName,
- "ShareFolder": resourceInfo.Name,
- "ShareLink": shareLink,
- }, filteredGrantees, sharerDisplayName)
- if err != nil {
- s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("could not get render the email")
- return
- }
- s.send(ctx, recipientList)
+ return owner, shareFolder, shareLink, ctx, err
}
-func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
+func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) {
logger := s.logger.With().
Str("event", "ShareExpired").
Str("itemid", e.ItemID.GetOpaqueId()).
@@ -81,21 +101,13 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
return
}
- ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
+ shareFolder, ctx, err := s.prepareShareExpired(logger, e)
if err != nil {
- logger.Error().Err(err).Msg("Could not impersonate sharer")
+ logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
- resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
- if err != nil {
- logger.Error().
- Err(err).
- Msg("could not stat resource")
- return
- }
-
- owner, err := utils.GetUser(e.ShareOwner, gatewayClient)
+ owner, err := utils.GetUserWithContext(ctx, e.ShareOwner, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("Could not get user")
return
@@ -103,19 +115,48 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareExpired)
- if filteredGrantees == nil {
+
+ recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
+ if recipientsInstant == nil {
return
}
- recipientList, err := s.render(ctx, email.ShareExpired,
+ emails, err := s.render(ctx, email.ShareExpired,
"ShareGrantee",
map[string]string{
- "ShareFolder": resourceInfo.GetName(),
+ "ShareFolder": shareFolder,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
- }, filteredGrantees, owner.GetDisplayName())
+ }, recipientsInstant, owner.GetDisplayName())
if err != nil {
- s.logger.Error().Err(err).Str("event", "ShareExpired").Msg("could not get render the email")
+ logger.Error().Err(err).Msg("could not get render the email")
return
}
- s.send(ctx, recipientList)
+ s.send(ctx, emails)
+}
+
+func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.ShareExpired) (shareFolder string, ctx context.Context, err error) {
+ gatewayClient, err := s.gatewaySelector.Next()
+ if err != nil {
+ logger.Error().Err(err).Msg("could not select next gateway client")
+ return shareFolder, ctx, err
+ }
+
+ ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not get service user context")
+ return shareFolder, ctx, err
+ }
+
+ resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
+ if err != nil {
+ logger.Error().
+ Err(err).
+ Msg("could not stat resource")
+ return shareFolder, ctx, err
+ }
+ shareFolder = resourceInfo.GetName()
+
+ return shareFolder, ctx, err
}
diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go
index 5f1b4c827ca..8607acbafde 100644
--- a/services/notifications/pkg/service/spaces.go
+++ b/services/notifications/pkg/service/spaces.go
@@ -1,31 +1,71 @@
package service
import (
+ "context"
+ user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/storagespace"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
+ "github.com/rs/zerolog"
)
-func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
+func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceShared").
Str("itemid", e.ID.OpaqueId).
Logger()
+ executant, spaceName, shareLink, ctx, err := s.prepareSpaceShared(logger, e)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not prepare vars for email")
+ return
+ }
+
+ granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
+ filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceShared)
+
+ recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
+ if recipientsInstant == nil {
+ return
+ }
+
+ sharerDisplayName := executant.GetDisplayName()
+ emails, err := s.render(ctx, email.SharedSpace,
+ "SpaceGrantee",
+ map[string]string{
+ "SpaceSharer": sharerDisplayName,
+ "SpaceName": spaceName,
+ "ShareLink": shareLink,
+ }, recipientsInstant, sharerDisplayName)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not get render the email")
+ return
+ }
+ s.send(ctx, emails)
+}
+func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.SpaceShared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
- return
+ return executant, spaceName, shareLink, ctx, err
}
- ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
+ ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
+ if err != nil {
+ logger.Error().Err(err).Msg("could not get service user context")
+ return executant, spaceName, shareLink, ctx, err
+ }
+
+ executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
- Msg("could not handle space shared event")
- return
+ Msg("could not get user")
+ return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
@@ -33,7 +73,7 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
logger.Error().
Err(err).
Msg("could not parse resourceid from ItemID ")
- return
+ return executant, spaceName, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
@@ -41,124 +81,106 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
logger.Error().
Err(err).
Msg("could not get space info")
- return
+ return executant, spaceName, shareLink, ctx, err
}
+ spaceName = resourceInfo.GetSpace().GetName()
- shareLink, err := urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
+ shareLink, err = urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
- return
+ return executant, spaceName, shareLink, ctx, err
}
+ return executant, spaceName, shareLink, ctx, err
+}
- executant, err := utils.GetUser(e.Executant, gatewayClient)
+func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) {
+ logger := s.logger.With().
+ Str("event", "SpaceUnshared").
+ Str("itemid", e.ID.OpaqueId).
+ Logger()
+
+ executant, spaceName, shareLink, ctx, err := s.prepareSpaceUnshared(logger, e)
if err != nil {
- logger.Error().
- Err(err).
- Msg("could not get user")
+ logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
- // Note: We're using the 'executantCtx' (authenticated as the share executant) here for requesting
- // the Grantees of the shares. Ideally the notfication service would use some kind of service
- // user for this.
granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
- filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceShared)
- if filteredGrantees == nil {
+ filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceUnshared)
+
+ recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
+ if recipientsInstant == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
- recipientList, err := s.render(ctx, email.SharedSpace,
+ emails, err := s.render(ctx, email.UnsharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
- "SpaceName": resourceInfo.GetSpace().GetName(),
+ "SpaceName": spaceName,
"ShareLink": shareLink,
- }, filteredGrantees, sharerDisplayName)
+ }, recipientsInstant, sharerDisplayName)
if err != nil {
- s.logger.Error().Err(err).Str("event", "SharedSpace").Msg("could not get render the email")
+ logger.Error().Err(err).Msg("Could not get render the email")
return
}
- s.send(ctx, recipientList)
+ s.send(ctx, emails)
}
-func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) {
- logger := s.logger.With().
- Str("event", "SpaceUnshared").
- Str("itemid", e.ID.OpaqueId).
- Logger()
-
+func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.SpaceUnshared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
- return
+ return executant, spaceName, shareLink, ctx, err
}
- ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
+ ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
- logger.Error().Err(err).Msg("could not handle space unshared event")
- return
+ logger.Error().Err(err).Msg("could not get service user context")
+ return executant, spaceName, shareLink, ctx, err
}
- resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
+ executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
- Msg("could not parse resourceid from ItemID ")
- return
+ Msg("could not get user")
+ return executant, spaceName, shareLink, ctx, err
}
- resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
+ resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
- Msg("could not get space info")
- return
+ Msg("could not parse resourceid from ItemID ")
+ return executant, spaceName, shareLink, ctx, err
}
- shareLink, err := urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
+ resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
if err != nil {
logger.Error().
Err(err).
- Msg("could not create link to the share")
- return
+ Msg("could not get space info")
+ return executant, spaceName, shareLink, ctx, err
}
+ spaceName = resourceInfo.GetSpace().GetName()
- executant, err := utils.GetUser(e.Executant, gatewayClient)
+ shareLink, err = urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
- Msg("could not get user")
- return
- }
-
- // Note: We're using the 'executantCtx' (authenticated as the share executant) here for requesting
- // the Grantees of the shares. Ideally the notfication service would use some kind of service
- // user for this.
- granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
- filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceUnshared)
- if filteredGrantees == nil {
- return
- }
-
- sharerDisplayName := executant.GetDisplayName()
- recipientList, err := s.render(ctx, email.UnsharedSpace,
- "SpaceGrantee",
- map[string]string{
- "SpaceSharer": sharerDisplayName,
- "SpaceName": resourceInfo.GetSpace().Name,
- "ShareLink": shareLink,
- }, filteredGrantees, sharerDisplayName)
- if err != nil {
- s.logger.Error().Err(err).Str("event", "UnsharedSpace").Msg("Could not get render the email")
- return
+ Msg("could not create link to the share")
+ return executant, spaceName, shareLink, ctx, err
}
- s.send(ctx, recipientList)
+ return executant, spaceName, shareLink, ctx, err
}
-func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired) {
+func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired, eventId string) {
logger := s.logger.With().
Str("event", "SpaceMembershipExpired").
Str("itemid", e.SpaceID.GetOpaqueId()).
@@ -189,19 +211,23 @@ func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExp
return
}
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceMembershipExpired)
- if filteredGrantees == nil {
+
+ recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
+ recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
+ if recipientsInstant == nil {
return
}
- recipientList, err := s.render(ctx, email.MembershipExpired,
+ emails, err := s.render(ctx, email.MembershipExpired,
"SpaceGrantee",
map[string]string{
"SpaceName": e.SpaceName,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
- }, filteredGrantees, owner.GetDisplayName())
+ }, recipientsInstant, owner.GetDisplayName())
if err != nil {
- s.logger.Error().Err(err).Str("event", "SpaceUnshared").Msg("could not get render the email")
+ logger.Error().Err(err).Msg("could not get render the email")
return
}
- s.send(ctx, recipientList)
+ s.send(ctx, emails)
}
diff --git a/services/notifications/pkg/service/splitter.go b/services/notifications/pkg/service/splitter.go
new file mode 100644
index 00000000000..866e1860f3e
--- /dev/null
+++ b/services/notifications/pkg/service/splitter.go
@@ -0,0 +1,60 @@
+package service
+
+import (
+ "context"
+ user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+ "github.com/owncloud/ocis/v2/ocis-pkg/log"
+ "github.com/owncloud/ocis/v2/ocis-pkg/middleware"
+ settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+ "github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
+ "github.com/pkg/errors"
+ micrometadata "go-micro.dev/v4/metadata"
+)
+
+type intervalSplitter struct {
+ log log.Logger
+ valueClient settingssvc.ValueService
+}
+
+func newIntervalSplitter(l log.Logger, vc settingssvc.ValueService) *intervalSplitter {
+ return &intervalSplitter{log: l, valueClient: vc}
+}
+
+// execute splits users into 3 lists depending on their email sending interval settings
+func (s intervalSplitter) execute(ctx context.Context, users []*user.User) (instant, daily, weekly []*user.User) {
+ for _, u := range users {
+ userId := u.GetId().GetOpaqueId()
+ interval, err := getEmailSendingInterval(ctx, s.valueClient, userId)
+ if err != nil {
+ s.log.Error().Err(err).Str("userId", userId).Msg("cannot get user email sending interval")
+ instant = append(instant, u)
+ } else if interval == "instant" {
+ instant = append(instant, u)
+ } else if interval == _intervalDaily {
+ daily = append(daily, u)
+ } else if interval == _intervalWeekly {
+ weekly = append(weekly, u)
+ }
+ }
+ return
+}
+
+func getEmailSendingInterval(ctx context.Context, vc settingssvc.ValueService, userId string) (string, error) {
+ resp, err := vc.GetValueByUniqueIdentifiers(
+ micrometadata.Set(ctx, middleware.AccountID, userId),
+ &settingssvc.GetValueByUniqueIdentifiersRequest{
+ AccountUuid: userId,
+ SettingId: defaults.SettingUUIDProfileEmailSendingInterval,
+ },
+ )
+
+ if err != nil {
+ return "", err
+ }
+
+ val := resp.GetValue().GetValue().GetStringValue()
+ if val == "" {
+ return "", errors.New("email sending interval is empty")
+ }
+ return val, nil
+}
diff --git a/services/notifications/pkg/service/splitter_test.go b/services/notifications/pkg/service/splitter_test.go
new file mode 100644
index 00000000000..14832121fd8
--- /dev/null
+++ b/services/notifications/pkg/service/splitter_test.go
@@ -0,0 +1,196 @@
+package service
+
+import (
+ "context"
+ user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+ "github.com/owncloud/ocis/v2/ocis-pkg/log"
+ settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+ settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+ v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/assert"
+ "go-micro.dev/v4/client"
+ "strings"
+ "testing"
+)
+
+func Test_intervalSplitter_execute(t *testing.T) {
+ type fields struct {
+ log log.Logger
+ valueClient v0.ValueService
+ }
+ type args struct {
+ ctx context.Context
+ users []*user.User
+ settingId string
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantInstant []*user.User
+ wantDaily []*user.User
+ wantWeekly []*user.User
+ }{
+ {"no connection to ValueService",
+ fields{
+ log: testLogger,
+ valueClient: settings.MockValueService{
+ GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
+ return nil, errors.New("no connection to ValueService")
+ }}}, args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ settingId: "",
+ },
+ newUsers("foo"), []*user.User(nil), []*user.User(nil),
+ },
+ {"no setting in ValueService response",
+ fields{
+ log: testLogger,
+ valueClient: settings.MockValueService{
+ GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
+ return &settings.GetValueResponse{}, nil
+ }}},
+ args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ settingId: "",
+ },
+ newUsers("foo"), []*user.User(nil), []*user.User(nil),
+ },
+ {"ValueService nil response",
+ fields{
+ log: testLogger,
+ valueClient: settings.MockValueService{
+ GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
+ return nil, nil
+ }}},
+ args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ settingId: "",
+ },
+ newUsers("foo"), []*user.User(nil), []*user.User(nil),
+ },
+ {"input users nil",
+ fields{
+ log: testLogger,
+ valueClient: settings.MockValueService{
+ GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
+ return nil, nil
+ }},
+ },
+ args{
+ ctx: context.TODO(),
+ users: nil,
+ },
+ []*user.User(nil), []*user.User(nil), []*user.User(nil),
+ },
+ {"interval never",
+ fields{
+ log: testLogger,
+ valueClient: newStringValueMockValueService("never"),
+ },
+ args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ },
+ []*user.User(nil), []*user.User(nil), []*user.User(nil),
+ },
+ {"interval instant",
+ fields{
+ log: testLogger,
+ valueClient: newStringValueMockValueService("instant"),
+ },
+ args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ },
+ newUsers("foo"), []*user.User(nil), []*user.User(nil),
+ },
+ {"interval daily",
+ fields{
+ log: testLogger,
+ valueClient: newStringValueMockValueService("daily"),
+ },
+ args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ },
+ []*user.User(nil), newUsers("foo"), []*user.User(nil),
+ },
+ {"interval weekly",
+ fields{
+ log: testLogger,
+ valueClient: newStringValueMockValueService("weekly"),
+ },
+ args{
+ ctx: context.TODO(),
+ users: newUsers("foo"),
+ },
+ []*user.User(nil), []*user.User(nil), newUsers("foo"),
+ },
+ {"multiple users and intervals",
+ fields{
+ log: testLogger,
+ valueClient: settings.MockValueService{
+ GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
+ if strings.Contains(req.AccountUuid, "never") {
+ return newGetValueResponseStringValue("never"), nil
+ } else if strings.Contains(req.AccountUuid, "instant") {
+ return newGetValueResponseStringValue("instant"), nil
+ } else if strings.Contains(req.AccountUuid, "daily") {
+ return newGetValueResponseStringValue("daily"), nil
+ } else if strings.Contains(req.AccountUuid, "weekly") {
+ return newGetValueResponseStringValue("weekly"), nil
+ }
+ return nil, nil
+ }},
+ },
+ args{
+ ctx: context.TODO(),
+ users: newUsers("never1", "instant1", "daily1", "weekly1", "never2", "instant2", "daily2", "weekly2"),
+ },
+ newUsers("instant1", "instant2"), newUsers("daily1", "daily2"), newUsers("weekly1", "weekly2"),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := intervalSplitter{
+ log: tt.fields.log,
+ valueClient: tt.fields.valueClient,
+ }
+ gotInstant, gotDaily, gotWeekly := s.execute(tt.args.ctx, tt.args.users)
+ assert.Equalf(t, tt.wantInstant, gotInstant, "execute(%v, %v, %v)", tt.args.ctx, tt.args.users)
+ assert.Equalf(t, tt.wantDaily, gotDaily, "execute(%v, %v, %v)", tt.args.ctx, tt.args.users)
+ assert.Equalf(t, tt.wantWeekly, gotWeekly, "execute(%v, %v, %v)", tt.args.ctx, tt.args.users)
+ })
+ }
+}
+
+func newStringValueMockValueService(strVal string) settings.ValueService {
+ return settings.MockValueService{
+ GetValueByUniqueIdentifiersFunc: func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) (*settings.GetValueResponse, error) {
+ return newGetValueResponseStringValue(strVal), nil
+ },
+ }
+}
+
+func newGetValueResponseStringValue(strVal string) *settings.GetValueResponse {
+ return &settings.GetValueResponse{Value: &settingsmsg.ValueWithIdentifier{
+ Value: &settingsmsg.Value{
+ Value: &settingsmsg.Value_StringValue{
+ StringValue: strVal,
+ },
+ },
+ }}
+}
+
+func newUsers(ids ...string) []*user.User {
+ var users []*user.User
+ for _, s := range ids {
+ users = append(users, &user.User{Id: &user.UserId{OpaqueId: s}})
+ }
+ return users
+}
diff --git a/services/settings/pkg/service/v0/servicedecorator.go b/services/settings/pkg/service/v0/servicedecorator.go
index 33ccfd5b59b..421ffe238e1 100644
--- a/services/settings/pkg/service/v0/servicedecorator.go
+++ b/services/settings/pkg/service/v0/servicedecorator.go
@@ -139,6 +139,16 @@ func (s *defaultLanguageDecorator) withDefaultProfileValueList(ctx context.Conte
case *settingsmsg.Setting_MultiChoiceCollectionValue:
newVal.Value.Value = multiChoiceCollectionToValue(val.MultiChoiceCollectionValue)
requested[setting.GetId()] = newVal
+ case *settingsmsg.Setting_SingleChoiceValue:
+ sv := &settingsmsg.Value_StringValue{}
+ for _, option := range val.SingleChoiceValue.Options {
+ if option.GetDefault() {
+ sv.StringValue = option.Value.GetStringValue()
+ break
+ }
+ }
+ newVal.Value.Value = sv
+ requested[setting.GetId()] = newVal
}
}
@@ -182,5 +192,6 @@ func getDefaultValueList() map[string]*settingsmsg.ValueWithIdentifier {
defaults.SettingUUIDProfileEventSpaceDisabled: nil,
defaults.SettingUUIDProfileEventSpaceDeleted: nil,
defaults.SettingUUIDProfileEventPostprocessingStepFinished: nil,
+ defaults.SettingUUIDProfileEmailSendingInterval: nil,
}
}