From bff95edc2a500702089fb93c1512f09040bd4381 Mon Sep 17 00:00:00 2001 From: micbar Date: Thu, 22 Oct 2026 14:21:05 +0200 Subject: [PATCH 1/3] feat: new mail notifications based on design system --- services/notifications/README.md | 52 ++++- services/notifications/pkg/email/composer.go | 16 +- services/notifications/pkg/email/email.go | 62 +++--- .../notifications/pkg/email/email_test.go | 24 +++ services/notifications/pkg/email/html.go | 191 ++++++++++++++++++ services/notifications/pkg/email/html_test.go | 160 +++++++++++++++ services/notifications/pkg/email/templates.go | 88 +++++++- .../pkg/email/templates/html/digest.html.tmpl | 39 ++++ .../pkg/email/templates/html/email.html.tmpl | 41 ---- .../html/img/opencloud-logo-dark.png | Bin 0 -> 8052 bytes .../templates/html/img/opencloud-logo.png | Bin 0 -> 12762 bytes .../pkg/email/templates/html/layout.html.tmpl | 73 +++++++ .../email/templates/html/message.html.tmpl | 49 +++++ services/notifications/pkg/service/job.go | 21 +- .../notifications/pkg/service/sciencemesh.go | 1 + .../notifications/pkg/service/service_test.go | 153 +++----------- 16 files changed, 758 insertions(+), 212 deletions(-) create mode 100644 services/notifications/pkg/email/html.go create mode 100644 services/notifications/pkg/email/html_test.go create mode 100644 services/notifications/pkg/email/templates/html/digest.html.tmpl delete mode 100644 services/notifications/pkg/email/templates/html/email.html.tmpl create mode 100644 services/notifications/pkg/email/templates/html/img/opencloud-logo-dark.png create mode 100644 services/notifications/pkg/email/templates/html/img/opencloud-logo.png create mode 100644 services/notifications/pkg/email/templates/html/layout.html.tmpl create mode 100644 services/notifications/pkg/email/templates/html/message.html.tmpl diff --git a/services/notifications/README.md b/services/notifications/README.md index 447b792e96..cf2a1c1163 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,69 @@ 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 a `.Text`; 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 }}` | The whole message and the call to action, as used by the text template. In a grouped email, `{{ .MessageBody }}` holds all events, each with its call to action. | + +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 +92,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..913782c3a7 100644 --- a/services/notifications/pkg/email/composer.go +++ b/services/notifications/pkg/email/composer.go @@ -87,6 +87,13 @@ func NewGroupedTextTemplate(gmt GroupedMessageTemplate, vars map[string]string, if err != nil { return gmt, err } + callToAction, err := composeMessage(t.Get(mt.CallToAction, []any{}...), 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") @@ -112,10 +119,17 @@ func NewGroupedHTMLTemplate(gmt GroupedMessageTemplate, vars map[string]string, 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(t.Get(mt.MessageBody, []any{}...)), mtsVars[i]) if err != nil { return gmt, err } + callToAction, err := composeMessage(callToActionToHTML(t.Get(mt.CallToAction, []any{}...)), 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, "


") 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..e55fd3df08 100644 --- a/services/notifications/pkg/email/email_test.go +++ b/services/notifications/pkg/email/email_test.go @@ -23,3 +23,27 @@ 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) + } + } +} diff --git a/services/notifications/pkg/email/html.go b/services/notifications/pkg/email/html.go new file mode 100644 index 0000000000..44553e9757 --- /dev/null +++ b/services/notifications/pkg/email/html.go @@ -0,0 +1,191 @@ +package email + +import ( + "html" + "html/template" + "maps" + "strings" + "time" + + "github.com/opencloud-eu/opencloud/pkg/l10n" +) + +// htmlContent is the data of the html templates. Text fields are already translated and +// hold escaped variables, so they are safe to use as template.HTML. +type htmlContent struct { + Subject template.HTML + Greeting template.HTML + // Paragraphs are shown before the button, Notes after it + Paragraphs []htmlParagraph + Notes []htmlParagraph + // ActionLink is built from the unescaped variables, the template escapes it + 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" +type htmlParagraph struct { + Kind string + Text template.HTML + Label template.HTML + Value template.HTML +} + +// digestEntry is one event in the timeline of a digest email +type digestEntry struct { + Time string + Paragraphs []htmlParagraph + Last bool +} + +// 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) { + escaped := escapeStringMap(vars) + legacy, err := NewHTMLTemplate(mt, locale, defaultLocale, translationPath, escaped) + if err != nil { + return htmlContent{}, err + } + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) + body, err := composeMessage(t.Get(mt.MessageBody, []any{}...), escaped) + if err != nil { + return htmlContent{}, err + } + content := htmlContent{ + Subject: template.HTML(legacy.Subject), // #nosec G203 + Greeting: template.HTML(strings.TrimSpace(legacy.Greeting)), // #nosec G203 + MessageBody: template.HTML(strings.TrimSpace(legacy.MessageBody)), // #nosec G203 + CallToAction: template.HTML(strings.TrimSpace(legacy.CallToAction)), // #nosec G203 + } + for _, p := range layoutParagraphs(body, mt.bodyLayout) { + if p.Kind == "note" { + content.Notes = append(content.Notes, p) + continue + } + content.Paragraphs = append(content.Paragraphs, p) + } + if mt.actionLink != "" { + content.ActionLink, err = composeMessage(mt.actionLink, vars) + if err != nil { + return htmlContent{}, err + } + content.ActionLabel = t.Get(mt.ActionLabel, []any{}...) + content.FallbackLinkLabel = t.Get(_fallbackLinkLabel, []any{}...) + } + 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{ + Subject: template.HTML(legacy.Subject), // #nosec G203 + Greeting: template.HTML(strings.TrimSpace(legacy.Greeting)), // #nosec G203 + MessageBody: template.HTML(strings.TrimSpace(legacy.MessageBody)), // #nosec G203 + } + for i, mt := range mts { + entryVars, err := linkResourceName(mt, mtsVars[i], escapedMtsVars[i]) + if err != nil { + return htmlContent{}, err + } + body, err := composeMessage(t.Get(mt.MessageBody, []any{}...), entryVars) + if err != nil { + return htmlContent{}, err + } + entry := digestEntry{Paragraphs: layoutParagraphs(body, mt.bodyLayout)} + 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 +} + +// linkResourceName returns the escaped variables of a digest entry with the resource name +// turned into a link to the resource, if the template has one +func linkResourceName(mt MessageTemplate, vars, escaped map[string]string) (map[string]string, error) { + if mt.digestLinkVar == "" || mt.actionLink == "" { + return escaped, nil + } + link, err := composeMessage(mt.actionLink, vars) + if err != nil { + return nil, err + } + // the link is set into the message as html, so only plain web links are accepted + if !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "http://") { + return escaped, nil + } + linked := maps.Clone(escaped) + linked[mt.digestLinkVar] = `` + escaped[mt.digestLinkVar] + `` + return linked, 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. +func layoutParagraphs(body string, layout []paragraphRole) []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", Text: template.HTML(newlineToBr(part))} // #nosec G203 + 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.Kind = "code" + p.Label = template.HTML(strings.TrimSpace(label)) // #nosec G203 + p.Value = template.HTML(strings.TrimSpace(value)) // #nosec G203 + } + } + } + paragraphs = append(paragraphs, p) + } + return paragraphs +} + +// 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" +} diff --git a/services/notifications/pkg/email/html_test.go b/services/notifications/pkg/email/html_test.go new file mode 100644 index 0000000000..1e2d01a2a3 --- /dev/null +++ b/services/notifications/pkg/email/html_test.go @@ -0,0 +1,160 @@ +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}) + + 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}) + 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) + } +} 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..fba15ac60f --- /dev/null +++ b/services/notifications/pkg/email/templates/html/digest.html.tmpl @@ -0,0 +1,39 @@ +{{ define "content" }} + + +

{{ .Greeting }}

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

{{ .Text }}

+{{- else }} +

{{ .Text }}

+{{- 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 0000000000000000000000000000000000000000..fc1d67e513c0af3bbeeda263a740da7eccc8e0dc GIT binary patch literal 8052 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 Reur}aa<7!7%7Mng{{x|dFarPp literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..049b3d111c1a39c4c63995e8b3c3f224a8a3a1cb GIT binary patch literal 12762 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 Z#=nn7U2`@N{QgOXf{coEiR7Dr{{eYk!1@3H literal 0 HcmV?d00001 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..d4bfe7c335 --- /dev/null +++ b/services/notifications/pkg/email/templates/html/message.html.tmpl @@ -0,0 +1,49 @@ +{{ define "content" }} + + +

{{ .Greeting }}

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

{{ .Text }}

+{{- else }} +

{{ .Text }}

+{{- end }} +{{- end }} + + +{{- if .ActionLink }} + + + + + + +
+ {{ .ActionLabel }} +
+ + + + + {{ .FallbackLinkLabel }} {{ .ActionLink }} + + +{{- end }} +{{- range .Notes }} + + {{ .Text }} + +{{- 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..2c8f4488f9 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("