Files
opencloud/services/notifications/pkg/email/email.go
Michael Stingl 12206e3e70 fix(notifications): don't re-escape email vars for each recipient
escapeStringMap mutated its input map. The recipient loop in eventsNotifier.render reuses that map across iterations, so each recipient past the first got values with one extra HTML escape layer. Return a new map instead.

Fixes #2804

Signed-off-by: Michael Stingl <mail@michaelstingl.com>
2026-05-20 18:56:28 +02:00

213 lines
6.0 KiB
Go

// Package email implements utility for rendering the Email.
//
// The email package supports transifex translation for email templates.
package email
import (
"bytes"
"embed"
"errors"
"html"
"html/template"
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/opencloud-eu/opencloud/services/notifications/pkg/channels"
)
var (
//go:embed templates
templatesFS embed.FS
imgDir = filepath.Join("templates", "html", "img")
)
// RenderEmailTemplate is responsible to prepare a message which than can be used to notify the user via email.
func RenderEmailTemplate(mt MessageTemplate, locale, defaultLocale string, emailTemplatePath string, translationPath string, vars map[string]string) (*channels.Message, error) {
textMt, err := NewTextTemplate(mt, locale, defaultLocale, 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
}
htmlMt, err := NewHTMLTemplate(mt, locale, defaultLocale, translationPath, escapeStringMap(vars))
if err != nil {
return nil, err
}
htmlTpl, err := parseTemplate(emailTemplatePath, mt.htmlTemplate)
if err != nil {
return nil, err
}
htmlBody, err := emailTemplate(htmlTpl, htmlMt)
if err != nil {
return nil, err
}
var data map[string][]byte
if emailTemplatePath != "" {
data, err = readImages(emailTemplatePath)
if err != nil {
return nil, err
}
}
return &channels.Message{
Subject: textMt.Subject,
TextBody: textBody,
HTMLBody: htmlBody,
AttachInline: data,
}, nil
}
// RenderGroupedEmailTemplate is responsible to prepare a message which than can be used to notify the user via email.
func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, emailTemplatePath string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string) (*channels.Message, error) {
textMt, err := NewGroupedTextTemplate(gmt, vars, locale, defaultLocale, translationPath, mts, mtsVars)
if err != nil {
return nil, err
}
tpl, err := parseTemplate(emailTemplatePath, gmt.textTemplate)
if err != nil {
return nil, err
}
textBody, err := groupedEmailTemplate(tpl, textMt)
if err != nil {
return nil, err
}
escapedMtsVars := make([]map[string]string, 0, len(mtsVars))
for _, m := range mtsVars {
escapedMtsVars = append(escapedMtsVars, escapeStringMap(m))
}
htmlMt, err := NewGroupedHTMLTemplate(gmt, escapeStringMap(vars), locale, defaultLocale, translationPath, mts, escapedMtsVars)
if err != nil {
return nil, err
}
htmlTpl, err := parseTemplate(emailTemplatePath, gmt.htmlTemplate)
if err != nil {
return nil, err
}
htmlBody, err := groupedEmailTemplate(htmlTpl, htmlMt)
if err != nil {
return nil, err
}
var data map[string][]byte
if emailTemplatePath != "" {
data, err = readImages(emailTemplatePath)
if err != nil {
return nil, err
}
}
return &channels.Message{
Subject: textMt.Subject,
TextBody: textBody,
HTMLBody: htmlBody,
AttachInline: data,
}, nil
}
// emailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func emailTemplate(tpl *template.Template, mt MessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]any{
"Greeting": template.HTML(strings.TrimSpace(mt.Greeting)), // #nosec G203
"MessageBody": template.HTML(strings.TrimSpace(mt.MessageBody)), // #nosec G203
"CallToAction": template.HTML(strings.TrimSpace(mt.CallToAction)), // #nosec G203
})
if err != nil {
return "", err
}
return str, err
}
// groupedEmailTemplate builds the email template. It does not use any user provided input, so it is safe to use template.HTML.
func groupedEmailTemplate(tpl *template.Template, gmt GroupedMessageTemplate) (string, error) {
str, err := executeTemplate(tpl, map[string]any{
"Greeting": template.HTML(strings.TrimSpace(gmt.Greeting)), // #nosec G203
"MessageBody": template.HTML(strings.TrimSpace(gmt.MessageBody)), // #nosec G203
})
if err != nil {
return "", err
}
return str, err
}
func parseTemplate(emailTemplatePath string, file string) (*template.Template, error) {
if emailTemplatePath != "" {
return template.ParseFiles(filepath.Join(emailTemplatePath, file))
}
return template.ParseFS(templatesFS, filepath.Join(file))
}
func executeTemplate(tpl *template.Template, vars any) (string, error) {
var writer bytes.Buffer
if err := tpl.Execute(&writer, vars); err != nil {
return "", err
}
return writer.String(), nil
}
func readImages(emailTemplatePath string) (map[string][]byte, error) {
dir := filepath.Join(emailTemplatePath, imgDir)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
return read(entries, os.DirFS(emailTemplatePath))
}
func read(entries []fs.DirEntry, fsys fs.FS) (map[string][]byte, error) {
list := make(map[string][]byte)
for _, e := range entries {
if e.IsDir() {
continue
}
file, err := fs.ReadFile(fsys, filepath.Join(imgDir, e.Name()))
if err != nil {
// skip if the symbolic is a directory
if errors.Is(err, syscall.EISDIR) {
continue
}
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
}
func escapeStringMap(vars map[string]string) map[string]string {
escaped := make(map[string]string, len(vars))
for k, v := range vars {
escaped[k] = html.EscapeString(v)
}
return escaped
}