Skip to content

Commit

Permalink
The email HTML templates added #6146 (#6147)
Browse files Browse the repository at this point in the history
* The email HTML templates added #6146

* use a single palne text email template. use fs.FS

* Update services/notifications/README.md

Co-authored-by: Martin <github@diemattels.at>

* Update services/notifications/README.md

Co-authored-by: Martin <github@diemattels.at>

* fix md

---------

Co-authored-by: Roman Perekhod <rperekhod@owncloud.com>
Co-authored-by: Martin <github@diemattels.at>
  • Loading branch information
3 people committed May 3, 2023
1 parent 77e7735 commit 27322c5
Show file tree
Hide file tree
Showing 18 changed files with 402 additions and 269 deletions.
6 changes: 6 additions & 0 deletions changelog/unreleased/add-html-email-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Add the email HTML templates

Add the email HTML templates

https://github.com/owncloud/ocis/pull/6147
https://github.com/owncloud/ocis/issues/6146
38 changes: 22 additions & 16 deletions services/notifications/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,43 @@ 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 and follow the [templates subfolder hierarchy](#templates-subfolder-hierarchy).This path must be available from all instances of the notifications service, a shared storage is recommended.
```text
{NOTIFICATIONS_EMAIL_TEMPLATE_PATH}/templates/text/email.text.tmpl
{NOTIFICATIONS_EMAIL_TEMPLATE_PATH}/templates/html/email.html.tmpl
{NOTIFICATIONS_EMAIL_TEMPLATE_PATH}/templates/html/img/
```
The source templates provided by ocis you can derive from are located in the 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 `templates/text` and `templates/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)
- [spaces/membershipExpired.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl)
- [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)
- [text/email.text.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/text/email.text.tmpl)
- [html/email.html.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/html/email.html.tmpl)

### Templates subfolder hierarchy
```text
templates
└───shares
│ │ shareCreated.email.body.tmpl
│ │ shareExpired.email.body.tmpl
└───html
│ │ email.html.tmpl
│ │
│ └───img
│ │ logo-mail.gif
└───spaces
│ membershipExpired.email.body.tmpl
│ sharedSpace.email.body.tmpl
│ unsharedSpace.email.body.tmpl
└───text
│ email.text.tmpl
```

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.
Custom email templates referenced via `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` must also be located in subfolder `templates/text` and `templates/html` 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.
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.

## Translations

Expand Down
108 changes: 27 additions & 81 deletions services/notifications/pkg/channels/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -111,73 +99,31 @@ 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 == "" {
m.logger.Info().Str("mail", "SendMessage").Msg("failed to send a message. SMTP host is not set")
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)
}
92 changes: 69 additions & 23 deletions services/notifications/pkg/email/composer.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,70 @@
package email

import (
"embed"
"io/fs"
"bytes"
"strings"
"text/template"

"github.com/leonelquinteros/gotext"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email/l10n"
)

var (
//go:embed l10n/locale
_translationFS embed.FS
_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
t := l10n.NewTranslator(locale, translationPath)
mt.Subject, err = composeMessage(t.Translate(mt.Subject), vars)
if err != nil {
return mt, err
}
mt.Greeting, err = composeMessage(t.Translate(mt.Greeting), vars)
if err != nil {
return mt, err
}
mt.MessageBody, err = composeMessage(t.Translate(mt.MessageBody), vars)
if err != nil {
return mt, err
}
mt.CallToAction, err = composeMessage(t.Translate(mt.CallToAction), 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 {
raw := loadTemplate(template, locale, path)
return replacePlaceholders(raw)
// 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
t := l10n.NewTranslator(locale, translationPath)
mt.Subject, err = composeMessage(t.Translate(mt.Subject), vars)
if err != nil {
return mt, err
}
mt.Greeting, err = composeMessage(newlineToBr(t.Translate(mt.Greeting)), vars)
if err != nil {
return mt, err
}
mt.MessageBody, err = composeMessage(newlineToBr(t.Translate(mt.MessageBody)), vars)
if err != nil {
return mt, err
}
mt.CallToAction, err = composeMessage(callToActionToHTML(t.Translate(mt.CallToAction)), vars)
if err != nil {
return mt, err
}
return mt, nil
}

func loadTemplate(template, locale string, path string) string {
// Create Locale with library path and language code and load default domain
var l *gotext.Locale
if path == "" {
filesystem, _ := fs.Sub(_translationFS, "l10n/locale")
l = gotext.NewLocaleFS(locale, filesystem)
} else { // use custom path instead
l = gotext.NewLocale(path, locale)
}
l.AddDomain(_domain) // make domain configurable only if needed
return l.Get(template)
// composeMessage renders the message based on template
func composeMessage(tmpl string, vars map[string]interface{}) (string, error) {
tpl, err := template.New("").Parse(replacePlaceholders(tmpl))
if err != nil {
return "", err
}
var writer bytes.Buffer
if err := tpl.Execute(&writer, vars); err != nil {
return "", err
}
return writer.String(), nil
}

func replacePlaceholders(raw string) string {
Expand All @@ -39,3 +73,15 @@ func replacePlaceholders(raw string) string {
}
return raw
}

func newlineToBr(s string) string {
return strings.Replace(s, "\n", "<br>", -1)
}

func callToActionToHTML(s string) string {
if strings.TrimSpace(s) == "" {
return ""
}
s = strings.TrimSuffix(s, "{{ .ShareLink }}")
return `<a href="{{ .ShareLink }}">` + s + `</a>`
}
Loading

0 comments on commit 27322c5

Please sign in to comment.