From db3d49afe5de1ec26624fe6f03b9fbb3b8c6df55 Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Wed, 8 Jan 2025 19:31:40 +0100 Subject: [PATCH 01/11] feat: Add cli command to trigger grouped emails --- services/notifications/pkg/command/root.go | 1 + .../notifications/pkg/command/send_email.go | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 services/notifications/pkg/command/send_email.go diff --git a/services/notifications/pkg/command/root.go b/services/notifications/pkg/command/root.go index 2833e622ed..d61b299dc1 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 0000000000..8da57ce844 --- /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 + }, + } +} From 151c9ccabacc827c01406b4850fc7dc3c403fe87 Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Wed, 8 Jan 2025 19:33:31 +0100 Subject: [PATCH 02/11] fix: Default value for email sending interval --- services/settings/pkg/service/v0/servicedecorator.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/settings/pkg/service/v0/servicedecorator.go b/services/settings/pkg/service/v0/servicedecorator.go index 33ccfd5b59..421ffe238e 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, } } From 9af84f02e42c4e04207b6df791b96f3b58a4a30f Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Wed, 8 Jan 2025 19:37:00 +0100 Subject: [PATCH 03/11] feat: Send emails when retrieval of setting fails --- services/notifications/pkg/service/filter.go | 1 + services/notifications/pkg/service/filter_test.go | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/services/notifications/pkg/service/filter.go b/services/notifications/pkg/service/filter.go index cde3fd19a8..ac0194ed7a 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 c910ead669..a0ba9c4436 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)}, } From 9849e747e18ea9afa4500125568de4a323149782 Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Wed, 8 Jan 2025 19:40:16 +0100 Subject: [PATCH 04/11] feat: Persist events for grouped emails based on settings --- services/notifications/pkg/command/server.go | 27 ++- services/notifications/pkg/config/config.go | 20 ++ .../pkg/config/defaults/defaultconfig.go | 8 + .../notifications/pkg/service/persistence.go | 116 ++++++++++ .../notifications/pkg/service/sciencemesh.go | 2 +- services/notifications/pkg/service/service.go | 29 ++- .../notifications/pkg/service/service_test.go | 13 +- services/notifications/pkg/service/shares.go | 151 ++++++++----- services/notifications/pkg/service/spaces.go | 198 ++++++++++-------- .../notifications/pkg/service/splitter.go | 61 ++++++ .../pkg/service/splitter_test.go | 196 +++++++++++++++++ 11 files changed, 664 insertions(+), 157 deletions(-) create mode 100644 services/notifications/pkg/service/persistence.go create mode 100644 services/notifications/pkg/service/splitter.go create mode 100644 services/notifications/pkg/service/splitter_test.go diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go index 8aa1e1bec7..309bc603f0 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 280a695470..9dd24773b2 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,20 @@ 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"` } + +// TODO: +// - README +// - DOCS +// - introductionVersion correct? +// - is TTL mandatory? + +// 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 3a5ff26192..69b94c91d7 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/service/persistence.go b/services/notifications/pkg/service/persistence.go new file mode 100644 index 0000000000..b08deefdf8 --- /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 98d01f71a6..a1c260414a 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 4016249e9a..0f3bac70e9 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,15 +108,15 @@ 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) } @@ -135,8 +148,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 e2ab94e132..9ba58acb4a 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 1413b3e049..58aee3b601 100644 --- a/services/notifications/pkg/service/shares.go +++ b/services/notifications/pkg/service/shares.go @@ -1,29 +1,63 @@ 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/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/notifications/pkg/email" "github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults" "google.golang.org/protobuf/types/known/fieldmaskpb" ) -func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { - logger := s.logger.With(). +func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string) { + logger := log.Logger{Logger: s.logger.With(). Str("event", "ShareCreated"). Str("itemid", e.ItemID.OpaqueId). - Logger() + Logger()} + owner, shareFolder, shareLink, ctx, err := s.prepareShareCreated(logger, e) + if err != nil { + logger.Error().Err(err).Msg("could not prepare vars for email") + return + } + + 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 get render the email") + return + } + s.send(ctx, emails) +} + +func (s eventsNotifier) prepareShareCreated(logger log.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 } - 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 impersonate service user") - return + logger.Error().Err(err).Msg("could not get service user context") } resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) @@ -33,8 +67,9 @@ func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { Msg("could not stat resource") return } + 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). @@ -42,38 +77,22 @@ func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { return } - 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") + 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 - } - - 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 } -func (s eventsNotifier) handleShareExpired(e events.ShareExpired) { - logger := s.logger.With(). +func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) { + logger := log.Logger{Logger: s.logger.With(). Str("event", "ShareExpired"). Str("itemid", e.ItemID.GetOpaqueId()). - Logger() + Logger()} gatewayClient, err := s.gatewaySelector.Next() if err != nil { @@ -81,12 +100,53 @@ 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 } + owner, err := utils.GetUserWithContext(ctx, e.ShareOwner, 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.SettingUUIDProfileEventShareExpired) + + 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 + } + + emails, err := s.render(ctx, email.ShareExpired, + "ShareGrantee", + map[string]string{ + "ShareFolder": shareFolder, + "ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"), + }, recipientsInstant, owner.GetDisplayName()) + if err != nil { + logger.Error().Err(err).Msg("could not get render the email") + return + } + s.send(ctx, emails) +} + +func (s eventsNotifier) prepareShareExpired(logger log.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 + } + + 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") + } + resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) if err != nil { logger.Error(). @@ -94,28 +154,7 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) { Msg("could not stat resource") return } + shareFolder = resourceInfo.GetName() - owner, err := utils.GetUser(e.ShareOwner, 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.SettingUUIDProfileEventShareExpired) - if filteredGrantees == nil { - return - } - - recipientList, err := s.render(ctx, email.ShareExpired, - "ShareGrantee", - map[string]string{ - "ShareFolder": resourceInfo.GetName(), - "ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"), - }, filteredGrantees, owner.GetDisplayName()) - if err != nil { - s.logger.Error().Err(err).Str("event", "ShareExpired").Msg("could not get render the email") - return - } - s.send(ctx, recipientList) + return } diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go index 5f1b4c827c..91ac5f204a 100644 --- a/services/notifications/pkg/service/spaces.go +++ b/services/notifications/pkg/service/spaces.go @@ -1,104 +1,69 @@ 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/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/notifications/pkg/email" "github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults" ) -func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) { - logger := s.logger.With(). +func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) { + logger := log.Logger{Logger: s.logger.With(). Str("event", "SpaceShared"). Str("itemid", e.ID.OpaqueId). - Logger() - - gatewayClient, err := s.gatewaySelector.Next() + Logger()} + executant, spaceName, shareLink, ctx, err := s.prepareSpaceShared(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) - if err != nil { - logger.Error(). - Err(err). - Msg("could not handle space shared event") - return - } - - resourceID, err := storagespace.ParseID(e.ID.OpaqueId) - if err != nil { - logger.Error(). - Err(err). - Msg("could not parse resourceid from ItemID ") - return - } - - resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil) - if err != nil { - logger.Error(). - Err(err). - Msg("could not get space info") - return - } - - 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 - } - - executant, err := utils.GetUser(e.Executant, gatewayClient) - 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.SettingUUIDProfileEventSpaceShared) - 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 } sharerDisplayName := executant.GetDisplayName() - recipientList, err := s.render(ctx, email.SharedSpace, + emails, err := s.render(ctx, email.SharedSpace, "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) prepareSpaceShared(logger log.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 } - 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") + logger.Error().Err(err).Msg("could not get service user context") + } + + executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient) + if err != nil { + logger.Error(). + Err(err). + Msg("could not get user") return } @@ -117,16 +82,68 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) { Msg("could not get space info") return } + 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, err := utils.GetUser(e.Executant, gatewayClient) +func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) { + logger := log.Logger{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 prepare vars for email") + return + } + + granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID) + 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() + emails, err := s.render(ctx, email.UnsharedSpace, + "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) prepareSpaceUnshared(logger log.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 + } + + 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") + } + + executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient) if err != nil { logger.Error(). Err(err). @@ -134,31 +151,34 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) { 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 { + resourceID, err := storagespace.ParseID(e.ID.OpaqueId) + if err != nil { + logger.Error(). + Err(err). + Msg("could not parse resourceid from ItemID ") 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) + resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil) if err != nil { - s.logger.Error().Err(err).Str("event", "UnsharedSpace").Msg("Could not get render the email") + logger.Error(). + Err(err). + Msg("could not get space info") return } - s.send(ctx, recipientList) + spaceName = resourceInfo.GetSpace().GetName() + + 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 } -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 +209,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 0000000000..1991223223 --- /dev/null +++ b/services/notifications/pkg/service/splitter.go @@ -0,0 +1,61 @@ +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 + persistenceService userEventStore +} + +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 0000000000..14832121fd --- /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 +} From 65661927776d51b0b3f70f9453d8501cf958a514 Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Wed, 8 Jan 2025 19:41:14 +0100 Subject: [PATCH 05/11] feat: Add job to create grouped email on event --- services/notifications/pkg/email/composer.go | 60 +++++++ services/notifications/pkg/email/email.go | 59 +++++++ services/notifications/pkg/email/templates.go | 57 +++++-- services/notifications/pkg/service/job.go | 161 ++++++++++++++++++ services/notifications/pkg/service/service.go | 2 + 5 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 services/notifications/pkg/service/job.go diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go index 097ff3631e..8bea2bd75e 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 + } + + var bodyParts []string + 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 + } + + var bodyParts []string + 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 e0d488e1fb..7fa259bdd3 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 + } + + var escapedMtsVars []map[string]string + 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 a7aa070306..c9ebb173b8 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,17 @@ 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 + GroupedTemplates: []MessageTemplate{ShareCreated, ShareExpired, SharedSpace, UnsharedSpace, MembershipExpired}, + } ) // holds the information to turn the raw template into a parseable go template @@ -118,6 +134,7 @@ var _placeholders = map[string]string{ "{ShareSharerMail}": "{{ .ShareSharerMail }}", "{ProviderDomain}": "{{ .ProviderDomain }}", "{Token}": "{{ .Token }}", + "{DisplayName}": "{{ .DisplayName }}", } // MessageTemplate is the data structure for the email @@ -132,3 +149,15 @@ type MessageTemplate struct { MessageBody string CallToAction string } + +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 + GroupedTemplates []MessageTemplate +} diff --git a/services/notifications/pkg/service/job.go b/services/notifications/pkg/service/job.go new file mode 100644 index 0000000000..52b5c12b69 --- /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" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + 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" +) + +func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEvent, eventId string) { + logger := log.Logger{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 log.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 := log.Logger{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 := log.Logger{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 := log.Logger{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 := log.Logger{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 log.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/service.go b/services/notifications/pkg/service/service.go index 0f3bac70e9..acf4545c52 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -119,6 +119,8 @@ func (s eventsNotifier) Run() error { s.handleShareExpired(e, evt.ID) case events.ScienceMeshInviteTokenGenerated: s.handleScienceMeshInviteTokenGenerated(e) + case events.SendEmailsEvent: + s.sendGroupedEmailsJob(e, evt.ID) } }() case <-s.signals: From 5f1b193792a7e31ef2595392ceb404a76fcb4032 Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Thu, 9 Jan 2025 09:38:06 +0100 Subject: [PATCH 06/11] refactor: Remove unused struct field --- services/notifications/pkg/email/templates.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index c9ebb173b8..85501a9a97 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -115,9 +115,8 @@ Please visit your federation settings and use the following details: // 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 - GroupedTemplates: []MessageTemplate{ShareCreated, ShareExpired, SharedSpace, UnsharedSpace, MembershipExpired}, + Greeting: l10n.Template(`Hi {DisplayName},`), + MessageBody: "", // is generated using the GroupedTemplates } ) @@ -156,8 +155,7 @@ type GroupedMessageTemplate struct { // 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 - GroupedTemplates []MessageTemplate + Subject string + Greeting string + MessageBody string } From 7697e6d9e5664491cc1b97fa9ca57a5d096d624a Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Thu, 9 Jan 2025 10:10:57 +0100 Subject: [PATCH 07/11] refactor: Use zerolog.Logger instead of log.Logger --- services/notifications/pkg/service/job.go | 28 +++++++++---------- .../notifications/pkg/service/persistence.go | 4 +-- services/notifications/pkg/service/shares.go | 22 +++++++-------- services/notifications/pkg/service/spaces.go | 26 ++++++++--------- .../notifications/pkg/service/splitter.go | 4 +-- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/services/notifications/pkg/service/job.go b/services/notifications/pkg/service/job.go index 52b5c12b69..56c60d9ebc 100644 --- a/services/notifications/pkg/service/job.go +++ b/services/notifications/pkg/service/job.go @@ -4,19 +4,19 @@ import ( "context" "github.com/cs3org/reva/v2/pkg/events" "github.com/owncloud/ocis/v2/ocis-pkg/l10n" - "github.com/owncloud/ocis/v2/ocis-pkg/log" 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 := log.Logger{Logger: s.logger.With(). + logger := s.logger.With(). Str("event", "SendEmailsEvent"). Str("eventId", eventId). - Logger()} + Logger() - if sendEmailsEvent.Interval != intervalDaily && sendEmailsEvent.Interval != intervalWeekly { + if sendEmailsEvent.Interval != _intervalDaily && sendEmailsEvent.Interval != _intervalWeekly { logger.Error().Str("interval", sendEmailsEvent.Interval).Msg("unsupported email sending interval") return } @@ -42,7 +42,7 @@ func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEv } } -func (s eventsNotifier) createGroupedMail(ctx context.Context, logger log.Logger, key string) { +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") @@ -56,10 +56,10 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger log.Logger for _, e := range userEvents.Events { switch te := s.unwrapEvent(logger, e).(type) { case events.SpaceShared: - logger := log.Logger{Logger: logger.With(). + logger := logger.With(). Str("event", "SpaceShared"). Str("eventId", te.ID.OpaqueId). - Logger()} + Logger() executant, spaceName, shareLink, _, err := s.prepareSpaceShared(logger, te) if err != nil { @@ -74,10 +74,10 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger log.Logger "ShareLink": shareLink, }) case events.SpaceUnshared: - logger := log.Logger{Logger: logger.With(). + logger := logger.With(). Str("event", "SpaceUnshared"). Str("eventId", te.ID.OpaqueId). - Logger()} + Logger() executant, spaceName, shareLink, _, err := s.prepareSpaceUnshared(logger, te) if err != nil { @@ -97,10 +97,10 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger log.Logger "ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"), }) case events.ShareCreated: - logger := log.Logger{Logger: logger.With(). + logger := logger.With(). Str("event", "ShareCreated"). Str("eventId", te.ItemID.OpaqueId). - Logger()} + Logger() owner, shareFolder, shareLink, _, err := s.prepareShareCreated(logger, te) if err != nil { @@ -114,10 +114,10 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger log.Logger "ShareLink": shareLink, }) case events.ShareExpired: - logger := log.Logger{Logger: logger.With(). + logger := logger.With(). Str("event", "ShareCreated"). Str("eventId", te.ItemID.OpaqueId). - Logger()} + Logger() shareFolder, _, err := s.prepareShareExpired(logger, te) if err != nil { @@ -144,7 +144,7 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger log.Logger s.send(ctx, []*channels.Message{rendered}) } -func (s eventsNotifier) unwrapEvent(logger log.Logger, e *ehmsg.Event) any { +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") diff --git a/services/notifications/pkg/service/persistence.go b/services/notifications/pkg/service/persistence.go index b08deefdf8..d362d355f9 100644 --- a/services/notifications/pkg/service/persistence.go +++ b/services/notifications/pkg/service/persistence.go @@ -28,8 +28,8 @@ type userEvents struct { } const ( - intervalDaily = "daily" - intervalWeekly = "weekly" + _intervalDaily = "daily" + _intervalWeekly = "weekly" ) func newUserEventStore(l log.Logger, s store.Store, hc ehsvc.EventHistoryService) *userEventStore { diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go index 58aee3b601..2799e9ca57 100644 --- a/services/notifications/pkg/service/shares.go +++ b/services/notifications/pkg/service/shares.go @@ -5,17 +5,17 @@ import ( 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/ocis-pkg/log" "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, eventId string) { - logger := log.Logger{Logger: s.logger.With(). + logger := s.logger.With(). Str("event", "ShareCreated"). Str("itemid", e.ItemID.OpaqueId). - Logger()} + Logger() owner, shareFolder, shareLink, ctx, err := s.prepareShareCreated(logger, e) if err != nil { @@ -27,8 +27,8 @@ func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string 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)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...) if recipientsInstant == nil { return } @@ -48,7 +48,7 @@ func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string s.send(ctx, emails) } -func (s eventsNotifier) prepareShareCreated(logger log.Logger, e events.ShareCreated) (owner *user.User, shareFolder, shareLink string, ctx context.Context, err error) { +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") @@ -89,10 +89,10 @@ func (s eventsNotifier) prepareShareCreated(logger log.Logger, e events.ShareCre } func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) { - logger := log.Logger{Logger: s.logger.With(). + logger := s.logger.With(). Str("event", "ShareExpired"). Str("itemid", e.ItemID.GetOpaqueId()). - Logger()} + Logger() gatewayClient, err := s.gatewaySelector.Next() if err != nil { @@ -116,8 +116,8 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareExpired) 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)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...) if recipientsInstant == nil { return } @@ -135,7 +135,7 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string s.send(ctx, emails) } -func (s eventsNotifier) prepareShareExpired(logger log.Logger, e events.ShareExpired) (shareFolder string, ctx context.Context, err error) { +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") diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go index 91ac5f204a..9c9a7cf140 100644 --- a/services/notifications/pkg/service/spaces.go +++ b/services/notifications/pkg/service/spaces.go @@ -6,16 +6,16 @@ import ( "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/ocis-pkg/log" "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, eventId string) { - logger := log.Logger{Logger: s.logger.With(). + logger := s.logger.With(). Str("event", "SpaceShared"). Str("itemid", e.ID.OpaqueId). - Logger()} + Logger() executant, spaceName, shareLink, ctx, err := s.prepareSpaceShared(logger, e) if err != nil { logger.Error().Err(err).Msg("could not prepare vars for email") @@ -26,8 +26,8 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) 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)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...) if recipientsInstant == nil { return } @@ -47,7 +47,7 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) s.send(ctx, emails) } -func (s eventsNotifier) prepareSpaceShared(logger log.Logger, e events.SpaceShared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) { +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") @@ -95,10 +95,10 @@ func (s eventsNotifier) prepareSpaceShared(logger log.Logger, e events.SpaceShar } func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) { - logger := log.Logger{Logger: s.logger.With(). + logger := s.logger.With(). Str("event", "SpaceUnshared"). Str("itemid", e.ID.OpaqueId). - Logger()} + Logger() executant, spaceName, shareLink, ctx, err := s.prepareSpaceUnshared(logger, e) if err != nil { @@ -110,8 +110,8 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId stri 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)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...) if recipientsInstant == nil { return } @@ -131,7 +131,7 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId stri s.send(ctx, emails) } -func (s eventsNotifier) prepareSpaceUnshared(logger log.Logger, e events.SpaceUnshared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) { +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") @@ -211,8 +211,8 @@ func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExp filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceMembershipExpired) 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)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...) if recipientsInstant == nil { return } diff --git a/services/notifications/pkg/service/splitter.go b/services/notifications/pkg/service/splitter.go index 1991223223..e2f080ba3c 100644 --- a/services/notifications/pkg/service/splitter.go +++ b/services/notifications/pkg/service/splitter.go @@ -31,9 +31,9 @@ func (s intervalSplitter) execute(ctx context.Context, users []*user.User) (inst instant = append(instant, u) } else if interval == "instant" { instant = append(instant, u) - } else if interval == intervalDaily { + } else if interval == _intervalDaily { daily = append(daily, u) - } else if interval == intervalWeekly { + } else if interval == _intervalWeekly { weekly = append(weekly, u) } } From 6c2ab079551c4aef3c3f2d3a486b6556ad0af01a Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Thu, 9 Jan 2025 13:03:40 +0100 Subject: [PATCH 08/11] docs: Add changelog and readme entry --- ...enhancement-mail-notifications-grouping.md | 6 +++++ services/notifications/README.md | 23 +++++++++++++++++++ services/notifications/pkg/config/config.go | 6 ----- 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 changelog/unreleased/enhancement-mail-notifications-grouping.md diff --git a/changelog/unreleased/enhancement-mail-notifications-grouping.md b/changelog/unreleased/enhancement-mail-notifications-grouping.md new file mode 100644 index 0000000000..85de9f3e23 --- /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 4e70e81ad6..c3b912baf7 100644 --- a/services/notifications/README.md +++ b/services/notifications/README.md @@ -46,6 +46,29 @@ 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 ```logo-mail``` or embedded as a CID source ```logo-mail```. 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. +## Grouped Emails + +The `notifications` service also supports grouped emails on a daily and weekly basis. The event IDs are stored using the `Store` interface. To send the grouped emails the CLI command `notifications send-email` has to be used. + +### 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/config/config.go b/services/notifications/pkg/config/config.go index 9dd24773b2..08f41f260e 100644 --- a/services/notifications/pkg/config/config.go +++ b/services/notifications/pkg/config/config.go @@ -69,12 +69,6 @@ type ServiceAccount struct { ServiceAccountSecret string `yaml:"service_account_secret" env:"OCIS_SERVICE_ACCOUNT_SECRET;NOTIFICATIONS_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"5.0"` } -// TODO: -// - README -// - DOCS -// - introductionVersion correct? -// - is TTL mandatory? - // 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"` From 8e89a3495891f318cebb6f258ef795ea6a33e630 Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Thu, 9 Jan 2025 16:01:48 +0100 Subject: [PATCH 09/11] fix: Missing return statement after error on getting service user context --- services/notifications/pkg/email/templates.go | 1 + services/notifications/pkg/service/shares.go | 2 ++ services/notifications/pkg/service/spaces.go | 2 ++ services/notifications/pkg/service/splitter.go | 5 ++--- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index 85501a9a97..c0d940f3a1 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -149,6 +149,7 @@ type MessageTemplate struct { CallToAction string } +// GroupedMessageTemplate is the data structure for the email type GroupedMessageTemplate struct { // textTemplate represent the path to text plain .tmpl file textTemplate string diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go index 2799e9ca57..455fa74e28 100644 --- a/services/notifications/pkg/service/shares.go +++ b/services/notifications/pkg/service/shares.go @@ -58,6 +58,7 @@ func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.Shar 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 } resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) @@ -145,6 +146,7 @@ func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.Shar 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 } resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go index 9c9a7cf140..8550bd05b9 100644 --- a/services/notifications/pkg/service/spaces.go +++ b/services/notifications/pkg/service/spaces.go @@ -57,6 +57,7 @@ func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.Space 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, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient) @@ -141,6 +142,7 @@ func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.Spa 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, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient) diff --git a/services/notifications/pkg/service/splitter.go b/services/notifications/pkg/service/splitter.go index e2f080ba3c..866e1860f3 100644 --- a/services/notifications/pkg/service/splitter.go +++ b/services/notifications/pkg/service/splitter.go @@ -12,9 +12,8 @@ import ( ) type intervalSplitter struct { - log log.Logger - valueClient settingssvc.ValueService - persistenceService userEventStore + log log.Logger + valueClient settingssvc.ValueService } func newIntervalSplitter(l log.Logger, vc settingssvc.ValueService) *intervalSplitter { From 4b764535c9a99f531953d59e07457cfd3feeb3de Mon Sep 17 00:00:00 2001 From: Bastian Beier Date: Thu, 9 Jan 2025 17:13:57 +0100 Subject: [PATCH 10/11] refactor: Make sonar happy --- services/notifications/pkg/email/composer.go | 4 +-- services/notifications/pkg/email/email.go | 2 +- services/notifications/pkg/service/shares.go | 20 +++++++------- services/notifications/pkg/service/spaces.go | 28 ++++++++++---------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go index 8bea2bd75e..73ff67fe90 100644 --- a/services/notifications/pkg/email/composer.go +++ b/services/notifications/pkg/email/composer.go @@ -79,7 +79,7 @@ func NewGroupedTextTemplate(gmt GroupedMessageTemplate, vars map[string]string, return gmt, err } - var bodyParts []string + bodyParts := make([]string, 0, len(mtsVars)) for i, mt := range mts { bodyPart, err := composeMessage(t.Get(mt.MessageBody), mtsVars[i]) if err != nil { @@ -108,7 +108,7 @@ func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, return gmt, err } - var bodyParts []string + bodyParts := make([]string, 0, len(mtsVars)) for i, mt := range mts { bodyPart, err := composeMessage(t.Get(mt.MessageBody), mtsVars[i]) if err != nil { diff --git a/services/notifications/pkg/email/email.go b/services/notifications/pkg/email/email.go index 7fa259bdd3..968fa78de5 100644 --- a/services/notifications/pkg/email/email.go +++ b/services/notifications/pkg/email/email.go @@ -82,7 +82,7 @@ func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]stri return nil, err } - var escapedMtsVars []map[string]string + escapedMtsVars := make([]map[string]string, 0, len(mtsVars)) for _, m := range mtsVars { escapedMtsVars = append(escapedMtsVars, escapeStringMap(m)) } diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go index 455fa74e28..1c3b53c0fb 100644 --- a/services/notifications/pkg/service/shares.go +++ b/services/notifications/pkg/service/shares.go @@ -52,13 +52,13 @@ func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.Shar gatewayClient, err := s.gatewaySelector.Next() if err != nil { logger.Error().Err(err).Msg("could not select next gateway client") - return + 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 + return owner, shareFolder, shareLink, ctx, err } resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) @@ -66,7 +66,7 @@ func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.Shar logger.Error(). Err(err). Msg("could not stat resource") - return + return owner, shareFolder, shareLink, ctx, err } shareFolder = resourceInfo.Name @@ -75,7 +75,7 @@ func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.Shar logger.Error(). Err(err). Msg("could not create link to the share") - return + return owner, shareFolder, shareLink, ctx, err } owner, err = utils.GetUserWithContext(ctx, e.Sharer, gatewayClient) @@ -83,10 +83,10 @@ func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.Shar logger.Error(). Err(err). Msg("could not get user") - return + return owner, shareFolder, shareLink, ctx, err } - return + return owner, shareFolder, shareLink, ctx, err } func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) { @@ -140,13 +140,13 @@ func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.Shar gatewayClient, err := s.gatewaySelector.Next() if err != nil { logger.Error().Err(err).Msg("could not select next gateway client") - return + 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 + return shareFolder, ctx, err } resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) @@ -154,9 +154,9 @@ func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.Shar logger.Error(). Err(err). Msg("could not stat resource") - return + return shareFolder, ctx, err } shareFolder = resourceInfo.GetName() - return + return shareFolder, ctx, err } diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go index 8550bd05b9..8607acbafd 100644 --- a/services/notifications/pkg/service/spaces.go +++ b/services/notifications/pkg/service/spaces.go @@ -51,13 +51,13 @@ func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.Space 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.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret) if err != nil { logger.Error().Err(err).Msg("could not get service user context") - return + return executant, spaceName, shareLink, ctx, err } executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient) @@ -65,7 +65,7 @@ func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.Space logger.Error(). Err(err). Msg("could not get user") - return + return executant, spaceName, shareLink, ctx, err } resourceID, err := storagespace.ParseID(e.ID.OpaqueId) @@ -73,7 +73,7 @@ func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.Space 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) @@ -81,7 +81,7 @@ func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.Space logger.Error(). Err(err). Msg("could not get space info") - return + return executant, spaceName, shareLink, ctx, err } spaceName = resourceInfo.GetSpace().GetName() @@ -90,9 +90,9 @@ func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.Space logger.Error(). Err(err). Msg("could not create link to the share") - return + return executant, spaceName, shareLink, ctx, err } - return + return executant, spaceName, shareLink, ctx, err } func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) { @@ -136,13 +136,13 @@ func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.Spa 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.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret) if err != nil { logger.Error().Err(err).Msg("could not get service user context") - return + return executant, spaceName, shareLink, ctx, err } executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient) @@ -150,7 +150,7 @@ func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.Spa logger.Error(). Err(err). Msg("could not get user") - return + return executant, spaceName, shareLink, ctx, err } resourceID, err := storagespace.ParseID(e.ID.OpaqueId) @@ -158,7 +158,7 @@ func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.Spa 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) @@ -166,7 +166,7 @@ func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.Spa logger.Error(). Err(err). Msg("could not get space info") - return + return executant, spaceName, shareLink, ctx, err } spaceName = resourceInfo.GetSpace().GetName() @@ -175,9 +175,9 @@ func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.Spa logger.Error(). Err(err). Msg("could not create link to the share") - return + return executant, spaceName, shareLink, ctx, err } - return + return executant, spaceName, shareLink, ctx, err } func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired, eventId string) { From 34e74e4554333324b9bca08aecbf2255d14f8ce3 Mon Sep 17 00:00:00 2001 From: Bastian Beier <34065050+bastianbeier@users.noreply.github.com> Date: Thu, 9 Jan 2025 17:40:53 +0100 Subject: [PATCH 11/11] docs: Improve readme for grouped email notifications Co-authored-by: Martin --- services/notifications/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/notifications/README.md b/services/notifications/README.md index c3b912baf7..af93508e05 100644 --- a/services/notifications/README.md +++ b/services/notifications/README.md @@ -46,9 +46,15 @@ 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 ```logo-mail``` or embedded as a CID source ```logo-mail```. 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. -## Grouped Emails +## Sending Grouped Emails -The `notifications` service also supports grouped emails on a daily and weekly basis. The event IDs are stored using the `Store` interface. To send the grouped emails the CLI command `notifications send-email` has to be used. +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