Skip to content

Commit

Permalink
The email HTML templates added owncloud#6146
Browse files Browse the repository at this point in the history
  • Loading branch information
2403905 committed Apr 26, 2023
1 parent b505695 commit 865dc6c
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 165 deletions.
99 changes: 21 additions & 78 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,23 @@ 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
}

// 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 +100,27 @@ 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.SetFrom(m.conf.Notifications.SMTP.Sender).AddTo(message.Recipient...)
}
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")
}

members := make([]string, 0, len(res.Group.Members))
for _, id := range res.Group.Members {
members = append(members, id.OpaqueId)
email.SetSubject(message.Subject)
email.SetBody(mail.TextPlain, message.TextBody)
if message.HtmlBody != "" {
email.AddAlternative(mail.TextHTML, message.HtmlBody)
}

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
}
addresses = append(addresses, res.User.Mail)
}

return addresses, nil
return email.Send(smtpClient)
}
78 changes: 65 additions & 13 deletions services/notifications/pkg/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
"html"
"html/template"
"path/filepath"
"strings"

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

var (
Expand All @@ -17,40 +20,57 @@ 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) {
func RenderEmailTemplate(mt MessageTemplate, locale string, emailTemplatePath string, translationPath string, vars map[string]interface{}) (*channels.Message, 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
// replace the subject email placeholders with the values
subject, err := executeRaw(mt.Subject, vars)
if err != nil {
return "", "", err
return nil, err
}

// replace the body email template placeholders with the translated template
rawBody, err := executeEmailTemplate(emailTemplatePath, mt)
// replace the textBody email template placeholders with the translated template
rawTextBody, err := rowEmailTemplate(emailTemplatePath, mt)
if err != nil {
return nil, err
}
// replace the textBody email placeholders with the values
textBody, err := executeRaw(rawTextBody, vars)
if err != nil {
return "", "", err
return nil, err
}
// replace the body email placeholders with the values
body, err := executeRaw(rawBody, vars)
// replace the textBody email template placeholders with the translated template
mt.Greeting = newlineToBr(mt.Greeting)
mt.MessageBody = newlineToBr(mt.MessageBody)
mt.CallToAction = callToActionToHtml(mt.CallToAction)
rawHtmlBody, err := rowHtmlEmailTemplate(emailTemplatePath, mt)
if err != nil {
return "", "", err
return nil, err
}
return subject, body, nil
// replace the textBody email placeholders with the values
htmlBody, err := executeRaw(rawHtmlBody, vars)
if err != nil {
return nil, err
}
return &channels.Message{
Subject: subject,
TextBody: textBody,
HtmlBody: htmlBody,
}, nil
}

func executeEmailTemplate(emailTemplatePath string, mt MessageTemplate) (string, error) {
func rowEmailTemplate(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))
tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, mt.textTemplate))
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/", mt.textTemplate))
if err != nil {
return "", err
}
Expand All @@ -62,6 +82,29 @@ func executeEmailTemplate(emailTemplatePath string, mt MessageTemplate) (string,
return html.UnescapeString(str), err
}

func rowHtmlEmailTemplate(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.htmlTemplate))
if err != nil {
// template has not been found in the fs, or path has not been specified => use embed templates
_ = filepath.Join("templates/", mt.htmlTemplate)
tpl, err = template.ParseFS(templatesFS, filepath.Join("templates/", mt.htmlTemplate))
if err != nil {
return "", err
}
}

content, err := tpl.ParseFS(templatesFS, filepath.Join("templates", "common", "email.footer.html.tmpl"))
str, err := executeTemplate(content, mt)
if err != nil {
return "", err
}
return html.UnescapeString(str), err
}

func executeRaw(raw string, vars map[string]interface{}) (string, error) {
tpl, err := template.New("").Parse(raw)
if err != nil {
Expand All @@ -77,3 +120,12 @@ func executeTemplate(tpl *template.Template, vars any) (string, error) {
}
return writer.String(), nil
}

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

func callToActionToHtml(s string) string {
s = strings.TrimSpace(strings.TrimRight(s, "{{ .ShareLink }}"))
return `<a href="{{ .ShareLink }}">` + s + `</a>`
}
22 changes: 15 additions & 7 deletions services/notifications/pkg/email/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "shares/shareCreated.email.body.html.tmpl",
// ShareCreated email template, Subject field (resolves directly)
Subject: Template(`{ShareSharer} shared '{ShareFolder}' with you`),
// ShareCreated email template, resolves via {{ .Greeting }}
Expand All @@ -19,7 +20,8 @@ var (
}

ShareExpired = MessageTemplate{
bodyTemplate: "shares/shareExpired.email.body.tmpl",
textTemplate: "shares/shareExpired.email.body.tmpl",
htmlTemplate: "shares/shareExpired.email.body.html.tmpl",
// ShareExpired email template, Subject field (resolves directly)
Subject: Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`),
// ShareExpired email template, resolves via {{ .Greeting }}
Expand All @@ -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: "spaces/sharedSpace.email.body.html.tmpl",
// SharedSpace email template, Subject field (resolves directly)
Subject: Template("{SpaceSharer} invited you to join {SpaceName}"),
// SharedSpace email template, resolves via {{ .Greeting }}
Expand All @@ -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: "spaces/unsharedSpace.email.body.html.tmpl",
// UnsharedSpace email template, Subject field (resolves directly)
Subject: Template(`{SpaceSharer} removed you from {SpaceName}`),
// UnsharedSpace email template, resolves via {{ .Greeting }}
Expand All @@ -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: "spaces/membershipExpired.email.body.html.tmpl",
// MembershipExpired email template, Subject field (resolves directly)
Subject: Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`),
// MembershipExpired email template, resolves via {{ .Greeting }}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{define "footer"}}
<footer>
<br>
<br>
--- <br>
ownCloud - Store. Share. Work.<br>
<a href="https://owncloud.com">https://owncloud.com</a>
</footer>
{{end}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
</head>
<body>
<p>
{{ .Greeting }}
<br>
<br>
{{ .MessageBody }}
<br>
<br>
{{ .CallToAction }}
</p>
{{template "footer"}}
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
</head>
<body>
<p>
{{ .Greeting }}
<br>
<br>
{{ .MessageBody }}
</p>
{{template "footer"}}
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
</head>
<body>
<p>
{{ .Greeting }}
<br>
<br>
{{ .MessageBody }}
</p>
{{template "footer"}}
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
</head>
<body>
<p>
{{ .Greeting }}
<br>
<br>
{{ .MessageBody }}
<br>
<br>
{{ .CallToAction }}
</p>
{{template "footer"}}
</body>
</html>
Loading

0 comments on commit 865dc6c

Please sign in to comment.