Merge pull request #10838 from bastianbeier/issue-10793

Grouped email notifications
This commit is contained in:
Bastian Beier authored and GitHub committed 2025-01-09 18:12:27 +01:00
commit 1cd64a1d43
22 files changed
+1088 -169

No files matched your search

@@ -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
+29
View File
@@ -46,6 +46,35 @@ Custom email templates referenced via `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` must a
The `templates/html` subfolder contains a default HTML template provided by ocis. When using a custom HTML template, hosted images can either be linked with standard HTML code like ```<img src="https://raw.githubusercontent.com/owncloud/core/master/core/img/logo-mail.gif" alt="logo-mail"/>``` or embedded as a CID source ```<img src="cid:logo-mail.gif" alt="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.
## Sending Grouped Emails
The `notification` service can initiate sending emails based on events stored in the configured store that are grouped into a `daily` or `weekly` bucket. These groups contain events that get populated e.g. when the user configures `daily` or `weekly` email notifications in his personal settings in the web UI. If a user does not define any of the named groups for notification events, no event is stored.
Grouped events are stored for the TTL defined in `OCIS_PERSISTENT_STORE_TTL`. This TTL can either be configured globally or individually for the notification service via the `NOTIFICATIONS_STORE_TTL` envvar.
Grouped events that have passed the TTL are removed automatically without further notice or sending!
To initiate sending grouped emails like via a cron job, use the `ocis notifications send-email` command. Note that the command mandatory requires at least one option which is `--daily` or `--weekly`. Note that both options can be used together.
### Storing
The `notifications` service persists information via the configured store in `NOTIFICATIONS_STORE`. Possible stores are:
- `memory`: Basic in-memory store. Will not survive a restart. This is not recommended for this service.
- `redis-sentinel`: Stores data in a configured Redis Sentinel cluster.
- `nats-js-kv`: Stores data using key-value-store feature of [nats jetstream](https://docs.nats.io/nats-concepts/jetstream/key-value-store). This is the default value.
- `noop`: Stores nothing. Useful for testing. Not recommended in production environments.
Other store types may work but are not supported currently.
Note: The service can only be scaled if not using `memory` store and the stores are configured identically over all instances!
Note that if you have used one of the deprecated stores, you should reconfigure to one of the supported ones as the deprecated stores will be removed in a later version.
Store specific notes:
- When using `redis-sentinel`, the Redis master to use is configured via e.g. `OCIS_CACHE_STORE_NODES` in the form of `<sentinel-host>:<sentinel-port>/<redis-master>` 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.
@@ -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),
@@ -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
},
}
}
+26 -1
View File
@@ -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()
@@ -3,6 +3,7 @@ package config
import (
"context"
"time"
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
)
@@ -24,6 +25,8 @@ type Config struct {
ServiceAccount ServiceAccount `yaml:"service_account"`
Context context.Context `yaml:"-"`
Store Store `yaml:"store"`
}
// Notifications defines the config options for the notifications service.
@@ -65,3 +68,14 @@ type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OCIS_SERVICE_ACCOUNT_ID;NOTIFICATIONS_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"5.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OCIS_SERVICE_ACCOUNT_SECRET;NOTIFICATIONS_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"5.0"`
}
// Store configures the store to use
type Store struct {
Store string `yaml:"store" env:"OCIS_PERSISTENT_STORE;NOTIFICATIONS_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"7.1"`
Nodes []string `yaml:"nodes" env:"OCIS_PERSISTENT_STORE_NODES;NOTIFICATIONS_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"7.1"`
Database string `yaml:"database" env:"NOTIFICATIONS_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"7.1"`
Table string `yaml:"table" env:"NOTIFICATIONS_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"7.1"`
TTL time.Duration `yaml:"ttl" env:"OCIS_PERSISTENT_STORE_TTL;NOTIFICATIONS_STORE_TTL" desc:"Time to live for notifications in the store. Defaults to '336h' (2 weeks). See the Environment Variable Types description for more details." introductionVersion:"7.1"`
AuthUsername string `yaml:"username" env:"OCIS_PERSISTENT_STORE_AUTH_USERNAME;NOTIFICATIONS_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"7.1"`
AuthPassword string `yaml:"password" env:"OCIS_PERSISTENT_STORE_AUTH_PASSWORD;NOTIFICATIONS_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"7.1"`
}
@@ -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,
},
}
}
@@ -3,6 +3,7 @@ package email
import (
"bytes"
"embed"
"github.com/pkg/errors"
"strings"
"text/template"
@@ -61,6 +62,65 @@ func NewHTMLTemplate(mt MessageTemplate, locale, defaultLocale string, translati
return mt, nil
}
// NewGroupedTextTemplate replace the body message template placeholders with the translated template
func NewGroupedTextTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (GroupedMessageTemplate, error) {
if len(mts) != len(mtsVars) {
return gmt, errors.New("number of templates does not match number of variables")
}
var err error
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
gmt.Subject, err = composeMessage(t.Get(gmt.Subject), vars)
if err != nil {
return gmt, err
}
gmt.Greeting, err = composeMessage(t.Get(gmt.Greeting), vars)
if err != nil {
return gmt, err
}
bodyParts := make([]string, 0, len(mtsVars))
for i, mt := range mts {
bodyPart, err := composeMessage(t.Get(mt.MessageBody), mtsVars[i])
if err != nil {
return gmt, err
}
bodyParts = append(bodyParts, bodyPart)
}
gmt.MessageBody = strings.Join(bodyParts, "\n\n\n")
return gmt, nil
}
// NewGroupedHTMLTemplate replace the body message template placeholders with the translated template
func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (GroupedMessageTemplate, error) {
if len(mts) != len(mtsVars) {
return gmt, errors.New("number of templates does not match number of variables")
}
var err error
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale)
gmt.Subject, err = composeMessage(t.Get(gmt.Subject), vars)
if err != nil {
return gmt, err
}
gmt.Greeting, err = composeMessage(newlineToBr(t.Get(gmt.Greeting)), vars)
if err != nil {
return gmt, err
}
bodyParts := make([]string, 0, len(mtsVars))
for i, mt := range mts {
bodyPart, err := composeMessage(t.Get(mt.MessageBody), mtsVars[i])
if err != nil {
return gmt, err
}
bodyParts = append(bodyParts, bodyPart)
}
gmt.MessageBody = strings.Join(bodyParts, "<br><br><br>")
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))
+59
View File
@@ -67,6 +67,53 @@ func RenderEmailTemplate(mt MessageTemplate, locale, defaultLocale string, email
}, nil
}
// RenderGroupedEmailTemplate is responsible to prepare a message which than can be used to notify the user via email.
func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, emailTemplatePath string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (*channels.Message, error) {
textMt, err := NewGroupedTextTemplate(gmt, vars, locale, defaultLocale, translationPath, mts, mtsVars)
if err != nil {
return nil, err
}
tpl, err := parseTemplate(emailTemplatePath, gmt.textTemplate)
if err != nil {
return nil, err
}
textBody, err := groupedEmailTemplate(tpl, textMt)
if err != nil {
return nil, err
}
escapedMtsVars := make([]map[string]string, 0, len(mtsVars))
for _, m := range mtsVars {
escapedMtsVars = append(escapedMtsVars, escapeStringMap(m))
}
htmlMt, err := NewGroupedHTMLTemplate(gmt, escapeStringMap(vars), locale, defaultLocale, translationPath, mts, escapedMtsVars)
if err != nil {
return nil, err
}
htmlTpl, err := parseTemplate(emailTemplatePath, gmt.htmlTemplate)
if err != nil {
return nil, err
}
htmlBody, err := groupedEmailTemplate(htmlTpl, htmlMt)
if err != nil {
return nil, err
}
var data map[string][]byte
if emailTemplatePath != "" {
data, err = readImages(emailTemplatePath)
if err != nil {
return nil, err
}
}
return &channels.Message{
Subject: textMt.Subject,
TextBody: textBody,
HTMLBody: htmlBody,
AttachInline: data,
}, nil
}
// emailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]interface{}{
@@ -80,6 +127,18 @@ func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) {
return str, err
}
// groupedEmailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func groupedEmailTemplate(tpl *template.Template, gmt GroupedMessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]interface{}{
"Greeting": template.HTML(strings.TrimSpace(gmt.Greeting)), // #nosec G203
"MessageBody": template.HTML(strings.TrimSpace(gmt.MessageBody)), // #nosec G203
})
if err != nil {
return "", err
}
return str, err
}
func parseTemplate(emailTemplatePath string, file string) (*template.Template, error) {
if emailTemplatePath != "" {
return template.ParseFiles(filepath.Join(emailTemplatePath, file))
+42 -14
View File
@@ -2,12 +2,17 @@ package email
import "github.com/owncloud/ocis/v2/ocis-pkg/l10n"
const (
_textTemplate = "templates/text/email.text.tmpl"
_htmlTemplate = "templates/html/email.html.tmpl"
)
// the available templates
var (
// Shares
ShareCreated = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ShareCreated email template, Subject field (resolves directly)
Subject: l10n.Template(`{ShareSharer} shared '{ShareFolder}' with you`),
// ShareCreated email template, resolves via {{ .Greeting }}
@@ -19,8 +24,8 @@ var (
}
ShareExpired = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ShareExpired email template, Subject field (resolves directly)
Subject: l10n.Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`),
// ShareExpired email template, resolves via {{ .Greeting }}
@@ -33,8 +38,8 @@ Even though this share has been revoked you still might have access through othe
// Spaces templates
SharedSpace = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// SharedSpace email template, Subject field (resolves directly)
Subject: l10n.Template("{SpaceSharer} invited you to join {SpaceName}"),
// SharedSpace email template, resolves via {{ .Greeting }}
@@ -46,8 +51,8 @@ Even though this share has been revoked you still might have access through othe
}
UnsharedSpace = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// UnsharedSpace email template, Subject field (resolves directly)
Subject: l10n.Template(`{SpaceSharer} removed you from {SpaceName}`),
// UnsharedSpace email template, resolves via {{ .Greeting }}
@@ -61,8 +66,8 @@ You might still have access through your other groups or direct membership.`),
}
MembershipExpired = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// MembershipExpired email template, Subject field (resolves directly)
Subject: l10n.Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`),
// MembershipExpired email template, resolves via {{ .Greeting }}
@@ -74,8 +79,8 @@ Even though this membership has expired you still might have access through othe
}
ScienceMeshInviteTokenGenerated = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ScienceMeshInviteTokenGenerated email template, Subject field (resolves directly)
Subject: l10n.Template(`ScienceMesh: {InitiatorName} wants to collaborate with you`),
// ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting }}
@@ -91,8 +96,8 @@ Alternatively, you can visit your federation settings and use the following deta
}
ScienceMeshInviteTokenGeneratedWithoutShareLink = MessageTemplate{
textTemplate: "templates/text/email.text.tmpl",
htmlTemplate: "templates/html/email.html.tmpl",
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject field (resolves directly)
Subject: l10n.Template(`ScienceMesh: {InitiatorName} wants to collaborate with you`),
// ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves via {{ .Greeting }}
@@ -103,6 +108,16 @@ Please visit your federation settings and use the following details:
Token: {Token}
ProviderDomain: {ProviderDomain}`),
}
Grouped = GroupedMessageTemplate{
textTemplate: _textTemplate,
htmlTemplate: _htmlTemplate,
// Grouped email template, Subject field (resolves directly)
Subject: l10n.Template(`Report`), // TODO find meaningful subject
// Grouped email template, resolves via {{ .Greeting }}
Greeting: l10n.Template(`Hi {DisplayName},`),
MessageBody: "", // is generated using the GroupedTemplates
}
)
// holds the information to turn the raw template into a parseable go template
@@ -118,6 +133,7 @@ var _placeholders = map[string]string{
"{ShareSharerMail}": "{{ .ShareSharerMail }}",
"{ProviderDomain}": "{{ .ProviderDomain }}",
"{Token}": "{{ .Token }}",
"{DisplayName}": "{{ .DisplayName }}",
}
// MessageTemplate is the data structure for the email
@@ -132,3 +148,15 @@ type MessageTemplate struct {
MessageBody string
CallToAction string
}
// GroupedMessageTemplate is the data structure for the email
type GroupedMessageTemplate struct {
// textTemplate represent the path to text plain .tmpl file
textTemplate string
// htmlTemplate represent the path to html .tmpl file
htmlTemplate string
// The fields below represent the placeholders for the translatable templates
Subject string
Greeting string
MessageBody string
}
@@ -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 {
@@ -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)},
}
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"context"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
ehmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/eventhistory/v0"
"github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
"github.com/rs/zerolog"
)
func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEvent, eventId string) {
logger := s.logger.With().
Str("event", "SendEmailsEvent").
Str("eventId", eventId).
Logger()
if sendEmailsEvent.Interval != _intervalDaily && sendEmailsEvent.Interval != _intervalWeekly {
logger.Error().Str("interval", sendEmailsEvent.Interval).Msg("unsupported email sending interval")
return
}
keys, err := s.userEventStore.listKeys(sendEmailsEvent.Interval)
if err != nil {
logger.Error().Err(err).Msg("could not get list of keys")
return
}
ctx := context.Background()
jobs := make(chan string, 10)
go func() {
for _, key := range keys {
jobs <- key
}
close(jobs)
}()
for job := range jobs {
go s.createGroupedMail(ctx, logger, job)
}
}
func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Logger, key string) {
userEvents, err := s.userEventStore.pop(ctx, key)
if err != nil {
logger.Error().Err(err).Str("key", key).Msg("could not pop user events")
return
}
var mts []email.MessageTemplate
var mtsVars []map[string]string
locale := l10n.MustGetUserLocale(ctx, userEvents.User.GetId().GetOpaqueId(), "", s.valueService)
for _, e := range userEvents.Events {
switch te := s.unwrapEvent(logger, e).(type) {
case events.SpaceShared:
logger := logger.With().
Str("event", "SpaceShared").
Str("eventId", te.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, _, err := s.prepareSpaceShared(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.SharedSpace)
mtsVars = append(mtsVars, map[string]string{
"SpaceSharer": executant.GetDisplayName(),
"SpaceName": spaceName,
"ShareLink": shareLink,
})
case events.SpaceUnshared:
logger := logger.With().
Str("event", "SpaceUnshared").
Str("eventId", te.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, _, err := s.prepareSpaceUnshared(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.UnsharedSpace)
mtsVars = append(mtsVars, map[string]string{
"SpaceSharer": executant.GetDisplayName(),
"SpaceName": spaceName,
"ShareLink": shareLink,
})
case events.SpaceMembershipExpired:
mts = append(mts, email.MembershipExpired)
mtsVars = append(mtsVars, map[string]string{
"SpaceName": te.SpaceName,
"ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
})
case events.ShareCreated:
logger := logger.With().
Str("event", "ShareCreated").
Str("eventId", te.ItemID.OpaqueId).
Logger()
owner, shareFolder, shareLink, _, err := s.prepareShareCreated(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.ShareCreated)
mtsVars = append(mtsVars, map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareFolder": shareFolder,
"ShareLink": shareLink,
})
case events.ShareExpired:
logger := logger.With().
Str("event", "ShareCreated").
Str("eventId", te.ItemID.OpaqueId).
Logger()
shareFolder, _, err := s.prepareShareExpired(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.ShareExpired)
mtsVars = append(mtsVars, map[string]string{
"ShareFolder": shareFolder,
"ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
})
}
}
rendered, err := email.RenderGroupedEmailTemplate(email.Grouped, map[string]string{
"DisplayName": userEvents.User.GetDisplayName(),
}, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, mts, mtsVars)
if err != nil {
logger.Error().Err(err).Msg("could not render template")
return
}
rendered.Sender = s.defaultEmailSender
rendered.Recipient = []string{userEvents.User.GetMail()}
s.send(ctx, []*channels.Message{rendered})
}
func (s eventsNotifier) unwrapEvent(logger zerolog.Logger, e *ehmsg.Event) any {
etype, ok := s.registeredEvents[e.GetType()]
if !ok {
logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("event not registered")
return nil
}
ue, err := etype.Unmarshal(e.GetEvent())
if err != nil {
logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("failed to umarshal event")
return nil
}
return ue
}
@@ -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
}
@@ -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
}
+23 -8
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
"go-micro.dev/v4/store"
"net/url"
"os"
"os/signal"
@@ -50,7 +52,10 @@ func NewEventsNotifier(
logger log.Logger,
gatewaySelector pool.Selectable[gateway.GatewayAPIClient],
valueService settingssvc.ValueService,
serviceAccountID, serviceAccountSecret, emailTemplatePath, defaultLanguage, ocisURL, translationPath string) Service {
serviceAccountID, serviceAccountSecret, emailTemplatePath, defaultLanguage, ocisURL, translationPath, emailSender string,
store store.Store,
historyClient ehsvc.EventHistoryService,
registeredEvents map[string]events.Unmarshaller) Service {
return eventsNotifier{
logger: logger,
@@ -63,9 +68,13 @@ func NewEventsNotifier(
serviceAccountSecret: serviceAccountSecret,
emailTemplatePath: emailTemplatePath,
defaultLanguage: defaultLanguage,
defaultEmailSender: emailSender,
ocisURL: ocisURL,
translationPath: translationPath,
filter: newNotificationFilter(logger, valueService),
splitter: newIntervalSplitter(logger, valueService),
userEventStore: newUserEventStore(logger, store, historyClient),
registeredEvents: registeredEvents,
}
}
@@ -79,10 +88,14 @@ type eventsNotifier struct {
emailTemplatePath string
translationPath string
defaultLanguage string
defaultEmailSender string
ocisURL string
serviceAccountID string
serviceAccountSecret string
filter *notificationFilter
splitter *intervalSplitter
userEventStore *userEventStore
registeredEvents map[string]events.Unmarshaller
}
func (s eventsNotifier) Run() error {
@@ -95,17 +108,19 @@ func (s eventsNotifier) Run() error {
go func() {
switch e := evt.Event.(type) {
case events.SpaceShared:
s.handleSpaceShared(e)
s.handleSpaceShared(e, evt.ID)
case events.SpaceUnshared:
s.handleSpaceUnshared(e)
s.handleSpaceUnshared(e, evt.ID)
case events.SpaceMembershipExpired:
s.handleSpaceMembershipExpired(e)
s.handleSpaceMembershipExpired(e, evt.ID)
case events.ShareCreated:
s.handleShareCreated(e)
s.handleShareCreated(e, evt.ID)
case events.ShareExpired:
s.handleShareExpired(e)
s.handleShareExpired(e, evt.ID)
case events.ScienceMeshInviteTokenGenerated:
s.handleScienceMeshInviteTokenGenerated(e)
case events.SendEmailsEvent:
s.sendGroupedEmailsJob(e, evt.ID)
}
}()
case <-s.signals:
@@ -135,8 +150,8 @@ func (s eventsNotifier) render(ctx context.Context, template email.MessageTempla
return messageList, nil
}
func (s eventsNotifier) send(ctx context.Context, recipientList []*channels.Message) {
for _, r := range recipientList {
func (s eventsNotifier) send(ctx context.Context, emails []*channels.Message) {
for _, r := range emails {
err := s.channel.SendMessage(ctx, r)
if err != nil {
s.logger.Error().Err(err).Str("event", "SendEmail").Msg("failed to send a message")
@@ -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
+90 -49
View File
@@ -1,75 +1,95 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
"github.com/rs/zerolog"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
func (s eventsNotifier) handleShareCreated(e events.ShareCreated) {
func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string) {
logger := s.logger.With().
Str("event", "ShareCreated").
Str("itemid", e.ItemID.OpaqueId).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
owner, shareFolder, shareLink, ctx, err := s.prepareShareCreated(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("Could not impersonate service user")
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 zerolog.Logger, e events.ShareCreated) (owner *user.User, shareFolder, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return owner, shareFolder, shareLink, ctx, err
}
ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return owner, shareFolder, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().
Err(err).
Msg("could not stat resource")
return
return owner, shareFolder, shareLink, ctx, err
}
shareFolder = resourceInfo.Name
shareLink, err := urlJoinPath(s.ocisURL, "files/shares/with-me")
shareLink, err = urlJoinPath(s.ocisURL, "files/shares/with-me")
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return
return owner, shareFolder, shareLink, ctx, err
}
owner, err := utils.GetUser(e.Sharer, gatewayClient)
owner, err = utils.GetUserWithContext(ctx, e.Sharer, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("Could not get user")
return
logger.Error().
Err(err).
Msg("could not get user")
return owner, shareFolder, shareLink, ctx, err
}
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 owner, shareFolder, shareLink, ctx, err
}
func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) {
logger := s.logger.With().
Str("event", "ShareExpired").
Str("itemid", e.ItemID.GetOpaqueId()).
@@ -81,21 +101,13 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
return
}
ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
shareFolder, ctx, err := s.prepareShareExpired(logger, e)
if err != nil {
logger.Error().Err(err).Msg("Could not impersonate sharer")
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().
Err(err).
Msg("could not stat resource")
return
}
owner, err := utils.GetUser(e.ShareOwner, gatewayClient)
owner, err := utils.GetUserWithContext(ctx, e.ShareOwner, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("Could not get user")
return
@@ -103,19 +115,48 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareExpired)
if filteredGrantees == nil {
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
recipientList, err := s.render(ctx, email.ShareExpired,
emails, err := s.render(ctx, email.ShareExpired,
"ShareGrantee",
map[string]string{
"ShareFolder": resourceInfo.GetName(),
"ShareFolder": shareFolder,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
}, filteredGrantees, owner.GetDisplayName())
}, recipientsInstant, owner.GetDisplayName())
if err != nil {
s.logger.Error().Err(err).Str("event", "ShareExpired").Msg("could not get render the email")
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, recipientList)
s.send(ctx, emails)
}
func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.ShareExpired) (shareFolder string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return shareFolder, ctx, err
}
ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return shareFolder, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().
Err(err).
Msg("could not stat resource")
return shareFolder, ctx, err
}
shareFolder = resourceInfo.GetName()
return shareFolder, ctx, err
}
+115 -89
View File
@@ -1,31 +1,71 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/storagespace"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
"github.com/rs/zerolog"
)
func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceShared").
Str("itemid", e.ID.OpaqueId).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
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)
granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceShared)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
emails, err := s.render(ctx, email.SharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": spaceName,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.SpaceShared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return 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 executant, spaceName, shareLink, ctx, err
}
executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not handle space shared event")
return
Msg("could not get user")
return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
@@ -33,7 +73,7 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
logger.Error().
Err(err).
Msg("could not parse resourceid from ItemID ")
return
return executant, spaceName, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
@@ -41,73 +81,84 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
logger.Error().
Err(err).
Msg("could not get space info")
return
return executant, spaceName, shareLink, ctx, err
}
spaceName = resourceInfo.GetSpace().GetName()
shareLink, err := urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
shareLink, err = urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return
return executant, spaceName, shareLink, ctx, err
}
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 {
return
}
sharerDisplayName := executant.GetDisplayName()
recipientList, err := s.render(ctx, email.SharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": resourceInfo.GetSpace().GetName(),
"ShareLink": shareLink,
}, filteredGrantees, sharerDisplayName)
if err != nil {
s.logger.Error().Err(err).Str("event", "SharedSpace").Msg("could not get render the email")
return
}
s.send(ctx, recipientList)
return executant, spaceName, shareLink, ctx, err
}
func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) {
func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceUnshared").
Str("itemid", e.ID.OpaqueId).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
executant, spaceName, shareLink, ctx, err := s.prepareSpaceUnshared(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 unshared event")
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 zerolog.Logger, e events.SpaceUnshared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return 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 executant, spaceName, shareLink, ctx, err
}
executant, err = utils.GetUserWithContext(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
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)
@@ -115,50 +166,21 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) {
logger.Error().
Err(err).
Msg("could not get space info")
return
return executant, spaceName, shareLink, ctx, err
}
spaceName = resourceInfo.GetSpace().GetName()
shareLink, err := urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
shareLink, err = urlJoinPath(s.ocisURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return
return executant, spaceName, shareLink, ctx, err
}
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.SettingUUIDProfileEventSpaceUnshared)
if filteredGrantees == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
recipientList, err := s.render(ctx, email.UnsharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": resourceInfo.GetSpace().Name,
"ShareLink": shareLink,
}, filteredGrantees, sharerDisplayName)
if err != nil {
s.logger.Error().Err(err).Str("event", "UnsharedSpace").Msg("Could not get render the email")
return
}
s.send(ctx, recipientList)
return executant, spaceName, shareLink, ctx, err
}
func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired) {
func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired, eventId string) {
logger := s.logger.With().
Str("event", "SpaceMembershipExpired").
Str("itemid", e.SpaceID.GetOpaqueId()).
@@ -189,19 +211,23 @@ func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExp
return
}
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceMembershipExpired)
if filteredGrantees == nil {
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
recipientList, err := s.render(ctx, email.MembershipExpired,
emails, err := s.render(ctx, email.MembershipExpired,
"SpaceGrantee",
map[string]string{
"SpaceName": e.SpaceName,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
}, filteredGrantees, owner.GetDisplayName())
}, recipientsInstant, owner.GetDisplayName())
if err != nil {
s.logger.Error().Err(err).Str("event", "SpaceUnshared").Msg("could not get render the email")
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, recipientList)
s.send(ctx, emails)
}
@@ -0,0 +1,60 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
"github.com/pkg/errors"
micrometadata "go-micro.dev/v4/metadata"
)
type intervalSplitter struct {
log log.Logger
valueClient settingssvc.ValueService
}
func newIntervalSplitter(l log.Logger, vc settingssvc.ValueService) *intervalSplitter {
return &intervalSplitter{log: l, valueClient: vc}
}
// execute splits users into 3 lists depending on their email sending interval settings
func (s intervalSplitter) execute(ctx context.Context, users []*user.User) (instant, daily, weekly []*user.User) {
for _, u := range users {
userId := u.GetId().GetOpaqueId()
interval, err := getEmailSendingInterval(ctx, s.valueClient, userId)
if err != nil {
s.log.Error().Err(err).Str("userId", userId).Msg("cannot get user email sending interval")
instant = append(instant, u)
} else if interval == "instant" {
instant = append(instant, u)
} else if interval == _intervalDaily {
daily = append(daily, u)
} else if interval == _intervalWeekly {
weekly = append(weekly, u)
}
}
return
}
func getEmailSendingInterval(ctx context.Context, vc settingssvc.ValueService, userId string) (string, error) {
resp, err := vc.GetValueByUniqueIdentifiers(
micrometadata.Set(ctx, middleware.AccountID, userId),
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userId,
SettingId: defaults.SettingUUIDProfileEmailSendingInterval,
},
)
if err != nil {
return "", err
}
val := resp.GetValue().GetValue().GetStringValue()
if val == "" {
return "", errors.New("email sending interval is empty")
}
return val, nil
}
@@ -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
}
@@ -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,
}
}