Skip to content

Commit

Permalink
The email HTML templates added #6146
Browse files Browse the repository at this point in the history
  • Loading branch information
2403905 committed Apr 27, 2023
1 parent c9fe640 commit 53c2455
Show file tree
Hide file tree
Showing 10 changed files with 329 additions and 181 deletions.
101 changes: 24 additions & 77 deletions services/notifications/pkg/channels/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import (
"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,31 +17,24 @@ 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
}

Expand Down Expand Up @@ -111,73 +101,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)
}
54 changes: 52 additions & 2 deletions services/notifications/pkg/email/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,48 @@ var (
_domain = "notifications"
)

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
}

func NewHtmlTemplate(mt MessageTemplate, locale string, translationPath string, vars map[string]interface{}) (MessageTemplate, error) {
var err error
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 {
Expand All @@ -39,3 +77,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.TrimSpace(strings.TrimRight(s, "{{ .ShareLink }}"))
return `<a href="{{ .ShareLink }}">` + s + `</a>`
}
152 changes: 126 additions & 26 deletions services/notifications/pkg/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import (
"embed"
"html"
"html/template"
"os"
"path/filepath"
"strings"

"github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
)

var (
Expand All @@ -17,51 +21,84 @@ 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)

// replace the body email placeholders with the values
subject, err := executeRaw(mt.Subject, vars)
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
}
textBody, err := emailTemplate(emailTemplatePath, textMt)
if err != nil {
return "", "", err
return nil, err
}

// replace the body email template placeholders with the translated template
rawBody, err := executeEmailTemplate(emailTemplatePath, mt)
htmlMt, err := NewHtmlTemplate(mt, locale, translationPath, vars)
if err != nil {
return "", "", err
return nil, err
}
// replace the body email placeholders with the values
body, err := executeRaw(rawBody, vars)
htmlBody, err := htmlEmailTemplate(emailTemplatePath, htmlMt)
if err != nil {
return "", "", err
return nil, err
}
return subject, body, nil
}

func executeEmailTemplate(emailTemplatePath string, mt MessageTemplate) (string, 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))
var data map[string][]byte
data, err = readImages(emailTemplatePath)
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))
data, err = readFs()
if err != nil {
return "", err
return nil, err
}
}
return &channels.Message{
Subject: textMt.Subject,
TextBody: textBody,
HtmlBody: htmlBody,
AttachInline: data,
}, nil
}

func emailTemplate(emailTemplatePath string, mt MessageTemplate) (string, error) {
tpl, err := parseTemplate(emailTemplatePath, mt.textTemplate)
if err != nil {
return "", err
}
str, err := executeTemplate(tpl, mt)
if err != nil {
return "", err
}
return html.UnescapeString(str), err
}

func htmlEmailTemplate(emailTemplatePath string, mt MessageTemplate) (string, error) {
mailTpl, err := parseTemplate(emailTemplatePath, filepath.Join(emailTemplatePath, "html", "email.html.tmpl"))
if err != nil {
return "", err
}
str, err := executeTemplate(mailTpl, map[string]interface{}{
"Greeting": template.HTML(html.UnescapeString(strings.TrimSpace(mt.Greeting))),
"MessageBody": template.HTML(html.UnescapeString(strings.TrimSpace(mt.MessageBody))),
"CallToAction": template.HTML(html.UnescapeString(strings.TrimSpace(mt.CallToAction))),
})
if err != nil {
return "", err
}
return strings.TrimSpace(str), err
}

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, 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", file))
if err != nil {
return nil, err
}
}
return tpl, err
}

func executeRaw(raw string, vars map[string]interface{}) (string, error) {
tpl, err := template.New("").Parse(raw)
if err != nil {
Expand All @@ -77,3 +114,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
}
Loading

0 comments on commit 53c2455

Please sign in to comment.