diff --git a/services/notifications/README.md b/services/notifications/README.md index 447b792e96..751c5498ff 100644 --- a/services/notifications/README.md +++ b/services/notifications/README.md @@ -6,7 +6,7 @@ The notification service is responsible for sending emails to users informing th 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. Though the email subject is also part of translations, it has no placeholder as it is a mandatory email component. +The text template can use the placeholders `{{ .Greeting }}`, `{{ .MessageBody }}` and `{{ .CallToAction }}` which are replaced with translations when sent, see the [Translations](#translations) section for more details. Though the email subject is also part of translations, it has no placeholder as it is a mandatory email component. Depending on the email purpose, placeholders will contain different strings. An individual translatable string is available for each purpose, finally resolved by the placeholder. The embedded templates are available for all deployment scenarios. @@ -17,33 +17,89 @@ template 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 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. +### HTML Templates + +The html emails are built from a parent template and two child templates: + +- `layout.html.tmpl` holds everything all emails share: the logo, the heading, + the card around the content, the footer and the styles for dark mode. The + children fill its `content` block. +- `message.html.tmpl` is the content of all emails about a single event. +- `digest.html.tmpl` is the content of the grouped daily and weekly emails, a + timeline of the events. + +The html templates get these values: + +| Value | Content | +| --- | --- | +| `{{ .Subject }}` | The translated subject, shown as the heading. | +| `{{ .Greeting }}` | The translated greeting. | +| `{{ .Paragraphs }}` | The paragraphs of the message shown before the button. Each has a `.Kind` (`text`, `hint` or `code`) and its `.Lines`; a `code` paragraph has a `.Label` and a `.Value` instead, such as the PIN of a guest link. | +| `{{ .ActionLabel }}`, `{{ .ActionLink }}` | The label and the link of the button, empty if the email has no button. | +| `{{ .FallbackLinkLabel }}` | The text in front of the plain link below the button. | +| `{{ .Notes }}` | The paragraphs shown in small print below the button. | +| `{{ .Entries }}` | The events of a grouped email, each with a `.Time` in UTC, its `.Paragraphs` and `.Last` for the last event. In the paragraphs, the name of the shared resource or space is a link to it. | +| `{{ .MessageBody }}`, `{{ .CallToAction }}` | Only for custom folders with the legacy `email.html.tmpl`: the whole message and the call to action as html. In a grouped email, `{{ .MessageBody }}` holds all events, each with its call to action. | + +All values are plain text, `html/template` escapes them. A paragraph is a +list of lines, and a line is a list of segments with a `.Text` and, for a +link, a `.Href`. Both child templates define a `lines` template that renders +them, with a `
` between the lines. A custom child template needs to +define it as well: + +```text +{{ define "lines" }} +{{- range $i, $line := . }}{{ if $i }}
{{ end }} +{{- range $line }} +{{- if .Href }}{{ .Text }} +{{- else }}{{ .Text }}{{ end }} +{{- end }} +{{- end }} +{{- end }} +``` + +The emails show the OpenCloud logo as an inline image, `opencloud-logo.png` in the light and `opencloud-logo-dark.png` in the dark mode of mail clients that support it. + +### Custom Templates + +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/layout.html.tmpl +{NOTIFICATIONS_EMAIL_TEMPLATE_PATH}/templates/html/message.html.tmpl +{NOTIFICATIONS_EMAIL_TEMPLATE_PATH}/templates/html/digest.html.tmpl {NOTIFICATIONS_EMAIL_TEMPLATE_PATH}/templates/html/img/ ``` The source templates provided by OpenCloud you can derive from are located in the following base folder [https://github.com/opencloud-eu/opencloud/tree/main/services/notifications/pkg/email/templates](https://github.com/opencloud-eu/opencloud/tree/main/services/notifications/pkg/email/templates) with subfolders `templates/text` and `templates/html`. - [text/email.text.tmpl](https://github.com/opencloud-eu/opencloud/blob/main/services/notifications/pkg/email/templates/text/email.text.tmpl) -- [html/email.html.tmpl](https://github.com/opencloud-eu/opencloud/blob/main/services/notifications/pkg/email/templates/html/email.html.tmpl) +- [html/layout.html.tmpl](https://github.com/opencloud-eu/opencloud/blob/main/services/notifications/pkg/email/templates/html/layout.html.tmpl) +- [html/message.html.tmpl](https://github.com/opencloud-eu/opencloud/blob/main/services/notifications/pkg/email/templates/html/message.html.tmpl) +- [html/digest.html.tmpl](https://github.com/opencloud-eu/opencloud/blob/main/services/notifications/pkg/email/templates/html/digest.html.tmpl) + +To only change the look shared by all emails, such as the logo or the colors, a custom `layout.html.tmpl` is enough, but the custom folder still needs all three html templates. + +A custom folder made for earlier versions, with a single `templates/html/email.html.tmpl` and no `layout.html.tmpl`, keeps working: the notifications service then uses `email.html.tmpl` with the `{{ .Greeting }}`, `{{ .MessageBody }}` and `{{ .CallToAction }}` placeholders for all html emails. ### Templates subfolder hierarchy ```text templates │ └───html -│ │ email.html.tmpl +│ │ layout.html.tmpl +│ │ message.html.tmpl +│ │ digest.html.tmpl │ │ │ └───img -│ │ logo-mail.gif +│ │ opencloud-logo.png +│ │ opencloud-logo-dark.png │ └───text │ email.text.tmpl ``` 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 OpenCloud. When using a custom HTML template, hosted images can either be linked with standard HTML code like ```logo-mail``` or embedded as a CID source ```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. +When using a custom HTML template, hosted images can either be linked with standard HTML code like ```logo-mail``` or embedded as a CID source ```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. ## Sending Grouped Emails @@ -56,6 +112,8 @@ Grouped events that have passed the TTL are removed automatically without furthe To initiate sending grouped emails like via a cron job, use the `opencloud notifications send-email` command. Note that the command mandatory requires at least one option which is `--daily` or `--weekly`. Note that both options can be used together. +A grouped email has the subject "Your daily notifications" or "Your weekly notifications". The html email shows the events as a timeline, oldest first, each with the time of the event in UTC, as the notifications service does not know the time zone of the recipient. Where an event has a link, the name of the shared resource or space is the link. The text email lists the events with their call to action. + ### Storing The `notifications` service persists information via the configured store in `NOTIFICATIONS_STORE`. Possible stores are: diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go index 7af545b31c..5728725573 100644 --- a/services/notifications/pkg/email/composer.go +++ b/services/notifications/pkg/email/composer.go @@ -7,6 +7,7 @@ import ( "strings" "text/template" + "github.com/leonelquinteros/gotext" "github.com/pkg/errors" "github.com/opencloud-eu/opencloud/pkg/l10n" @@ -22,19 +23,19 @@ var ( func NewTextTemplate(mt MessageTemplate, locale, defaultLocale string, translationPath string, vars map[string]string) (MessageTemplate, error) { var err error t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) - mt.Subject, err = composeMessage(t.Get(mt.Subject, []any{}...), vars) + mt.Subject, err = composeMessage(translate(t, mt.Subject), vars) if err != nil { return mt, err } - mt.Greeting, err = composeMessage(t.Get(mt.Greeting, []any{}...), vars) + mt.Greeting, err = composeMessage(translate(t, mt.Greeting), vars) if err != nil { return mt, err } - mt.MessageBody, err = composeMessage(t.Get(mt.MessageBody, []any{}...), vars) + mt.MessageBody, err = composeMessage(translate(t, mt.MessageBody), vars) if err != nil { return mt, err } - mt.CallToAction, err = composeMessage(t.Get(mt.CallToAction, []any{}...), vars) + mt.CallToAction, err = composeMessage(translate(t, mt.CallToAction), vars) if err != nil { return mt, err } @@ -45,19 +46,19 @@ func NewTextTemplate(mt MessageTemplate, locale, defaultLocale string, translati func NewHTMLTemplate(mt MessageTemplate, locale, defaultLocale string, translationPath string, vars map[string]string) (MessageTemplate, error) { var err error t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) - mt.Subject, err = composeMessage(t.Get(mt.Subject, []any{}...), vars) + mt.Subject, err = composeMessage(translate(t, mt.Subject), vars) if err != nil { return mt, err } - mt.Greeting, err = composeMessage(newlineToBr(t.Get(mt.Greeting, []any{}...)), vars) + mt.Greeting, err = composeMessage(newlineToBr(translate(t, mt.Greeting)), vars) if err != nil { return mt, err } - mt.MessageBody, err = composeMessage(newlineToBr(t.Get(mt.MessageBody, []any{}...)), vars) + mt.MessageBody, err = composeMessage(newlineToBr(translate(t, mt.MessageBody)), vars) if err != nil { return mt, err } - mt.CallToAction, err = composeMessage(callToActionToHTML(t.Get(mt.CallToAction, []any{}...)), vars) + mt.CallToAction, err = composeMessage(callToActionToHTML(translate(t, mt.CallToAction)), vars) if err != nil { return mt, err } @@ -72,21 +73,28 @@ func NewGroupedTextTemplate(gmt GroupedMessageTemplate, vars map[string]string, var err error t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) - gmt.Subject, err = composeMessage(t.Get(gmt.Subject, []any{}...), vars) + gmt.Subject, err = composeMessage(translate(t, gmt.Subject), vars) if err != nil { return gmt, err } - gmt.Greeting, err = composeMessage(t.Get(gmt.Greeting, []any{}...), vars) + gmt.Greeting, err = composeMessage(translate(t, gmt.Greeting), vars) if err != nil { return gmt, err } bodyParts := make([]string, 0, len(mtsVars)) for i, mt := range mts { - bodyPart, err := composeMessage(t.Get(mt.MessageBody, []any{}...), mtsVars[i]) + bodyPart, err := composeMessage(translate(t, mt.MessageBody), mtsVars[i]) if err != nil { return gmt, err } + callToAction, err := composeMessage(translate(t, mt.CallToAction), mtsVars[i]) + if err != nil { + return gmt, err + } + if callToAction = strings.TrimSpace(callToAction); callToAction != "" { + bodyPart += "\n\n" + callToAction + } bodyParts = append(bodyParts, bodyPart) } gmt.MessageBody = strings.Join(bodyParts, "\n\n\n") @@ -101,21 +109,28 @@ func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, var err error t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) - gmt.Subject, err = composeMessage(t.Get(gmt.Subject, []any{}...), vars) + gmt.Subject, err = composeMessage(translate(t, gmt.Subject), vars) if err != nil { return gmt, err } - gmt.Greeting, err = composeMessage(newlineToBr(t.Get(gmt.Greeting, []any{}...)), vars) + gmt.Greeting, err = composeMessage(newlineToBr(translate(t, gmt.Greeting)), vars) if err != nil { return gmt, err } bodyParts := make([]string, 0, len(mtsVars)) for i, mt := range mts { - bodyPart, err := composeMessage(t.Get(mt.MessageBody, []any{}...), mtsVars[i]) + bodyPart, err := composeMessage(newlineToBr(translate(t, mt.MessageBody)), mtsVars[i]) if err != nil { return gmt, err } + callToAction, err := composeMessage(callToActionToHTML(translate(t, mt.CallToAction)), mtsVars[i]) + if err != nil { + return gmt, err + } + if callToAction = strings.TrimSpace(callToAction); callToAction != "" { + bodyPart += "

" + callToAction + } bodyParts = append(bodyParts, bodyPart) } gmt.MessageBody = strings.Join(bodyParts, "


") @@ -123,6 +138,15 @@ func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, return gmt, nil } +// translate returns the translation of s. An empty string stays empty: gotext returns the +// header of the .po file for it. +func translate(t *gotext.Locale, s string) string { + if s == "" { + return "" + } + return t.Get(s) +} + // composeMessage renders the message based on template func composeMessage(tmpl string, vars map[string]string) (string, error) { tpl, err := template.New("").Parse(replacePlaceholders(tmpl)) diff --git a/services/notifications/pkg/email/email.go b/services/notifications/pkg/email/email.go index b511dd6b18..764d68d53c 100644 --- a/services/notifications/pkg/email/email.go +++ b/services/notifications/pkg/email/email.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" "syscall" + "time" "github.com/opencloud-eu/opencloud/services/notifications/pkg/channels" ) @@ -39,24 +40,21 @@ func RenderEmailTemplate(mt MessageTemplate, locale, defaultLocale string, email if err != nil { return nil, err } - htmlMt, err := NewHTMLTemplate(mt, locale, defaultLocale, translationPath, escapeStringMap(vars)) + htmlContent, err := newMessageHTML(mt, locale, defaultLocale, translationPath, vars) if err != nil { return nil, err } - htmlTpl, err := parseTemplate(emailTemplatePath, mt.htmlTemplate) + htmlTpl, err := parseHTMLTemplate(emailTemplatePath, mt.htmlTemplate) if err != nil { return nil, err } - htmlBody, err := emailTemplate(htmlTpl, htmlMt) + htmlBody, err := executeTemplate(htmlTpl, htmlContent) if err != nil { return nil, err } - var data map[string][]byte - if emailTemplatePath != "" { - data, err = readImages(emailTemplatePath) - if err != nil { - return nil, err - } + data, err := readImages(emailTemplatePath) + if err != nil { + return nil, err } return &channels.Message{ @@ -68,7 +66,8 @@ func RenderEmailTemplate(mt MessageTemplate, locale, defaultLocale string, email } // 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) { +// times holds the time of each grouped event, it is shown in the timeline of the html email. +func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale string, emailTemplatePath string, translationPath string, mts []MessageTemplate, mtsVars []map[string]string, times []time.Time) (*channels.Message, error) { textMt, err := NewGroupedTextTemplate(gmt, vars, locale, defaultLocale, translationPath, mts, mtsVars) if err != nil { return nil, err @@ -82,28 +81,21 @@ func RenderGroupedEmailTemplate(gmt GroupedMessageTemplate, vars map[string]stri 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) + htmlContent, err := newDigestHTML(gmt, vars, locale, defaultLocale, translationPath, mts, mtsVars, times) if err != nil { return nil, err } - htmlTpl, err := parseTemplate(emailTemplatePath, gmt.htmlTemplate) + htmlTpl, err := parseHTMLTemplate(emailTemplatePath, gmt.htmlTemplate) if err != nil { return nil, err } - htmlBody, err := groupedEmailTemplate(htmlTpl, htmlMt) + htmlBody, err := executeTemplate(htmlTpl, htmlContent) if err != nil { return nil, err } - var data map[string][]byte - if emailTemplatePath != "" { - data, err = readImages(emailTemplatePath) - if err != nil { - return nil, err - } + data, err := readImages(emailTemplatePath) + if err != nil { + return nil, err } return &channels.Message{ @@ -146,6 +138,21 @@ func parseTemplate(emailTemplatePath string, file string) (*template.Template, e return template.ParseFS(templatesFS, filepath.Join(file)) } +// parseHTMLTemplate parses the html layout together with the given child template. A custom +// template folder without the layout is a folder made for the legacy single html template, +// which is used instead; it gets the same data with the Greeting, MessageBody and CallToAction +// placeholders. +func parseHTMLTemplate(emailTemplatePath string, child string) (*template.Template, error) { + if emailTemplatePath == "" { + return template.ParseFS(templatesFS, _htmlLayout, child) + } + layout := filepath.Join(emailTemplatePath, _htmlLayout) + if _, err := os.Stat(layout); errors.Is(err, fs.ErrNotExist) { + return template.ParseFiles(filepath.Join(emailTemplatePath, _htmlLegacyTemplate)) + } + return template.ParseFiles(layout, filepath.Join(emailTemplatePath, child)) +} + func executeTemplate(tpl *template.Template, vars any) (string, error) { var writer bytes.Buffer if err := tpl.Execute(&writer, vars); err != nil { @@ -154,7 +161,16 @@ func executeTemplate(tpl *template.Template, vars any) (string, error) { return writer.String(), nil } +// readImages reads the images that are attached inline, from the custom template folder if one +// is set, else the embedded ones func readImages(emailTemplatePath string) (map[string][]byte, error) { + if emailTemplatePath == "" { + entries, err := fs.ReadDir(templatesFS, imgDir) + if err != nil { + return nil, err + } + return read(entries, templatesFS) + } dir := filepath.Join(emailTemplatePath, imgDir) entries, err := os.ReadDir(dir) if err != nil { diff --git a/services/notifications/pkg/email/email_test.go b/services/notifications/pkg/email/email_test.go index 01df4094f8..6df1ad9228 100644 --- a/services/notifications/pkg/email/email_test.go +++ b/services/notifications/pkg/email/email_test.go @@ -1,6 +1,9 @@ package email -import "testing" +import ( + "strings" + "testing" +) // Regression for #2804: escapeStringMap used to mutate its input map, and the // recipient render loop reuses that map across iterations. @@ -23,3 +26,53 @@ func TestEscapeStringMapDoesNotMutateInput(t *testing.T) { input["SpaceName"], raw) } } + +// Regression: the ScienceMesh subject showed the literal {InitiatorName} placeholder. +func TestScienceMeshSubjectNamesInitiator(t *testing.T) { + vars := map[string]string{ + "ShareSharer": "Anna", + "InitiatorName": "Anna", + "ShareSharerMail": "anna@example.org", + "ShareLink": "https://cloud.example.org/accept", + "Token": "token", + "ProviderDomain": "cloud.example.org", + } + for locale, want := range map[string]string{ + "en": "ScienceMesh: Anna wants to collaborate with you", + "es": "ScienceMesh: Anna quiere colaborar contigo", + } { + msg, err := RenderEmailTemplate(ScienceMeshInviteTokenGenerated, locale, "en", "", "", vars) + if err != nil { + t.Fatal(err) + } + if msg.Subject != want { + t.Errorf("%s: got subject %q, want %q", locale, msg.Subject, want) + } + } +} + +// Regression: translating an empty string returns the header of the .po file, so emails +// without a call to action got "Project-Id-Version: …" in every translated language. +func TestEmptyStringsAreNotTranslated(t *testing.T) { + vars := map[string]string{"ShareGrantee": "Ben", "ShareSharer": "Anna", "ShareFolder": "Q3 Report"} + + msg, err := RenderGroupedEmailTemplate(GroupedDaily, map[string]string{"DisplayName": "Ben"}, "de", "en", "", "", + []MessageTemplate{ShareRemoved}, []map[string]string{vars}, nil) + if err != nil { + t.Fatal(err) + } + single, err := RenderEmailTemplate(ShareRemoved, "de", "en", "", "", vars) + if err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "digest text": msg.TextBody, + "digest html": msg.HTMLBody, + "single text": single.TextBody, + "single html": single.HTMLBody, + } { + if strings.Contains(body, "Project-Id-Version") { + t.Errorf("%s contains the header of the translation file:\n%s", name, body) + } + } +} diff --git a/services/notifications/pkg/email/html.go b/services/notifications/pkg/email/html.go new file mode 100644 index 0000000000..a4d8cc2157 --- /dev/null +++ b/services/notifications/pkg/email/html.go @@ -0,0 +1,226 @@ +package email + +import ( + "html/template" + "maps" + "strings" + "time" + + "github.com/opencloud-eu/opencloud/pkg/l10n" +) + +// htmlContent is the data of the html templates. All text is plain text with unescaped +// variables, html/template escapes it. +type htmlContent struct { + Subject string + Greeting string + // Paragraphs are shown before the button, Notes after it + Paragraphs []htmlParagraph + Notes []htmlParagraph + ActionLabel string + ActionLink string + FallbackLinkLabel string + // Entries are the events of a digest email + Entries []digestEntry + + // MessageBody and CallToAction are the placeholders of the legacy single html template + MessageBody template.HTML + CallToAction template.HTML +} + +// htmlParagraph is a paragraph of the message body, Kind is one of "text", "hint", "code" and "note". +// A code paragraph has a Label and a Value instead of Lines. +type htmlParagraph struct { + Kind string + Lines []htmlLine + Label string + Value string +} + +// htmlLine is a line of a paragraph, a segment with a Href is shown as a link +type htmlLine []htmlSegment + +type htmlSegment struct { + Text string + Href string +} + +// digestEntry is one event in the timeline of a digest email +type digestEntry struct { + Time string + Paragraphs []htmlParagraph + Last bool +} + +// _linkMarker stands in for the linked resource name while the message is composed +const _linkMarker = "\x00oc-link\x00" + +// newMessageHTML translates the template and prepares the data of the html templates +func newMessageHTML(mt MessageTemplate, locale, defaultLocale, translationPath string, vars map[string]string) (htmlContent, error) { + legacy, err := NewHTMLTemplate(mt, locale, defaultLocale, translationPath, escapeStringMap(vars)) + if err != nil { + return htmlContent{}, err + } + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) + content := htmlContent{ + MessageBody: legacyHTML(legacy.MessageBody), + CallToAction: legacyHTML(legacy.CallToAction), + } + if content.Subject, err = composeMessage(translate(t, mt.Subject), vars); err != nil { + return htmlContent{}, err + } + if content.Greeting, err = composeMessage(translate(t, mt.Greeting), vars); err != nil { + return htmlContent{}, err + } + body, err := composeMessage(translate(t, mt.MessageBody), vars) + if err != nil { + return htmlContent{}, err + } + for _, p := range layoutParagraphs(body, mt.bodyLayout, nil) { + if p.Kind == "note" { + content.Notes = append(content.Notes, p) + continue + } + content.Paragraphs = append(content.Paragraphs, p) + } + if mt.actionLink != "" { + if content.ActionLink, err = composeMessage(mt.actionLink, vars); err != nil { + return htmlContent{}, err + } + content.ActionLabel = translate(t, mt.ActionLabel) + content.FallbackLinkLabel = translate(t, _fallbackLinkLabel) + } + return content, nil +} + +// newDigestHTML translates the grouped template and prepares the data of the digest html template +func newDigestHTML(gmt GroupedMessageTemplate, vars map[string]string, locale, defaultLocale, translationPath string, mts []MessageTemplate, mtsVars []map[string]string, times []time.Time) (htmlContent, error) { + escapedMtsVars := make([]map[string]string, 0, len(mtsVars)) + for _, m := range mtsVars { + escapedMtsVars = append(escapedMtsVars, escapeStringMap(m)) + } + legacy, err := NewGroupedHTMLTemplate(gmt, escapeStringMap(vars), locale, defaultLocale, translationPath, mts, escapedMtsVars) + if err != nil { + return htmlContent{}, err + } + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) + content := htmlContent{MessageBody: legacyHTML(legacy.MessageBody)} + if content.Subject, err = composeMessage(translate(t, gmt.Subject), vars); err != nil { + return htmlContent{}, err + } + if content.Greeting, err = composeMessage(translate(t, gmt.Greeting), vars); err != nil { + return htmlContent{}, err + } + for i, mt := range mts { + entryVars, link, err := markResourceName(mt, mtsVars[i]) + if err != nil { + return htmlContent{}, err + } + body, err := composeMessage(translate(t, mt.MessageBody), entryVars) + if err != nil { + return htmlContent{}, err + } + entry := digestEntry{Paragraphs: layoutParagraphs(body, mt.bodyLayout, link)} + if i < len(times) { + entry.Time = formatEntryTime(times[i]) + } + content.Entries = append(content.Entries, entry) + } + if n := len(content.Entries); n > 0 { + content.Entries[n-1].Last = true + } + return content, nil +} + +// markResourceName replaces the resource name of a digest entry with a marker, so that it can +// be shown as a link to the resource. It returns the variables to compose the message with and +// the segment the marker stands for, nil if the template has no link. +func markResourceName(mt MessageTemplate, vars map[string]string) (map[string]string, *htmlSegment, error) { + if mt.digestLinkVar == "" || mt.actionLink == "" { + return vars, nil, nil + } + link, err := composeMessage(mt.actionLink, vars) + if err != nil { + return nil, nil, err + } + marked := maps.Clone(vars) + marked[mt.digestLinkVar] = _linkMarker + return marked, &htmlSegment{Text: vars[mt.digestLinkVar], Href: link}, nil +} + +// layoutParagraphs splits a composed message body into paragraphs and gives each the role of +// the layout. The roles only apply if the number of paragraphs matches the layout, so a +// translation that merges or splits paragraphs is shown as plain text. Markers in the body +// are replaced with the link segment. +func layoutParagraphs(body string, layout []paragraphRole, link *htmlSegment) []htmlParagraph { + var parts []string + for _, part := range strings.Split(strings.TrimSpace(body), "\n\n") { + if part = strings.TrimSpace(part); part != "" { + parts = append(parts, part) + } + } + if len(parts) != len(layout) { + layout = nil + } + + paragraphs := make([]htmlParagraph, 0, len(parts)) + for i, part := range parts { + p := htmlParagraph{Kind: "text", Lines: splitLines(part, link)} + if layout != nil { + switch layout[i] { + case paragraphHint: + p.Kind = "hint" + case paragraphNote: + p.Kind = "note" + case paragraphCode: + if label, value, ok := cutLast(part, ":"); ok { + p = htmlParagraph{Kind: "code", Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)} + } + } + } + paragraphs = append(paragraphs, p) + } + return paragraphs +} + +// splitLines splits a paragraph into lines of text segments, with the link in place of each marker +func splitLines(paragraph string, link *htmlSegment) []htmlLine { + var lines []htmlLine + for _, text := range strings.Split(paragraph, "\n") { + var line htmlLine + for i, part := range strings.Split(text, _linkMarker) { + if i > 0 && link != nil { + line = append(line, *link) + } + if part != "" { + line = append(line, htmlSegment{Text: part}) + } + } + lines = append(lines, line) + } + return lines +} + +// cutLast slices s around the last instance of sep +func cutLast(s, sep string) (before, after string, found bool) { + if i := strings.LastIndex(s, sep); i >= 0 { + return s[:i], s[i+len(sep):], true + } + return s, "", false +} + +// formatEntryTime formats the time of a digest entry, the notifications service does not know +// the time zone of the recipient +func formatEntryTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02 15:04") + " UTC" +} + +// legacyHTML marks the html for the legacy single html template as safe. It is composed from +// translations and from variables that escapeStringMap escaped before. +func legacyHTML(s string) template.HTML { + // nosemgrep + return template.HTML(strings.TrimSpace(s)) // #nosec G203 +} diff --git a/services/notifications/pkg/email/html_test.go b/services/notifications/pkg/email/html_test.go new file mode 100644 index 0000000000..b1352a3728 --- /dev/null +++ b/services/notifications/pkg/email/html_test.go @@ -0,0 +1,177 @@ +package email + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLayoutParagraphs(t *testing.T) { + body := "A new link has been requested.\n\nYour PIN: 482193\n\nDo not forward it.\n\nOnly valid for 30 minutes." + got := layoutParagraphs(body, []paragraphRole{paragraphText, paragraphCode, paragraphText, paragraphNote}, nil) + + wantKinds := []string{"text", "code", "text", "note"} + if len(got) != len(wantKinds) { + t.Fatalf("got %d paragraphs, want %d", len(got), len(wantKinds)) + } + for i, k := range wantKinds { + if got[i].Kind != k { + t.Errorf("paragraph %d: got kind %q, want %q", i, got[i].Kind, k) + } + } + if got[1].Label != "Your PIN" || got[1].Value != "482193" { + t.Errorf("code paragraph: got label %q value %q", got[1].Label, got[1].Value) + } +} + +func TestLayoutParagraphsIgnoresLayoutOfOtherLength(t *testing.T) { + // a translation that merged two paragraphs must not shift the roles + got := layoutParagraphs("Merged first and second.\n\nThird.", []paragraphRole{paragraphText, paragraphCode, paragraphNote}, nil) + for i, p := range got { + if p.Kind != "text" { + t.Errorf("paragraph %d: got kind %q, want text", i, p.Kind) + } + } +} + +func TestRenderEmailTemplateHTML(t *testing.T) { + msg, err := RenderEmailTemplate(GuestLinkShareRenewed, "en", "en", "", "", map[string]string{ + "ShareSharer": "Anna Lindqvist", + "ShareFolder": "Q3 Report", + "SharePin": "482193", + "ShareLink": "https://cloud.example.com/s/Xk29fQ?a=1&b=2", + }) + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + `

Your PIN`, + `>482193`, + `href="https://cloud.example.com/s/Xk29fQ?a=1&b=2"`, + ">Open link", + "Or open this link:", + "The link and the PIN are only valid for 30 minutes.", + `src="cid:opencloud-logo.png"`, + `src="cid:opencloud-logo-dark.png"`, + "prefers-color-scheme: dark", + } { + if !strings.Contains(msg.HTMLBody, want) { + t.Errorf("html body is missing %q", want) + } + } + if strings.Contains(msg.HTMLBody, "Lindqvist") { + t.Error("html body contains unescaped user input") + } + for _, name := range []string{"opencloud-logo.png", "opencloud-logo-dark.png"} { + if len(msg.AttachInline[name]) == 0 { + t.Errorf("inline attachment %q is missing", name) + } + } + // the plain text email keeps the full message body and call to action + if !strings.Contains(msg.TextBody, "Your PIN: 482193") || !strings.Contains(msg.TextBody, "Click here to view it: https://cloud.example.com/s/Xk29fQ?a=1&b=2") { + t.Errorf("unexpected text body:\n%s", msg.TextBody) + } +} + +func TestRenderEmailTemplateLegacyCustomFolder(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + _textTemplate: "{{ .Greeting }} {{ .MessageBody }}", + _htmlLegacyTemplate: "

{{ .Greeting }}

{{ .MessageBody }}

{{ .CallToAction }}

", + } { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(dir, imgDir), 0o700); err != nil { + t.Fatal(err) + } + + msg, err := RenderEmailTemplate(ShareCreated, "en", "en", dir, "", map[string]string{ + "ShareGrantee": "Ben", + "ShareSharer": "Anna", + "ShareFolder": "Q3 Report", + "ShareLink": "https://cloud.example.com/files/shares/with-me", + }) + if err != nil { + t.Fatal(err) + } + want := `

Hello Ben

Anna has shared "Q3 Report" with you.

Click here to view it: https://cloud.example.com/files/shares/with-me

` + if msg.HTMLBody != want { + t.Errorf("got html body\n%s\nwant\n%s", msg.HTMLBody, want) + } +} + +func TestRenderGroupedEmailTemplateTimeline(t *testing.T) { + cet := time.FixedZone("CET", 3600) + msg, err := RenderGroupedEmailTemplate(GroupedWeekly, map[string]string{"DisplayName": "Ben"}, "en", "en", "", "", + []MessageTemplate{ShareCreated, UnsharedSpace}, + []map[string]string{ + {"ShareSharer": "Anna", "ShareFolder": "Q3 Report", "ShareLink": "https://cloud.example.com/files/shares/with-me"}, + {"SpaceSharer": "Jonas", "SpaceName": "Nostromo", "ShareLink": "https://cloud.example.com/f/spaceid"}, + }, + []time.Time{time.Date(2026, 10, 21, 10, 12, 0, 0, cet), {}}, + ) + if err != nil { + t.Fatal(err) + } + if msg.Subject != "Your weekly notifications" { + t.Errorf("got subject %q", msg.Subject) + } + for _, want := range []string{ + "Hi Ben,", + ">2026-10-21 09:12 UTC", + `

You might still have access through your other groups or direct membership.

`, + } { + if !strings.Contains(msg.HTMLBody, want) { + t.Errorf("html body is missing %q", want) + } + } + for _, want := range []string{ + `Anna has shared "Q3 Report" with you.`, + `Jonas has removed you from "Nostromo".`, + } { + if !strings.Contains(msg.HTMLBody, want) { + t.Errorf("html body is missing the linked resource name %q", want) + } + } + for _, want := range []string{ + "Click here to view it: https://cloud.example.com/files/shares/with-me", + "Click here to check it: https://cloud.example.com/f/spaceid", + } { + if !strings.Contains(msg.TextBody, want) { + t.Errorf("text body is missing %q", want) + } + } + // only the line between the two entries is drawn + if n := strings.Count(msg.HTMLBody, `class="oc-line"`); n != 1 { + t.Errorf("got %d timeline line segments, want 1", n) + } +} + +func TestDigestLinkIsEscaped(t *testing.T) { + msg, err := RenderGroupedEmailTemplate(GroupedDaily, map[string]string{"DisplayName": "Ben"}, "en", "en", "", "", + []MessageTemplate{ShareCreated}, + []map[string]string{{"ShareSharer": "Anna", "ShareFolder": "", "ShareLink": "javascript:alert(1)"}}, + nil, + ) + if err != nil { + t.Fatal(err) + } + if strings.Contains(msg.HTMLBody, "") { + t.Error("digest html does not show the escaped resource name as link text") + } +} diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index 38af45ed85..d35a08d319 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -4,7 +4,13 @@ import "github.com/opencloud-eu/opencloud/pkg/l10n" const ( _textTemplate = "templates/text/email.text.tmpl" - _htmlTemplate = "templates/html/email.html.tmpl" + // _htmlLayout is the parent of every html email, the children fill its "content" block + _htmlLayout = "templates/html/layout.html.tmpl" + _htmlTemplate = "templates/html/message.html.tmpl" + _htmlDigest = "templates/html/digest.html.tmpl" + // _htmlLegacyTemplate is the single html template used before the layout was split, + // custom template folders that only contain this file keep working with it + _htmlLegacyTemplate = "templates/html/email.html.tmpl" ) // the available templates @@ -21,6 +27,10 @@ var ( MessageBody: l10n.Template(`{ShareSharer} has shared "{ShareFolder}" with you.`), // ShareCreated email template, resolves via {{ .CallToAction }} CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), + // ShareCreated email template, label of the button in the html email + ActionLabel: l10n.Template(`View shares`), + actionLink: `{ShareLink}`, + digestLinkVar: "ShareFolder", } ShareRemoved = MessageTemplate{ @@ -34,6 +44,7 @@ var ( MessageBody: l10n.Template(`{ShareSharer} has unshared '{ShareFolder}' with you. Even though this share has been revoked you still might have access through other shares and/or space memberships.`), + bodyLayout: []paragraphRole{paragraphText, paragraphHint}, } ShareExpired = MessageTemplate{ @@ -47,6 +58,7 @@ Even though this share has been revoked you still might have access through othe MessageBody: l10n.Template(`Your share to {ShareFolder} has expired at {ExpiredAt} Even though this share has been revoked you still might have access through other shares and/or space memberships.`), + bodyLayout: []paragraphRole{paragraphText, paragraphHint}, } // Guest link templates @@ -65,6 +77,10 @@ The link below is personal and can only be used once. Please do not forward it. The link is only valid for 30 minutes.`), // GuestLinkShareCreated email template, resolves via {{ .CallToAction }} CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), + // GuestLinkShareCreated email template, label of the button in the html email + ActionLabel: l10n.Template(`Open link`), + actionLink: `{ShareLink}`, + bodyLayout: []paragraphRole{paragraphText, paragraphText, paragraphNote}, } GuestLinkShareRenewed = MessageTemplate{ @@ -84,6 +100,10 @@ Open the link below or enter the PIN on the page where you requested the new lin The link and the PIN are only valid for 30 minutes.`), // GuestLinkShareRenewed email template, resolves via {{ .CallToAction }} CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), + // GuestLinkShareRenewed email template, label of the button in the html email + ActionLabel: l10n.Template(`Open link`), + actionLink: `{ShareLink}`, + bodyLayout: []paragraphRole{paragraphText, paragraphCode, paragraphText, paragraphNote}, } GuestLinkShareRenewedWithoutPin = MessageTemplate{ @@ -101,6 +121,10 @@ The link below is personal and can only be used once. Please do not forward it. The link is only valid for 30 minutes.`), // GuestLinkShareRenewedWithoutPin email template, resolves via {{ .CallToAction }} CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), + // GuestLinkShareRenewedWithoutPin email template, label of the button in the html email + ActionLabel: l10n.Template(`Open link`), + actionLink: `{ShareLink}`, + bodyLayout: []paragraphRole{paragraphText, paragraphText, paragraphNote}, } // Spaces templates @@ -115,6 +139,10 @@ The link is only valid for 30 minutes.`), MessageBody: l10n.Template(`{SpaceSharer} has invited you to join "{SpaceName}".`), // SharedSpace email template, resolves via {{ .CallToAction }} CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), + // SharedSpace email template, label of the button in the html email + ActionLabel: l10n.Template(`Open space`), + actionLink: `{ShareLink}`, + digestLinkVar: "SpaceName", } UnsharedSpace = MessageTemplate{ @@ -130,6 +158,11 @@ The link is only valid for 30 minutes.`), You might still have access through your other groups or direct membership.`), // UnsharedSpace email template, resolves via {{ .CallToAction }} CallToAction: l10n.Template(`Click here to check it: {ShareLink}`), + // UnsharedSpace email template, label of the button in the html email + ActionLabel: l10n.Template(`Check access`), + actionLink: `{ShareLink}`, + bodyLayout: []paragraphRole{paragraphText, paragraphHint}, + digestLinkVar: "SpaceName", } MembershipExpired = MessageTemplate{ @@ -143,6 +176,7 @@ You might still have access through your other groups or direct membership.`), MessageBody: l10n.Template(`Your membership of space {SpaceName} has expired at {ExpiredAt} Even though this membership has expired you still might have access through other shares and/or space memberships`), + bodyLayout: []paragraphRole{paragraphText, paragraphHint}, } ScienceMeshInviteTokenGenerated = MessageTemplate{ @@ -176,16 +210,29 @@ Please visit your federation settings and use the following details: ProviderDomain: {ProviderDomain}`), } - Grouped = GroupedMessageTemplate{ + GroupedDaily = GroupedMessageTemplate{ textTemplate: _textTemplate, - htmlTemplate: _htmlTemplate, - // Grouped email template, Subject field (resolves directly) - Subject: l10n.Template(`Report`), // TODO find meaningful subject - // Grouped email template, resolves via {{ .Greeting }} + htmlTemplate: _htmlDigest, + // GroupedDaily email template, Subject field (resolves directly) + Subject: l10n.Template(`Your daily notifications`), + // GroupedDaily email template, resolves via {{ .Greeting }} Greeting: l10n.Template(`Hi {DisplayName},`), MessageBody: "", // is generated using the GroupedTemplates } + GroupedWeekly = GroupedMessageTemplate{ + textTemplate: _textTemplate, + htmlTemplate: _htmlDigest, + // GroupedWeekly email template, Subject field (resolves directly) + Subject: l10n.Template(`Your weekly notifications`), + // GroupedWeekly email template, resolves via {{ .Greeting }} + Greeting: l10n.Template(`Hi {DisplayName},`), + MessageBody: "", // is generated using the GroupedTemplates + } + + // fallbackLinkLabel introduces the plain link below the button of the html email + _fallbackLinkLabel = l10n.Template(`Or open this link:`) + Mention = MessageTemplate{ textTemplate: _textTemplate, htmlTemplate: _htmlTemplate, @@ -193,6 +240,9 @@ Please visit your federation settings and use the following details: Greeting: l10n.Template(`Hello {RecipientName},`), MessageBody: l10n.Template(`{AuthorName} mentioned you in "{ResourceName}".`), CallToAction: l10n.Template(`You can view the mention here: {ResourceLink}`), + // Mention email template, label of the button in the html email + ActionLabel: l10n.Template(`View mention`), + actionLink: `{ResourceLink}`, } ) @@ -208,6 +258,7 @@ var _placeholders = map[string]string{ "{SpaceSharer}": "{{ .SpaceSharer }}", "{ExpiredAt}": "{{ .ExpiredAt }}", "{ShareSharerMail}": "{{ .ShareSharerMail }}", + "{InitiatorName}": "{{ .InitiatorName }}", "{ProviderDomain}": "{{ .ProviderDomain }}", "{Token}": "{{ .Token }}", "{DisplayName}": "{{ .DisplayName }}", @@ -217,6 +268,20 @@ var _placeholders = map[string]string{ "{ResourceLink}": "{{ .ResourceLink }}", } +// paragraphRole tells the html template how to show a paragraph of the message body +type paragraphRole int + +const ( + // paragraphText is a regular paragraph + paragraphText paragraphRole = iota + // paragraphHint is a secondary paragraph shown in a muted color + paragraphHint + // paragraphCode holds a "label: value" pair, the value is shown large in a box + paragraphCode + // paragraphNote is shown in small print below the button + paragraphNote +) + // MessageTemplate is the data structure for the email type MessageTemplate struct { // textTemplate represent the path to text plain .tmpl file @@ -228,6 +293,17 @@ type MessageTemplate struct { Greeting string MessageBody string CallToAction string + // ActionLabel is the label of the button in the html email + ActionLabel string + // actionLink is the placeholder of the link the button opens + actionLink string + // digestLinkVar names the variable that is shown as the link to the resource in the + // timeline of a grouped email + digestLinkVar string + // bodyLayout gives the role of each paragraph of the message body in the html email, + // paragraphs are separated by an empty line. Without a layout, or if a translation has a + // different number of paragraphs, all paragraphs are regular text. + bodyLayout []paragraphRole } // GroupedMessageTemplate is the data structure for the email diff --git a/services/notifications/pkg/email/templates/html/digest.html.tmpl b/services/notifications/pkg/email/templates/html/digest.html.tmpl new file mode 100644 index 0000000000..4acd15b1c7 --- /dev/null +++ b/services/notifications/pkg/email/templates/html/digest.html.tmpl @@ -0,0 +1,40 @@ +{{ define "lines" }}{{ range $i, $line := . }}{{ if $i }}
{{ end }}{{ range $line }}{{ if .Href }}{{ .Text }}{{ else }}{{ .Text }}{{ end }}{{ end }}{{ end }}{{ end }} +{{ define "content" }} + + +

{{ .Greeting }}

+ + + + + +{{- range $i, $entry := .Entries }} + + + + + + + +{{- if $entry.Last }} + +{{- else }} + +{{- end }} + + + + +{{- end }} +
{{ $entry.Time }}
+{{- range $entry.Paragraphs }} +{{- if eq .Kind "hint" }} +

{{ template "lines" .Lines }}

+{{- else }} +

{{ template "lines" .Lines }}

+{{- end }} +{{- end }} +
+ + +{{ end }} diff --git a/services/notifications/pkg/email/templates/html/email.html.tmpl b/services/notifications/pkg/email/templates/html/email.html.tmpl deleted file mode 100644 index 035feb1b0f..0000000000 --- a/services/notifications/pkg/email/templates/html/email.html.tmpl +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - -
- - - - - - - - - - - - - - - -
  - {{ .Greeting }} -

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

- {{ .CallToAction }}{{end}} -
 
  - -
 
-
- - diff --git a/services/notifications/pkg/email/templates/html/img/opencloud-logo-dark.png b/services/notifications/pkg/email/templates/html/img/opencloud-logo-dark.png new file mode 100644 index 0000000000..fc1d67e513 Binary files /dev/null and b/services/notifications/pkg/email/templates/html/img/opencloud-logo-dark.png differ diff --git a/services/notifications/pkg/email/templates/html/img/opencloud-logo.png b/services/notifications/pkg/email/templates/html/img/opencloud-logo.png new file mode 100644 index 0000000000..049b3d111c Binary files /dev/null and b/services/notifications/pkg/email/templates/html/img/opencloud-logo.png differ diff --git a/services/notifications/pkg/email/templates/html/layout.html.tmpl b/services/notifications/pkg/email/templates/html/layout.html.tmpl new file mode 100644 index 0000000000..d3090b95c5 --- /dev/null +++ b/services/notifications/pkg/email/templates/html/layout.html.tmpl @@ -0,0 +1,73 @@ + + + + + + + +{{ .Subject }} + + + + + + + + + + diff --git a/services/notifications/pkg/email/templates/html/message.html.tmpl b/services/notifications/pkg/email/templates/html/message.html.tmpl new file mode 100644 index 0000000000..afd1bef9d5 --- /dev/null +++ b/services/notifications/pkg/email/templates/html/message.html.tmpl @@ -0,0 +1,50 @@ +{{ define "lines" }}{{ range $i, $line := . }}{{ if $i }}
{{ end }}{{ range $line }}{{ if .Href }}{{ .Text }}{{ else }}{{ .Text }}{{ end }}{{ end }}{{ end }}{{ end }} +{{ define "content" }} + + +

{{ .Greeting }}

+{{- range .Paragraphs }} +{{- if eq .Kind "code" }} + + + + + +{{- else if eq .Kind "hint" }} +

{{ template "lines" .Lines }}

+{{- else }} +

{{ template "lines" .Lines }}

+{{- end }} +{{- end }} + + +{{- if .ActionLink }} + + + + + + +
+ {{ .ActionLabel }} +
+ + + + + {{ .FallbackLinkLabel }} {{ .ActionLink }} + + +{{- end }} +{{- range .Notes }} + + {{ template "lines" .Lines }} + +{{- end }} + +   + +{{ end }} diff --git a/services/notifications/pkg/service/job.go b/services/notifications/pkg/service/job.go index 96884865e6..afb98352da 100644 --- a/services/notifications/pkg/service/job.go +++ b/services/notifications/pkg/service/job.go @@ -2,12 +2,14 @@ package service import ( "context" + "time" "github.com/opencloud-eu/opencloud/pkg/l10n" ehmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/eventhistory/v0" "github.com/opencloud-eu/opencloud/services/notifications/pkg/channels" "github.com/opencloud-eu/opencloud/services/notifications/pkg/email" "github.com/opencloud-eu/reva/v2/pkg/events" + "github.com/opencloud-eu/reva/v2/pkg/utils" "github.com/rs/zerolog" ) @@ -39,11 +41,11 @@ func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEv }() for job := range jobs { - go s.createGroupedMail(ctx, logger, job) + go s.createGroupedMail(ctx, logger, sendEmailsEvent.Interval, job) } } -func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Logger, key string) { +func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Logger, interval, key string) { userEvents, err := s.userEventStore.pop(ctx, key) if err != nil { logger.Error().Err(err).Str("key", key).Msg("could not pop user events") @@ -52,6 +54,7 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Lo var mts []email.MessageTemplate var mtsVars []map[string]string + var times []time.Time locale := l10n.MustGetUserLocale(ctx, userEvents.User.GetId().GetOpaqueId(), "", s.valueService) for _, e := range userEvents.Events { @@ -74,6 +77,7 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Lo "SpaceName": spaceName, "ShareLink": shareLink, }) + times = append(times, te.Timestamp) case events.SpaceUnshared: logger := logger.With(). Str("event", "SpaceUnshared"). @@ -91,12 +95,14 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Lo "SpaceName": spaceName, "ShareLink": shareLink, }) + times = append(times, te.Timestamp) case events.SpaceMembershipExpired: mts = append(mts, email.MembershipExpired) mtsVars = append(mtsVars, map[string]string{ "SpaceName": te.SpaceName, "ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"), }) + times = append(times, te.ExpiredAt) case events.ShareCreated: logger := logger.With(). Str("event", "ShareCreated"). @@ -114,6 +120,7 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Lo "ShareFolder": shareFolder, "ShareLink": shareLink, }) + times = append(times, utils.TSToTime(te.CTime)) case events.ShareExpired: logger := logger.With(). Str("event", "ShareCreated"). @@ -130,6 +137,7 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Lo "ShareFolder": shareFolder, "ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"), }) + times = append(times, te.ExpiredAt) case events.ShareRemoved: logger := logger.With(). Str("event", "ShareRemoved"). @@ -147,14 +155,19 @@ func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Lo "ShareSharer": executant.GetDisplayName(), "ShareFolder": shareFolder, }) + times = append(times, te.Timestamp) default: logger.Error().Str("eventType", e.Type).Msg("unsupported event type for grouped email") } } - rendered, err := email.RenderGroupedEmailTemplate(email.Grouped, map[string]string{ + grouped := email.GroupedDaily + if interval == _intervalWeekly { + grouped = email.GroupedWeekly + } + rendered, err := email.RenderGroupedEmailTemplate(grouped, map[string]string{ "DisplayName": userEvents.User.GetDisplayName(), - }, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, mts, mtsVars) + }, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, mts, mtsVars, times) if err != nil { logger.Error().Err(err).Msg("could not render template") return diff --git a/services/notifications/pkg/service/sciencemesh.go b/services/notifications/pkg/service/sciencemesh.go index f198648323..ed5d3d9dc8 100644 --- a/services/notifications/pkg/service/sciencemesh.go +++ b/services/notifications/pkg/service/sciencemesh.go @@ -34,6 +34,7 @@ func (s eventsNotifier) handleScienceMeshInviteTokenGenerated(e events.ScienceMe msgENV := map[string]string{ "ShareSharer": owner.GetDisplayName(), + "InitiatorName": owner.GetDisplayName(), "ShareSharerMail": owner.GetMail(), "ShareLink": e.InviteLink, "Token": e.Token, diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go index 5b7de3bc44..738da354d5 100644 --- a/services/notifications/pkg/service/service_test.go +++ b/services/notifications/pkg/service/service_test.go @@ -284,48 +284,13 @@ Click here to view it: https://cloud.example.com/g/v1.sharehash.secret OpenCloud - a safe home for all your data https://opencloud.eu `, - expectedHTMLBody: ` - - - - - - -
- - - - - - - - - - - - - - - -
  - Hello, -

- Dr. S. Harer has shared "guest folder" with you.

The link below is personal and can only be used once. Please do not forward it.

The link is only valid for 30 minutes. -

- Click here to view it: https://cloud.example.com/g/v1.sharehash.secret -
 
  - -
 
-
- - -`, + expectedHTMLParts: []string{ + `

Hello,

`, + `

Dr. S. Harer has shared "guest folder" with you.

`, + `

The link below is personal and can only be used once. Please do not forward it.

`, + `>Open link`, + `The link is only valid for 30 minutes.`, + }, expectedSender: sharer.GetDisplayName(), expectedLink: testOpenCloudURL + "/g/v1.sharehash.secret", done: make(chan struct{}), @@ -506,48 +471,11 @@ Click here to view it: https://cloud.example.com/files/shares/with-me OpenCloud - a safe home for all your data https://opencloud.eu `, - expectedHTMLBody: ` - - - - - - -
- - - - - - - - - - - - - - - -
  - Hello <script>alert('Eric Expireling');</script> -

- Dr. O'reilly has shared "<script>alert('secrets of the board');</script>" with you. -

- Click here to view it: https://cloud.example.com/files/shares/with-me -
 
  - -
 
-
- - -`, + expectedHTMLParts: []string{ + `

Hello <script>alert('Eric Expireling');</script>

`, + `

Dr. O'reilly has shared "<script>alert('secrets of the board');</script>" with you.

`, + `>View shares`, + }, expectedSender: sharer.GetDisplayName(), expectedLink: testOpenCloudURL + "/files/shares/with-me", @@ -576,48 +504,11 @@ OpenCloud - a safe home for all your data https://opencloud.eu `, expectedSender: sharer.GetDisplayName(), - expectedHTMLBody: ` - - - - - - -
- - - - - - - - - - - - - - - -
  - Hello <script>alert('Eric Expireling');</script>, -

- Dr. O'reilly has invited you to join "<script>alert('secret space');</script>". -

- Click here to view it: https://cloud.example.com/f/spaceid -
 
  - -
 
-
- - -`, + expectedHTMLParts: []string{ + `

Hello <script>alert('Eric Expireling');</script>,

`, + `

Dr. O'reilly has invited you to join "<script>alert('secret space');</script>".

`, + `>Open space`, + }, expectedLink: testOpenCloudURL + "/f/spaceid", done: make(chan struct{}), }, events.Event{ @@ -637,7 +528,7 @@ type testChannel struct { expectedReceipients []string expectedSubject string expectedTextBody string - expectedHTMLBody string + expectedHTMLParts []string expectedSender string // expectedLink, if set, must be an absolute URL on the OpenCloud instance // and must be linked in the HTML body. @@ -653,13 +544,15 @@ func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) erro Expect(tc.expectedTextBody).To(Equal(m.TextBody)) Expect(tc.expectedSender).To(Equal(m.Sender)) Expect(m.HTMLBody).ToNot(BeEmpty()) - if tc.expectedHTMLBody != "" { - Expect(tc.expectedHTMLBody).To(Equal(m.HTMLBody)) + for _, part := range tc.expectedHTMLParts { + Expect(m.HTMLBody).To(ContainSubstring(part)) } + // user input must never reach the html body unescaped + Expect(m.HTMLBody).ToNot(ContainSubstring("