diff --git a/services/notifications/README.md b/services/notifications/README.md index 23274230419..f1a3e99b1c0 100644 --- a/services/notifications/README.md +++ b/services/notifications/README.md @@ -4,16 +4,16 @@ The notification service is responsible for sending emails to users informing th ## Email Notification Templates -The `notifications` service has embedded email body templates. Email templates can use the placeholders `{{ .Greeting }}`, `{{ .MessageBody }}` and `{{ .CallToAction }}` which are replaced with translations when sent, see the [Translations](#translations) section for more details. Depending on the email purpose, placeholders will contain different strings. An individual translatable string is available for each purpose, finally resolved by the placeholder. Though the email subject is also part of translations, it has no placeholder as it is a mandatory email component. The embedded templates are available for all deployment scenarios. +The `notifications` service has embedded email text and html body templates. Email templates can use the placeholders `{{ .Greeting }}`, `{{ .MessageBody }}` and `{{ .CallToAction }}` which are replaced with translations when sent, see the [Translations](#translations) section for more details. Depending on the email purpose, placeholders will contain different strings. An individual translatable string is available for each purpose, finally resolved by the placeholder. Though the email subject is also part of translations, it has no placeholder as it is a mandatory email component. The embedded templates are available for all deployment scenarios. ```text -template +template placeholders translated strings <-- source strings <-- purpose final output ``` -In addition, the notifications service supports custom templates. Custom email templates take precedence over the embedded ones. If a custom email template exists, the embedded templates are not used. To configure custom email templates, the `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` environment variable needs to point to a base folder that will contain the email templates. This path must be available from all instances of the notifications service, a shared storage is recommended. The source templates provided by ocis you can derive from are located in following base folder [https://github.com/owncloud/ocis/tree/master/services/notifications/pkg/email/templates](https://github.com/owncloud/ocis/tree/master/services/notifications/pkg/email/templates) with subfolders `shares` and `spaces`. +In addition, the notifications service supports custom templates. Custom email templates take precedence over the embedded ones. If a custom email template exists, the embedded templates are not used. To configure custom email templates, the `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` environment variable needs to point to a base folder that will contain the email templates. This path must be available from all instances of the notifications service, a shared storage is recommended. The source templates provided by ocis you can derive from are located in following base folder [https://github.com/owncloud/ocis/tree/master/services/notifications/pkg/email/templates](https://github.com/owncloud/ocis/tree/master/services/notifications/pkg/email/templates) with subfolders `shares` `spaces` and `html`. - [shares/shareCreated.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/shares/shareCreated.email.body.tmpl) - [shares/shareExpired.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl) @@ -21,9 +21,17 @@ In addition, the notifications service supports custom templates. Custom email t - [spaces/sharedSpace.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/spaces/sharedSpace.email.body.tmpl) - [spaces/unsharedSpace.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.body.tmpl) +- [html/email.html.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/html/email.html.tmpl) + ```text templates │ +└───html +│ │ email.html.tmpl +│ │ +│ └───img +│ │ logo-mail.gif +│ └───shares │ │ shareCreated.email.body.tmpl │ │ shareExpired.email.body.tmpl @@ -35,6 +43,7 @@ templates ``` Custom email templates referenced via `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` must also be located in subfolders `shares` and `spaces` and must have the same names as the embedded templates. It is important that the names of these files and folders match the embedded ones. +In the subfolder `html` contains a default HTML template provided by ocis. The images can be embedded in a custom HTML template as a CID source ```logo-mail``` The image files should be located in `html/img` subfolder. Supported image types are png, jpeg, and gif. ## Translations diff --git a/services/notifications/pkg/channels/channels.go b/services/notifications/pkg/channels/channels.go index eaf51e4e70b..89764cb7ed9 100644 --- a/services/notifications/pkg/channels/channels.go +++ b/services/notifications/pkg/channels/channels.go @@ -7,10 +7,6 @@ import ( "fmt" "strings" - gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - groups "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" - rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/notifications/pkg/config" "github.com/pkg/errors" @@ -20,39 +16,31 @@ import ( // Channel defines the methods of a communication channel. type Channel interface { // SendMessage sends a message to users. - SendMessage(ctx context.Context, userIDs []string, msg, subject, senderDisplayName string) error - // SendMessageToGroup sends a message to a group. - SendMessageToGroup(ctx context.Context, groupdID *groups.GroupId, msg, subject, senderDisplayName string) error + SendMessage(ctx context.Context, message *Message) error +} + +// Message represent the already rendered message including the user id opaqueID +type Message struct { + Sender string + Recipient []string + Subject string + TextBody string + HTMLBody string + AttachInline map[string][]byte } // NewMailChannel instantiates a new mail communication channel. func NewMailChannel(cfg config.Config, logger log.Logger) (Channel, error) { - tm, err := pool.StringToTLSMode(cfg.Notifications.GRPCClientTLS.Mode) - if err != nil { - logger.Error().Err(err).Msg("could not get gateway client tls mode") - return nil, err - } - gc, err := pool.GetGatewayServiceClient(cfg.Notifications.RevaGateway, - pool.WithTLSCACert(cfg.Notifications.GRPCClientTLS.CACert), - pool.WithTLSMode(tm), - ) - if err != nil { - logger.Error().Err(err).Msg("could not get gateway client") - return nil, err - } - return Mail{ - gatewayClient: gc, - conf: cfg, - logger: logger, + conf: cfg, + logger: logger, }, nil } // Mail is the communication channel for email. type Mail struct { - gatewayClient gateway.GatewayAPIClient - conf config.Config - logger log.Logger + conf config.Config + logger log.Logger } func (m Mail) getMailClient() (*mail.SMTPClient, error) { @@ -111,73 +99,30 @@ func (m Mail) getMailClient() (*mail.SMTPClient, error) { } // SendMessage sends a message to all given users. -func (m Mail) SendMessage(ctx context.Context, userIDs []string, msg, subject, senderDisplayName string) error { +func (m Mail) SendMessage(ctx context.Context, message *Message) error { if m.conf.Notifications.SMTP.Host == "" { return nil } - to, err := m.getReceiverAddresses(ctx, userIDs) - if err != nil { - return err - } - smtpClient, err := m.getMailClient() if err != nil { return err } email := mail.NewMSG() - if senderDisplayName != "" { - email.SetFrom(fmt.Sprintf("%s via %s", senderDisplayName, m.conf.Notifications.SMTP.Sender)).AddTo(to...) + if message.Sender != "" { + email.SetFrom(fmt.Sprintf("%s via %s", message.Sender, m.conf.Notifications.SMTP.Sender)).AddTo(message.Recipient...) } 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(ctx context.Context, groupID *groups.GroupId, msg, subject, senderDisplayName string) error { - res, err := m.gatewayClient.GetGroup(ctx, &groups.GetGroupRequest{GroupId: groupID}) - if err != nil { - return err - } - if res.Status.Code != rpc.Code_CODE_OK { - return errors.New("could not get group") + email.SetFrom(m.conf.Notifications.SMTP.Sender).AddTo(message.Recipient...) } - - members := make([]string, 0, len(res.Group.Members)) - for _, id := range res.Group.Members { - members = append(members, id.OpaqueId) - } - - return m.SendMessage(ctx, members, msg, subject, senderDisplayName) -} - -func (m Mail) getReceiverAddresses(ctx context.Context, receivers []string) ([]string, error) { - addresses := make([]string, 0, len(receivers)) - for _, id := range receivers { - // Authenticate is too costly but at the moment our only option to get the user. - // We don't have an authenticated context so calling `GetUser` doesn't work. - res, err := m.gatewayClient.Authenticate(ctx, &gateway.AuthenticateRequest{ - Type: "machine", - ClientId: "userid:" + id, - ClientSecret: m.conf.Notifications.MachineAuthAPIKey, - }) - if err != nil { - return nil, err - } - if res.Status.Code != rpc.Code_CODE_OK { - m.logger.Error(). - Interface("status", res.Status). - Str("receiver_id", id). - Msg("could not get user") - continue + email.SetSubject(message.Subject) + email.SetBody(mail.TextPlain, message.TextBody) + if message.HTMLBody != "" { + email.AddAlternative(mail.TextHTML, message.HTMLBody) + for filename, data := range message.AttachInline { + email.Attach(&mail.File{Data: data, Name: filename, Inline: true}) } - addresses = append(addresses, res.User.Mail) } - return addresses, nil + return email.Send(smtpClient) } diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go index ece92e0a21c..111a6fc4fe9 100644 --- a/services/notifications/pkg/email/composer.go +++ b/services/notifications/pkg/email/composer.go @@ -14,10 +14,54 @@ var ( _domain = "notifications" ) +// NewTextTemplate replace the body message template placeholders with the translated template +func NewTextTemplate(mt MessageTemplate, locale string, translationPath string, vars map[string]interface{}) (MessageTemplate, error) { + var err error + mt.Subject, err = ComposeMessage(mt.Subject, locale, translationPath, vars) + if err != nil { + return mt, err + } + mt.Greeting, err = ComposeMessage(mt.Greeting, locale, translationPath, vars) + if err != nil { + return mt, err + } + mt.MessageBody, err = ComposeMessage(mt.MessageBody, locale, translationPath, vars) + if err != nil { + return mt, err + } + mt.CallToAction, err = ComposeMessage(mt.CallToAction, locale, translationPath, vars) + if err != nil { + return mt, err + } + return mt, nil +} + +// NewHTMLTemplate replace the body message template placeholders with the translated template +func NewHTMLTemplate(mt MessageTemplate, locale string, translationPath string, vars map[string]interface{}) (MessageTemplate, error) { + var err error + mt.Subject, err = ComposeMessage(mt.Subject, locale, translationPath, vars) + if err != nil { + return mt, err + } + mt.Greeting, err = ComposeMessage(newlineToBr(mt.Greeting), locale, translationPath, vars) + if err != nil { + return mt, err + } + mt.MessageBody, err = ComposeMessage(newlineToBr(mt.MessageBody), locale, translationPath, vars) + if err != nil { + return mt, err + } + mt.CallToAction, err = ComposeMessage(callToActionToHTML(mt.CallToAction), locale, translationPath, vars) + if err != nil { + return mt, err + } + return mt, nil +} + // ComposeMessage renders the message based on template -func ComposeMessage(template, locale string, path string) string { +func ComposeMessage(template, locale string, path string, vars map[string]interface{}) (string, error) { raw := loadTemplate(template, locale, path) - return replacePlaceholders(raw) + return executeRaw(replacePlaceholders(raw), vars) } func loadTemplate(template, locale string, path string) string { @@ -39,3 +83,15 @@ func replacePlaceholders(raw string) string { } return raw } + +func newlineToBr(s string) string { + return strings.Replace(s, "\n", "
", -1) +} + +func callToActionToHTML(s string) string { + if strings.TrimSpace(s) == "" { + return "" + } + s = strings.TrimRight(s, "{{ .ShareLink }}") + return `` + s + `` +} diff --git a/services/notifications/pkg/email/email.go b/services/notifications/pkg/email/email.go index 283559b9c5f..63fa17195ba 100644 --- a/services/notifications/pkg/email/email.go +++ b/services/notifications/pkg/email/email.go @@ -6,9 +6,12 @@ package email import ( "bytes" "embed" - "html" "html/template" + "os" "path/filepath" + "strings" + + "github.com/owncloud/ocis/v2/services/notifications/pkg/channels" ) var ( @@ -17,49 +20,74 @@ var ( ) // RenderEmailTemplate renders the email template for a new share -func RenderEmailTemplate(mt MessageTemplate, locale string, emailTemplatePath string, translationPath string, vars map[string]interface{}) (string, string, error) { - // translate a message - mt.Subject = ComposeMessage(mt.Subject, locale, translationPath) - mt.Greeting = ComposeMessage(mt.Greeting, locale, translationPath) - mt.MessageBody = ComposeMessage(mt.MessageBody, locale, translationPath) - mt.CallToAction = ComposeMessage(mt.CallToAction, locale, translationPath) +func RenderEmailTemplate(mt MessageTemplate, locale string, emailTemplatePath string, translationPath string, vars map[string]interface{}) (*channels.Message, error) { + textMt, err := NewTextTemplate(mt, locale, translationPath, vars) + if err != nil { + return nil, err + } + tpl, err := parseTemplate(emailTemplatePath, mt.textTemplate) + if err != nil { + return nil, err + } + textBody, err := emailTemplate(tpl, textMt) + if err != nil { + return nil, err + } - // replace the body email placeholders with the values - subject, err := executeRaw(mt.Subject, vars) + htmlMt, err := NewHTMLTemplate(mt, locale, translationPath, vars) if err != nil { - return "", "", err + return nil, err + } + htmlTpl, err := parseTemplate(emailTemplatePath, filepath.Join("html", "email.html.tmpl")) + if err != nil { + return nil, err + } + htmlBody, err := emailTemplate(htmlTpl, htmlMt) + if err != nil { + return nil, err } - // replace the body email template placeholders with the translated template - rawBody, err := executeEmailTemplate(emailTemplatePath, mt) + var data map[string][]byte + data, err = readImages(emailTemplatePath) if err != nil { - return "", "", err + data, err = readFs() + if err != nil { + return nil, err + } } - // replace the body email placeholders with the values - body, err := executeRaw(rawBody, vars) + return &channels.Message{ + Subject: textMt.Subject, + TextBody: textBody, + HTMLBody: htmlBody, + AttachInline: data, + }, nil +} + +func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) { + str, err := executeTemplate(tpl, map[string]interface{}{ + "Greeting": template.HTML(strings.TrimSpace(mt.Greeting)), + "MessageBody": template.HTML(strings.TrimSpace(mt.MessageBody)), + "CallToAction": template.HTML(strings.TrimSpace(mt.CallToAction)), + }) if err != nil { - return "", "", err + return "", err } - return subject, body, nil + return str, err } -func executeEmailTemplate(emailTemplatePath string, mt MessageTemplate) (string, error) { +func parseTemplate(emailTemplatePath string, file string) (*template.Template, error) { var err error var tpl *template.Template // try to lookup the files in the filesystem - tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, mt.bodyTemplate)) + tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, file)) 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/", mt.bodyTemplate)) + tpl, err = template.ParseFS(templatesFS, filepath.Join("templates", file)) if err != nil { - return "", err + return nil, err } } - str, err := executeTemplate(tpl, mt) - if err != nil { - return "", err - } - return html.UnescapeString(str), err + return tpl, err } func executeRaw(raw string, vars map[string]interface{}) (string, error) { @@ -77,3 +105,66 @@ func executeTemplate(tpl *template.Template, vars any) (string, error) { } return writer.String(), nil } + +func readFs() (map[string][]byte, error) { + dir := filepath.Join("templates", "html", "img") + entries, err := templatesFS.ReadDir(dir) + if err != nil { + return nil, err + } + + list := make(map[string][]byte) + for _, e := range entries { + if !e.IsDir() { + file, err := templatesFS.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + if !validateMime(file) { + continue + } + list[e.Name()] = file + } + } + return list, nil +} + +func readImages(emailTemplatePath string) (map[string][]byte, error) { + dir := filepath.Join(emailTemplatePath, "html", "img") + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + list := make(map[string][]byte) + for _, e := range entries { + if !e.IsDir() { + file, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + if !validateMime(file) { + continue + } + list[e.Name()] = file + } + } + return list, nil +} + +// signature image formats signature https://go.dev/src/net/http/sniff.go #L:118 +var signature = map[string]string{ + "\xff\xd8\xff": "image/jpeg", + "\x89PNG\r\n\x1a\n": "image/png", + "GIF87a": "image/gif", + "GIF89a": "image/gif", +} + +// validateMime validate the mime type of image file from its first few bytes +func validateMime(incipit []byte) bool { + for s := range signature { + if strings.HasPrefix(string(incipit), s) { + return true + } + } + return false +} diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index f8161521764..56d3cecc41a 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -7,7 +7,8 @@ func Template(s string) string { return s } var ( // Shares ShareCreated = MessageTemplate{ - bodyTemplate: "shares/shareCreated.email.body.tmpl", + textTemplate: "shares/shareCreated.email.body.tmpl", + htmlTemplate: "html/email.html.tmpl", // ShareCreated email template, Subject field (resolves directly) Subject: Template(`{ShareSharer} shared '{ShareFolder}' with you`), // ShareCreated email template, resolves via {{ .Greeting }} @@ -19,7 +20,8 @@ var ( } ShareExpired = MessageTemplate{ - bodyTemplate: "shares/shareExpired.email.body.tmpl", + textTemplate: "shares/shareExpired.email.body.tmpl", + htmlTemplate: "html/email.html.tmpl", // ShareExpired email template, Subject field (resolves directly) Subject: Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`), // ShareExpired email template, resolves via {{ .Greeting }} @@ -32,7 +34,8 @@ Even though this share has been revoked you still might have access through othe // Spaces templates SharedSpace = MessageTemplate{ - bodyTemplate: "spaces/sharedSpace.email.body.tmpl", + textTemplate: "spaces/sharedSpace.email.body.tmpl", + htmlTemplate: "html/email.html.tmpl", // SharedSpace email template, Subject field (resolves directly) Subject: Template("{SpaceSharer} invited you to join {SpaceName}"), // SharedSpace email template, resolves via {{ .Greeting }} @@ -44,7 +47,8 @@ Even though this share has been revoked you still might have access through othe } UnsharedSpace = MessageTemplate{ - bodyTemplate: "spaces/unsharedSpace.email.body.tmpl", + textTemplate: "spaces/unsharedSpace.email.body.tmpl", + htmlTemplate: "html/email.html.tmpl", // UnsharedSpace email template, Subject field (resolves directly) Subject: Template(`{SpaceSharer} removed you from {SpaceName}`), // UnsharedSpace email template, resolves via {{ .Greeting }} @@ -58,7 +62,8 @@ You might still have access through your other groups or direct membership.`), } MembershipExpired = MessageTemplate{ - bodyTemplate: "spaces/membershipExpired.email.body.tmpl", + textTemplate: "spaces/membershipExpired.email.body.tmpl", + htmlTemplate: "html/email.html.tmpl", // MembershipExpired email template, Subject field (resolves directly) Subject: Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`), // MembershipExpired email template, resolves via {{ .Greeting }} @@ -84,8 +89,11 @@ var _placeholders = map[string]string{ // MessageTemplate is the data structure for the email type MessageTemplate struct { - // bodyTemplate represent the path to .tmpl file - bodyTemplate string + // textTemplate represent the path to text plain .tmpl file + textTemplate string + // htmlTemplate represent the path to html .tmpl file + htmlTemplate string + // The fields below represent the placeholders for the translatable templates Subject string Greeting string MessageBody string diff --git a/services/notifications/pkg/email/templates/html/email.html.tmpl b/services/notifications/pkg/email/templates/html/email.html.tmpl new file mode 100644 index 00000000000..5ba3adda1a2 --- /dev/null +++ b/services/notifications/pkg/email/templates/html/email.html.tmpl @@ -0,0 +1,51 @@ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
  + logo-mail +
 
  + {{ .Greeting }} +

