diff --git a/changelog/unreleased/add-email-templating.md b/changelog/unreleased/add-email-templating.md new file mode 100644 index 00000000000..3b7e5930d6b --- /dev/null +++ b/changelog/unreleased/add-email-templating.md @@ -0,0 +1,7 @@ +Enhancement: Add Email templating + +We have added email templating to ocis. Which are send on the SpaceShared and ShareCreated event. + +https://github.com/owncloud/ocis/pull/4564 +https://github.com/owncloud/ocis/issues/4303 +https://github.com/cs3org/reva/pull/3252 \ No newline at end of file diff --git a/services/audit/pkg/types/events.go b/services/audit/pkg/types/events.go index 2fe9d9aef6f..02c6dfa6694 100644 --- a/services/audit/pkg/types/events.go +++ b/services/audit/pkg/types/events.go @@ -29,6 +29,7 @@ func RegisteredEvents() []events.Unmarshaller { events.SpaceEnabled{}, events.SpaceDisabled{}, events.SpaceDeleted{}, + events.SpaceShared{}, events.UserCreated{}, events.UserDeleted{}, events.UserFeatureChanged{}, diff --git a/services/notifications/pkg/channels/channels.go b/services/notifications/pkg/channels/channels.go index 06129399800..a86fff60d9b 100644 --- a/services/notifications/pkg/channels/channels.go +++ b/services/notifications/pkg/channels/channels.go @@ -4,6 +4,7 @@ package channels import ( "context" "crypto/tls" + "fmt" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -19,9 +20,9 @@ import ( // Channel defines the methods of a communication channel. type Channel interface { // SendMessage sends a message to users. - SendMessage(userIDs []string, msg string) error + SendMessage(userIDs []string, msg, subject, senderDisplayName string) error // SendMessageToGroup sends a message to a group. - SendMessageToGroup(groupdID *groups.GroupId, msg string) error + SendMessageToGroup(groupdID *groups.GroupId, msg, subject, senderDisplayName string) error } // NewMailChannel instantiates a new mail communication channel. @@ -100,7 +101,7 @@ func (m Mail) getMailClient() (*mail.SMTPClient, error) { } // SendMessage sends a message to all given users. -func (m Mail) SendMessage(userIDs []string, msg string) error { +func (m Mail) SendMessage(userIDs []string, msg, subject, senderDisplayName string) error { if m.conf.Notifications.SMTP.Host == "" { return nil } @@ -116,14 +117,19 @@ func (m Mail) SendMessage(userIDs []string, msg string) error { } email := mail.NewMSG() - email.SetFrom(m.conf.Notifications.SMTP.Sender).AddTo(to...) + if senderDisplayName != "" { + email.SetFrom(fmt.Sprintf("%s via owncloud <%s>", senderDisplayName, m.conf.Notifications.SMTP.Sender)).AddTo(to...) + } else { + email.SetFrom(m.conf.Notifications.SMTP.Sender).AddTo(to...) + } email.SetBody(mail.TextPlain, msg) + email.SetSubject(subject) return email.Send(smtpClient) } // SendMessageToGroup sends a message to all members of the given group. -func (m Mail) SendMessageToGroup(groupID *groups.GroupId, msg string) error { +func (m Mail) SendMessageToGroup(groupID *groups.GroupId, msg, subject, senderDisplayName string) error { // TODO We need an authenticated context here... res, err := m.gatewayClient.GetGroup(context.Background(), &groups.GetGroupRequest{GroupId: groupID}) if err != nil { @@ -138,7 +144,7 @@ func (m Mail) SendMessageToGroup(groupID *groups.GroupId, msg string) error { members = append(members, id.OpaqueId) } - return m.SendMessage(members, msg) + return m.SendMessage(members, msg, subject, senderDisplayName) } func (m Mail) getReceiverAddresses(receivers []string) ([]string, error) { diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go index 8685b4e4291..5387cbbbe3d 100644 --- a/services/notifications/pkg/command/server.go +++ b/services/notifications/pkg/command/server.go @@ -5,6 +5,7 @@ import ( "github.com/cs3org/reva/v2/pkg/events" "github.com/cs3org/reva/v2/pkg/events/server" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/go-micro/plugins/v4/events/natsjs" "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" "github.com/owncloud/ocis/v2/services/notifications/pkg/channels" @@ -27,8 +28,10 @@ func Server(cfg *config.Config) *cli.Command { Action: func(c *cli.Context) error { logger := logging.Configure(cfg.Service.Name, cfg.Log) + // evs defines a list of events to subscribe to evs := []events.Unmarshaller{ events.ShareCreated{}, + events.SpaceShared{}, } evtsCfg := cfg.Notifications.Events @@ -47,7 +50,12 @@ func Server(cfg *config.Config) *cli.Command { if err != nil { return err } - svc := service.NewEventsNotifier(evts, channel, logger) + gwclient, err := pool.GetGatewayServiceClient(cfg.Notifications.RevaGateway) + if err != nil { + logger.Fatal().Err(err).Str("addr", cfg.Notifications.RevaGateway).Msg("could not get reva client") + } + + svc := service.NewEventsNotifier(evts, channel, logger, gwclient, cfg.Commons.MachineAuthAPIKey, cfg.Notifications.EmailTemplatePath) return svc.Run() }, } diff --git a/services/notifications/pkg/config/config.go b/services/notifications/pkg/config/config.go index cefd752b46e..e5a9f1344b5 100644 --- a/services/notifications/pkg/config/config.go +++ b/services/notifications/pkg/config/config.go @@ -26,6 +26,7 @@ type Notifications struct { Events Events `yaml:"events"` RevaGateway string `yaml:"reva_gateway" env:"REVA_GATEWAY;NOTIFICATIONS_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata"` MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;NOTIFICATIONS_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services."` + EmailTemplatePath string `yaml:"email_template_path" env:"OCIS_EMAIL_TEMPLATE_PATH;NOTIFICATIONS_EMAIL_TEMPLATE_PATH" desc:"Path to the E-Mail templates for the notifications to override the embedded ones."` } // SMTP combines the smtp configuration options. diff --git a/services/notifications/pkg/email/email.go b/services/notifications/pkg/email/email.go new file mode 100644 index 00000000000..d3302ccdf74 --- /dev/null +++ b/services/notifications/pkg/email/email.go @@ -0,0 +1,34 @@ +package email + +import ( + "bytes" + "embed" + "html/template" + "path/filepath" +) + +var ( + //go:embed templates + templatesFS embed.FS +) + +// RenderEmailTemplate renders the email template for a new share +func RenderEmailTemplate(templateName string, templateVariables map[string]string, emailTemplatePath string) (string, error) { + var err error + var tpl *template.Template + // try to lookup the files in the filesystem + tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, templateName)) + if err != nil { + // template has not been found in the fs, or path has not been specified => use embed templates + tpl, err = template.ParseFS(templatesFS, filepath.Join("templates/", templateName)) + if err != nil { + return "", err + } + } + var writer bytes.Buffer + err = tpl.Execute(&writer, templateVariables) + if err != nil { + return "", err + } + return writer.String(), nil +} diff --git a/services/notifications/pkg/email/templates/shareCreated.email.tmpl b/services/notifications/pkg/email/templates/shareCreated.email.tmpl new file mode 100644 index 00000000000..5c0b3fca0bf --- /dev/null +++ b/services/notifications/pkg/email/templates/shareCreated.email.tmpl @@ -0,0 +1,18 @@ +Hello {{ .ShareGrantee }}, + +{{ .ShareSharer }} has shared {{ .ShareFolder }} with you. + +Click here to view it: {{ .ShareLink }} + +---------------------------------------------------------- + +Hallo {{ .Grantee }}, + +{{ .ShareSharer }} hat dich zu {{ .ShareFolder }} eingeladen. + +Klicke hier zum Anzeigen: {{ .ShareLink }} + + +--- +ownCloud - Store. Share. Work. +https://owncloud.com \ No newline at end of file diff --git a/services/notifications/pkg/email/templates/sharedSpace.email.tmpl b/services/notifications/pkg/email/templates/sharedSpace.email.tmpl new file mode 100644 index 00000000000..16bef45f609 --- /dev/null +++ b/services/notifications/pkg/email/templates/sharedSpace.email.tmpl @@ -0,0 +1,18 @@ +Hello {{ .SpaceGrantee }}, + +{{ .SpaceSharer }} has invited you to join {{ .SpaceName }}. + +Click here to view it: {{ .ShareLink }} + +---------------------------------------------------------- + +Hallo {{ .SpaceGrantee }}, + +{{ .SpaceSharer }} hat dich in den Space {{ .SpaceName }} eingeladen. + +Klicke hier zum Anzeigen: {{ .ShareLink }} + + +--- +ownCloud - Store. Share. Work. +https://owncloud.com \ No newline at end of file diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go index d6c8f970517..d2f35e11774 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -1,33 +1,53 @@ package service import ( + "context" + "fmt" + "net/url" "os" "os/signal" + "path" "syscall" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/events" + "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/notifications/pkg/channels" + "github.com/owncloud/ocis/v2/services/notifications/pkg/email" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/fieldmaskpb" ) type Service interface { Run() error } -func NewEventsNotifier(events <-chan interface{}, channel channels.Channel, logger log.Logger) Service { +// NewEventsNotifier provides a new eventsNotifier +func NewEventsNotifier(events <-chan interface{}, channel channels.Channel, logger log.Logger, gwClient gateway.GatewayAPIClient, machineAuthAPIKey, emailTemplatePath string) Service { return eventsNotifier{ - logger: logger, - channel: channel, - events: events, - signals: make(chan os.Signal, 1), + logger: logger, + channel: channel, + events: events, + signals: make(chan os.Signal, 1), + gwClient: gwClient, + machineAuthAPIKey: machineAuthAPIKey, + emailTemplatePath: emailTemplatePath, } } type eventsNotifier struct { - logger log.Logger - channel channels.Channel - events <-chan interface{} - signals chan os.Signal + logger log.Logger + channel channels.Channel + events <-chan interface{} + signals chan os.Signal + gwClient gateway.GatewayAPIClient + machineAuthAPIKey string + emailTemplatePath string } func (s eventsNotifier) Run() error { @@ -39,20 +59,10 @@ func (s eventsNotifier) Run() error { case evt := <-s.events: go func() { switch e := evt.(type) { + case events.SpaceShared: + s.handleSpaceShared(e) case events.ShareCreated: - msg := "You got a share!" - var err error - if e.GranteeUserID != nil { - err = s.channel.SendMessage([]string{e.GranteeUserID.OpaqueId}, msg) - } else if e.GranteeGroupID != nil { - err = s.channel.SendMessageToGroup(e.GranteeGroupID, msg) - } - if err != nil { - s.logger.Error(). - Err(err). - Str("event", "ShareCreated"). - Msg("failed to send a message") - } + s.handleShareCreated(e) } }() case <-s.signals: @@ -62,3 +72,248 @@ func (s eventsNotifier) Run() error { } } } + +func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) { + sharerUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{ + UserId: e.Creator, + }) + if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Msg("Could not get user response from gatway client") + return + } + + granteeUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{ + UserId: e.GranteeUserID, + }) + if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Msg("Could not get user response from gatway client") + return + } + // Get auth context + ownerCtx := ctxpkg.ContextSetUser(context.Background(), sharerUserResponse.User) + authRes, err := s.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{ + Type: "machine", + ClientId: "userid:" + e.Executant.OpaqueId, + ClientSecret: s.machineAuthAPIKey, + }) + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Msg("Could not impersonate sharer") + return + } + + if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Msg("could not get authenticated context for user") + return + } + ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, ctxpkg.TokenHeader, authRes.Token) + + resourceID, err := storagespace.ParseID(e.ID.OpaqueId) + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Str("itemid", e.ID.OpaqueId). + Msg("could not parse resourceid from ItemID ") + return + } + // TODO: maybe cache this stat to reduce storage iops + md, err := s.gwClient.Stat(ownerCtx, &providerv1beta1.StatRequest{ + Ref: &providerv1beta1.Reference{ + ResourceId: &resourceID, + }, + // TODO: this filter needs to be implemented + //FieldMask: &fieldmaskpb.FieldMask{Paths: []string{"space.name"}}, + }) + + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Str("itemid", e.ID.OpaqueId). + Msg("could not stat resource") + return + } + + if md.Status.Code != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Str("itemid", e.ID.OpaqueId). + Str("rpc status", md.Status.Code.String()). + Msg("could not stat resource") + return + } + + shareLink, err := urlJoinPath(e.Executant.Idp, "files/spaces/projects", storagespace.FormatResourceID(*e.ID)) + + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("could not create link to the share") + return + } + + sharerDisplayName := sharerUserResponse.GetUser().DisplayName + msg, err := email.RenderEmailTemplate("sharedSpace.email.tmpl", map[string]string{ + "SpaceGrantee": granteeUserResponse.GetUser().DisplayName, + "SpaceSharer": sharerDisplayName, + "SpaceName": md.GetInfo().GetSpace().Name, + "ShareLink": shareLink, + }, s.emailTemplatePath) + + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Msg("Could not render E-Mail template for spaces") + } + + emailSubject := fmt.Sprintf("%s invited you to join %s", sharerUserResponse.GetUser().DisplayName, md.GetInfo().GetSpace().Name) + if e.GranteeUserID != nil { + err = s.channel.SendMessage([]string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName) + } else if e.GranteeGroupID != nil { + err = s.channel.SendMessageToGroup(e.GranteeGroupID, msg, emailSubject, sharerDisplayName) + } + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "SpaceCreated"). + Msg("failed to send a message") + } +} + +func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { + sharerUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{ + UserId: e.Sharer, + }) + if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("Could not get user response from gatway client") + return + } + + granteeUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{ + UserId: e.GranteeUserID, + }) + if err != nil || sharerUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("Could not get user response from gatway client") + return + } + + // Get auth context + ownerCtx := ctxpkg.ContextSetUser(context.Background(), sharerUserResponse.User) + authRes, err := s.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{ + Type: "machine", + ClientId: "userid:" + e.Sharer.OpaqueId, + ClientSecret: s.machineAuthAPIKey, + }) + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("Could not impersonate sharer") + return + } + + if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("could not get authenticated context for user") + return + } + ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, ctxpkg.TokenHeader, authRes.Token) + + // TODO: maybe cache this stat to reduce storage iops + md, err := s.gwClient.Stat(ownerCtx, &providerv1beta1.StatRequest{ + Ref: &providerv1beta1.Reference{ + ResourceId: e.ItemID, + }, + FieldMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}}, + }) + + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Str("itemid", e.ItemID.OpaqueId). + Msg("could not stat resource") + return + } + + if md.Status.Code != rpcv1beta1.Code_CODE_OK { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Str("itemid", e.ItemID.OpaqueId). + Str("rpc status", md.Status.Code.String()). + Msg("could not stat resource") + return + } + + shareLink, err := urlJoinPath(e.Executant.Idp, "files/shares/with-me") + + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("could not create link to the share") + return + } + + sharerDisplayName := sharerUserResponse.GetUser().DisplayName + msg, err := email.RenderEmailTemplate("shareCreated.email.tmpl", map[string]string{ + "ShareGrantee": granteeUserResponse.GetUser().DisplayName, + "ShareSharer": sharerDisplayName, + "ShareFolder": md.GetInfo().Name, + "ShareLink": shareLink, + }, s.emailTemplatePath) + + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("Could not render E-Mail template for shares") + } + + emailSubject := fmt.Sprintf("%s shared %s with you", sharerUserResponse.GetUser().DisplayName, md.GetInfo().Name) + if e.GranteeUserID != nil { + err = s.channel.SendMessage([]string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName) + } else if e.GranteeGroupID != nil { + err = s.channel.SendMessageToGroup(e.GranteeGroupID, msg, emailSubject, sharerDisplayName) + } + if err != nil { + s.logger.Error(). + Err(err). + Str("event", "ShareCreated"). + Msg("failed to send a message") + } +} + +// TODO: this function is a backport for go1.19 url.JoinPath, upon go bump, replace this +func urlJoinPath(base string, elements ...string) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", err + } + u.Path = path.Join(append([]string{u.Path}, elements...)...) + return u.String(), nil +}