+ {{ .MessageBody }} + {{if ne .CallToAction "" }} +

{{ .CallToAction }} + {{end}} +
 
  + +
 
+
+ + diff --git a/services/notifications/pkg/email/templates/html/img/logo-mail.gif b/services/notifications/pkg/email/templates/html/img/logo-mail.gif new file mode 100644 index 00000000000..c8bb0c7e51a Binary files /dev/null and b/services/notifications/pkg/email/templates/html/img/logo-mail.gif differ diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go index df17067f984..9a0d2bda40f 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -97,98 +97,96 @@ func (s eventsNotifier) Run() error { } } -// recipient represent the already rendered message including the user id opaqueID -type recipient struct { - opaqueID string - subject string - msg string -} - func (s eventsNotifier) render(ctx context.Context, template email.MessageTemplate, - granteeFieldName string, fields map[string]interface{}, granteeList []*user.UserId) ([]recipient, error) { + granteeFieldName string, fields map[string]interface{}, granteeList []*user.User, sender string) ([]*channels.Message, error) { // Render the Email Template for each user - recipientList := make([]recipient, len(granteeList)) - for i, userID := range granteeList { - locale := s.getUserLang(ctx, userID) - grantee, err := s.getUserName(ctx, userID) - if err != nil { - return nil, err - } - fields[granteeFieldName] = grantee + messageList := make([]*channels.Message, len(granteeList)) + for i, usr := range granteeList { + locale := s.getUserLang(ctx, usr.GetId()) + fields[granteeFieldName] = usr.GetDisplayName() - subj, msg, err := email.RenderEmailTemplate(template, locale, s.emailTemplatePath, s.translationPath, fields) + rendered, err := email.RenderEmailTemplate(template, locale, s.emailTemplatePath, s.translationPath, fields) if err != nil { return nil, err } - recipientList[i] = recipient{opaqueID: userID.GetOpaqueId(), subject: subj, msg: msg} + rendered.Sender = sender + rendered.Recipient = []string{usr.GetMail()} + messageList[i] = rendered } - return recipientList, nil + return messageList, nil } -func (s eventsNotifier) send(ctx context.Context, recipientList []recipient, sender string) { +func (s eventsNotifier) send(ctx context.Context, recipientList []*channels.Message) { for _, r := range recipientList { - err := s.channel.SendMessage(ctx, []string{r.opaqueID}, r.msg, r.subject, sender) + err := s.channel.SendMessage(ctx, r) if err != nil { s.logger.Error().Err(err).Str("event", "SendEmail").Msg("failed to send a message") } } } -func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.UserId, error) { +func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.User, error) { switch { case u != nil: if s.disableEmails(ctx, u) { - return []*user.UserId{}, nil + return nil, nil } - return []*user.UserId{u}, nil + usr, err := s.getUser(ctx, u) + if err != nil { + return nil, err + } + return []*user.User{usr}, nil case g != nil: res, err := s.gwClient.GetGroup(ctx, &group.GetGroupRequest{GroupId: g}) if err != nil { return nil, err } - if res.Status.Code != rpc.Code_CODE_OK { + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { return nil, errors.New("could not get group") } - var grantees []*user.UserId + userList := make([]*user.User, 0, len(res.GetGroup().GetMembers())) for _, userID := range res.GetGroup().GetMembers() { // don't add the executant if userID.GetOpaqueId() == executant.GetOpaqueId() { continue } - // don't add users who opted out if s.disableEmails(ctx, userID) { continue } - - grantees = append(grantees, userID) + usr, err := s.getUser(ctx, userID) + if err != nil { + return nil, err + } + userList = append(userList, usr) } - return grantees, nil + return userList, nil default: return nil, errors.New("need at least one non-nil grantee") } } -func (s eventsNotifier) getUserName(ctx context.Context, u *user.UserId) (string, error) { +// func (s eventsNotifier) getUserName(ctx context.Context, u *user.UserId) (string, error) { +func (s eventsNotifier) getUser(ctx context.Context, u *user.UserId) (*user.User, error) { if u == nil { - return "", errors.New("need at least one non-nil grantee") + return nil, errors.New("need at least one non-nil grantee") } r, err := s.gwClient.GetUser(ctx, &user.GetUserRequest{UserId: u}) if err != nil { - return "", err + return nil, err } - if r.Status.Code != rpc.Code_CODE_OK { - return "", fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode()) + if r.GetStatus().GetCode() != rpc.Code_CODE_OK { + return nil, fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode()) } - return r.GetUser().GetDisplayName(), nil + return r.GetUser(), nil } func (s eventsNotifier) getUserLang(ctx context.Context, u *user.UserId) string { - granteeCtx := metadata.Set(ctx, middleware.AccountID, u.OpaqueId) + granteeCtx := metadata.Set(ctx, middleware.AccountID, u.GetOpaqueId()) if resp, err := s.valueService.GetValueByUniqueIdentifiers(granteeCtx, &settingssvc.GetValueByUniqueIdentifiersRequest{ - AccountUuid: u.OpaqueId, + AccountUuid: u.GetOpaqueId(), SettingId: defaults.SettingUUIDProfileLanguage, }, ); err == nil { @@ -226,8 +224,7 @@ func (s eventsNotifier) getResourceInfo(ctx context.Context, resourceID *provide if err != nil { return nil, err } - - if md.Status.Code != rpc.Code_CODE_OK { + if md.GetStatus().GetCode() != rpc.Code_CODE_OK { return nil, fmt.Errorf("could not resource info: %s", md.Status.Message) } return md.GetInfo(), nil diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go index 4f6017eded5..fdc64410c9f 100644 --- a/services/notifications/pkg/service/service_test.go +++ b/services/notifications/pkg/service/service_test.go @@ -5,7 +5,6 @@ import ( "time" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" @@ -19,6 +18,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/shared" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" "github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults" + "github.com/owncloud/ocis/v2/services/notifications/pkg/channels" "github.com/owncloud/ocis/v2/services/notifications/pkg/service" "github.com/test-go/testify/mock" "go-micro.dev/v4/client" @@ -32,12 +32,14 @@ var _ = Describe("Notifications", func() { Id: &user.UserId{ OpaqueId: "sharer", }, + Mail: "sharer@owncloud.com", DisplayName: "Dr. S. Harer", } sharee = &user.User{ Id: &user.UserId{ OpaqueId: "sharee", }, + Mail: "sharee@owncloud.com", DisplayName: "Eric Expireling", } resourceid = &provider.ResourceId{ @@ -78,7 +80,7 @@ var _ = Describe("Notifications", func() { }, Entry("Share Created", testChannel{ - expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedReceipients: []string{sharee.GetMail()}, expectedSubject: "Dr. S. Harer shared 'secrets of the board' with you", expectedMessage: `Hello Eric Expireling @@ -103,7 +105,7 @@ https://owncloud.com }), Entry("Share Expired", testChannel{ - expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedReceipients: []string{sharee.GetMail()}, expectedSubject: "Share to 'secrets of the board' expired at 2023-04-17 16:42:00", expectedMessage: `Hello Eric Expireling, @@ -128,7 +130,7 @@ https://owncloud.com }), Entry("Added to Space", testChannel{ - expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedReceipients: []string{sharee.GetMail()}, expectedSubject: "Dr. S. Harer invited you to join secret space", expectedMessage: `Hello Eric Expireling, @@ -153,7 +155,7 @@ https://owncloud.com }), Entry("Removed from Space", testChannel{ - expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedReceipients: []string{sharee.GetMail()}, expectedSubject: "Dr. S. Harer removed you from secret space", expectedMessage: `Hello Eric Expireling, @@ -179,7 +181,7 @@ https://owncloud.com }), Entry("Space Expired", testChannel{ - expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedReceipients: []string{sharee.GetMail()}, expectedSubject: "Membership of 'secret space' expired at 2023-04-17 16:42:00", expectedMessage: `Hello Eric Expireling, @@ -208,27 +210,20 @@ https://owncloud.com // NOTE: This is explictitly not testing the message itself. Should we? type testChannel struct { - expectedReceipients map[string]bool + expectedReceipients []string expectedSubject string expectedMessage string expectedSender string done chan struct{} } -func (tc testChannel) SendMessage(ctx context.Context, userIDs []string, msg, subject, senderDisplayName string) error { +func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) error { defer GinkgoRecover() - for _, u := range userIDs { - Expect(tc.expectedReceipients[u]).To(Equal(true)) - } - - Expect(msg).To(Equal(tc.expectedMessage)) - Expect(subject).To(Equal(tc.expectedSubject)) - Expect(senderDisplayName).To(Equal(tc.expectedSender)) + Expect(m.Recipient).To(Equal(tc.expectedReceipients)) + Expect(m.Subject).To(Equal(tc.expectedSubject)) + Expect(m.TextBody).To(Equal(tc.expectedMessage)) + Expect(m.Sender).To(Equal(tc.expectedSender)) tc.done <- struct{}{} return nil } - -func (tc testChannel) SendMessageToGroup(ctx context.Context, groupID *group.GroupId, msg, subject, senderDisplayName string) error { - return tc.SendMessage(ctx, []string{groupID.GetOpaqueId()}, msg, subject, senderDisplayName) -} diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go index 4c8d8444cf5..1182ea38ee5 100644 --- a/services/notifications/pkg/service/shares.go +++ b/services/notifications/pkg/service/shares.go @@ -48,12 +48,12 @@ func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { "ShareSharer": sharerDisplayName, "ShareFolder": resourceInfo.Name, "ShareLink": shareLink, - }, granteeList) + }, granteeList, sharerDisplayName) if err != nil { s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("could not get render the email") return } - s.send(ownerCtx, recipientList, sharerDisplayName) + s.send(ownerCtx, recipientList) } func (s eventsNotifier) handleShareExpired(e events.ShareExpired) { @@ -87,10 +87,10 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) { map[string]interface{}{ "ShareFolder": resourceInfo.GetName(), "ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"), - }, granteeList) + }, granteeList, owner.GetDisplayName()) if err != nil { s.logger.Error().Err(err).Str("event", "ShareExpired").Msg("could not get render the email") return } - s.send(ownerCtx, recipientList, owner.GetDisplayName()) + s.send(ownerCtx, recipientList) } diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go index 78f6e59359d..37af8171330 100644 --- a/services/notifications/pkg/service/spaces.go +++ b/services/notifications/pkg/service/spaces.go @@ -61,12 +61,12 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) { "SpaceSharer": sharerDisplayName, "SpaceName": resourceInfo.GetSpace().GetName(), "ShareLink": shareLink, - }, spaceGrantee) + }, spaceGrantee, sharerDisplayName) if err != nil { s.logger.Error().Err(err).Str("event", "SharedSpace").Msg("could not get render the email") return } - s.send(executantCtx, recipientList, sharerDisplayName) + s.send(executantCtx, recipientList) } func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) { @@ -121,12 +121,12 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) { "SpaceSharer": sharerDisplayName, "SpaceName": resourceInfo.GetSpace().Name, "ShareLink": shareLink, - }, spaceGrantee) + }, spaceGrantee, sharerDisplayName) if err != nil { s.logger.Error().Err(err).Str("event", "UnsharedSpace").Msg("Could not get render the email") return } - s.send(executantCtx, recipientList, sharerDisplayName) + s.send(executantCtx, recipientList) } func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired) { @@ -152,10 +152,10 @@ func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExp map[string]interface{}{ "SpaceName": e.SpaceName, "ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"), - }, granteeList) + }, granteeList, owner.GetDisplayName()) if err != nil { s.logger.Error().Err(err).Str("event", "SpaceUnshared").Msg("could not get render the email") return } - s.send(ownerCtx, recipientList, owner.GetDisplayName()) + s.send(ownerCtx, recipientList) }