Merge pull request #3789 from opencloud-eu/feat-mail-notifications

feat: new mail notifications based on design system
This commit is contained in:
Michael Barz authored and GitHub committed 2026-10-26 11:37:11 +01:00
commit cd6afff9e8
16 files changed
+885 -226

No files matched your search

+65 -7
View File
@@ -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 `<br>` between the lines. A custom child template needs to
define it as well:
```text
{{ define "lines" }}
{{- range $i, $line := . }}{{ if $i }}<br>{{ end }}
{{- range $line }}
{{- if .Href }}<a href="{{ .Href }}">{{ .Text }}</a>
{{- 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 ```<img src="https://raw.githubusercontent.com/opencloud-eu/opencloud/master/opencloud/img/logo-mail.gif" alt="logo-mail"/>``` or embedded as a CID source ```<img src="cid:logo-mail.gif" alt="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 ```<img src="https://raw.githubusercontent.com/opencloud-eu/opencloud/master/opencloud/img/logo-mail.gif" alt="logo-mail"/>``` or embedded as a CID source ```<img src="cid:logo-mail.gif" alt="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:
+38 -14
View File
@@ -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 += "<br><br>" + callToAction
}
bodyParts = append(bodyParts, bodyPart)
}
gmt.MessageBody = strings.Join(bodyParts, "<br><br><br>")
@@ -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))
+39 -23
View File
@@ -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 {
+54 -1
View File
@@ -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)
}
}
}
+226
View File
@@ -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
}
@@ -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 <b>Lindqvist</b>",
"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{
`<h1 class="oc-text"`,
"Your new link to &#39;Q3 Report&#39;",
"Anna &lt;b&gt;Lindqvist&lt;/b&gt;",
`>Your PIN</div>`,
`>482193</div>`,
`href="https://cloud.example.com/s/Xk29fQ?a=1&amp;b=2"`,
">Open link</a>",
"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, "<b>Lindqvist</b>") {
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: "<p>{{ .Greeting }}</p><p>{{ .MessageBody }}</p><p>{{ .CallToAction }}</p>",
} {
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 := `<p>Hello Ben</p><p>Anna has shared "Q3 Report" with you.</p><p>Click here to view it: <a href="https://cloud.example.com/files/shares/with-me">https://cloud.example.com/files/shares/with-me</a></p>`
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</td>",
`<p class="oc-muted" style="margin: 8px 0 0; color: #40484c;">You might still have access through your other groups or direct membership.</p>`,
} {
if !strings.Contains(msg.HTMLBody, want) {
t.Errorf("html body is missing %q", want)
}
}
for _, want := range []string{
`Anna has shared &#34;<a class="oc-link" href="https://cloud.example.com/files/shares/with-me" style="color: #00677f; text-decoration: none;">Q3 Report</a>&#34; with you.`,
`Jonas has removed you from &#34;<a class="oc-link" href="https://cloud.example.com/f/spaceid" style="color: #00677f; text-decoration: none;">Nostromo</a>&#34;.`,
} {
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": "<img src=x onerror=alert(1)>", "ShareLink": "javascript:alert(1)"}},
nil,
)
if err != nil {
t.Fatal(err)
}
if strings.Contains(msg.HTMLBody, "<img src=x") || strings.Contains(msg.HTMLBody, `href="javascript:`) {
t.Errorf("digest html contains unescaped input:\n%s", msg.HTMLBody)
}
if !strings.Contains(msg.HTMLBody, "&lt;img src=x onerror=alert(1)&gt;</a>") {
t.Error("digest html does not show the escaped resource name as link text")
}
}
+82 -6
View File
@@ -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
@@ -0,0 +1,40 @@
{{ define "lines" }}{{ range $i, $line := . }}{{ if $i }}<br>{{ end }}{{ range $line }}{{ if .Href }}<a class="oc-link" href="{{ .Href }}" style="color: #00677f; text-decoration: none;">{{ .Text }}</a>{{ else }}{{ .Text }}{{ end }}{{ end }}{{ end }}{{ end }}
{{ define "content" }}
<tr>
<td class="oc-pad oc-text" style="padding: 16px 40px 24px; font-size: 14px; line-height: 21px; color: #191c1d;">
<p style="margin: 0;">{{ .Greeting }}</p>
</td>
</tr>
<tr>
<td class="oc-pad" style="padding: 0 40px 32px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
{{- range $i, $entry := .Entries }}
<tr>
<td colspan="3" width="12" valign="middle" style="width: 12px;"><div class="oc-dot" style="width: 12px; height: 12px; border-radius: 6px; background-color: #00677f;"></div></td>
<td width="16" style="width: 16px;"></td>
<td class="oc-muted" style="font-size: 12.25px; line-height: 17.5px; color: #40484c;">{{ $entry.Time }}</td>
</tr>
<tr>
<td width="5" style="width: 5px;"></td>
{{- if $entry.Last }}
<td width="2" style="width: 2px;"></td>
{{- else }}
<td class="oc-line" width="2" style="width: 2px; background-color: #dbe4e8;"></td>
{{- end }}
<td width="5" style="width: 5px;"></td>
<td width="16" style="width: 16px;"></td>
<td class="oc-text" style="padding: 4px 0 {{ if $entry.Last }}0{{ else }}24px{{ end }}; font-size: 14px; line-height: 21px; color: #191c1d;">
{{- range $entry.Paragraphs }}
{{- if eq .Kind "hint" }}
<p class="oc-muted" style="margin: 8px 0 0; color: #40484c;">{{ template "lines" .Lines }}</p>
{{- else }}
<p style="margin: 0;">{{ template "lines" .Lines }}</p>
{{- end }}
{{- end }}
</td>
</tr>
{{- end }}
</table>
</td>
</tr>
{{ end }}
@@ -1,41 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
{{ .Greeting }}
<br><br>
{{ .MessageBody }}
{{if ne .CallToAction "" }}<br><br>
{{ .CallToAction }}{{end}}
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
OpenCloud - a safe home for all your data<br>
<a href="https://opencloud.eu">https://opencloud.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>{{ .Subject }}</title>
<style>
:root { color-scheme: light dark; supported-color-schemes: light dark; }
@media (prefers-color-scheme: dark) {
.oc-bg { background-color: #1d2021 !important; }
.oc-card { background-color: #191c1d !important; border-color: #323537 !important; }
.oc-text { color: #e1e3e4 !important; }
.oc-muted { color: #bfc8cc !important; }
.oc-link { color: #5cd5fb !important; }
.oc-button { background-color: #5cd5fb !important; }
.oc-button-text { color: #003543 !important; }
.oc-box { background-color: #272a2c !important; }
.oc-dot { background-color: #5cd5fb !important; }
.oc-line { background-color: #40484c !important; }
.oc-logo-light { display: none !important; }
.oc-logo-dark { display: block !important; max-height: none !important; overflow: visible !important; }
}
[data-ogsc] .oc-text { color: #e1e3e4 !important; }
[data-ogsc] .oc-muted { color: #bfc8cc !important; }
[data-ogsc] .oc-link { color: #5cd5fb !important; }
[data-ogsc] .oc-button-text { color: #003543 !important; }
[data-ogsb] .oc-bg { background-color: #1d2021 !important; }
[data-ogsb] .oc-card { background-color: #191c1d !important; }
[data-ogsb] .oc-button { background-color: #5cd5fb !important; }
[data-ogsb] .oc-box { background-color: #272a2c !important; }
[data-ogsc] .oc-logo-light { display: none !important; }
[data-ogsc] .oc-logo-dark { display: block !important; max-height: none !important; overflow: visible !important; }
@media only screen and (max-width: 640px) {
.oc-outer { padding: 16px 8px !important; }
.oc-pad { padding-left: 24px !important; padding-right: 24px !important; }
}
</style>
</head>
<body class="oc-bg" style="margin: 0; padding: 0; background-color: #f6f8fa;">
<table role="presentation" class="oc-bg" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f6f8fa; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
<tr>
<td class="oc-outer" align="center" valign="top" style="padding: 40px 16px 32px;">
<table role="presentation" class="oc-card" cellspacing="0" cellpadding="0" border="0" width="600" style="width: 100%; max-width: 600px; background-color: #ffffff; border: 1px solid #eceef0; border-radius: 10.5px;">
<tr>
<td class="oc-pad" style="padding: 32px 40px 0;">
<img class="oc-logo-light" src="cid:opencloud-logo.png" width="136" height="38" alt="OpenCloud" style="display: block; width: 136px; height: 38px; border: 0;">
<div class="oc-logo-dark" style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">
<img src="cid:opencloud-logo-dark.png" width="136" height="38" alt="OpenCloud" style="display: block; width: 136px; height: 38px; border: 0;">
</div>
</td>
</tr>
<tr>
<td class="oc-pad" style="padding: 32px 40px 0;">
<h1 class="oc-text" style="margin: 0; font-size: 21px; line-height: 28px; font-weight: 700; color: #191c1d;">{{ .Subject }}</h1>
</td>
</tr>
{{ block "content" . }}{{ end }}
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="width: 100%; max-width: 600px;">
<tr>
<td class="oc-pad oc-muted" style="padding: 24px 40px 0; font-size: 12.25px; line-height: 17.5px; color: #40484c;">
OpenCloud - a safe home for all your data<br>
<a class="oc-link" href="https://opencloud.eu" style="color: #00677f;">opencloud.eu</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,50 @@
{{ define "lines" }}{{ range $i, $line := . }}{{ if $i }}<br>{{ end }}{{ range $line }}{{ if .Href }}<a class="oc-link" href="{{ .Href }}" style="color: #00677f; text-decoration: none;">{{ .Text }}</a>{{ else }}{{ .Text }}{{ end }}{{ end }}{{ end }}{{ end }}
{{ define "content" }}
<tr>
<td class="oc-pad oc-text" style="padding: 16px 40px 0; font-size: 14px; line-height: 21px; color: #191c1d;">
<p style="margin: 0;">{{ .Greeting }}</p>
{{- range .Paragraphs }}
{{- if eq .Kind "code" }}
<table role="presentation" class="oc-box" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 16px 0 0; background-color: #f6f8fa; border-radius: 7px;">
<tr>
<td align="center" style="padding: 16px;">
<div class="oc-muted" style="font-size: 12.25px; line-height: 17.5px; color: #40484c;">{{ .Label }}</div>
<div class="oc-text" style="font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 26.25px; line-height: 31.5px; font-weight: 700; letter-spacing: 6px; color: #191c1d;">{{ .Value }}</div>
</td>
</tr>
</table>
{{- else if eq .Kind "hint" }}
<p class="oc-muted" style="margin: 12px 0 0; color: #40484c;">{{ template "lines" .Lines }}</p>
{{- else }}
<p style="margin: 12px 0 0;">{{ template "lines" .Lines }}</p>
{{- end }}
{{- end }}
</td>
</tr>
{{- if .ActionLink }}
<tr>
<td class="oc-pad" style="padding: 24px 40px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="oc-button" style="background-color: #00677f; border-radius: 3.5px;">
<a class="oc-button-text" href="{{ .ActionLink }}" style="display: inline-block; padding: 8px 20px; font-size: 14px; line-height: 21px; font-weight: 600; color: #ffffff; text-decoration: none;">{{ .ActionLabel }}</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="oc-pad oc-muted" style="padding: 16px 40px 0; font-size: 12.25px; line-height: 17.5px; color: #40484c;">
{{ .FallbackLinkLabel }} <a class="oc-link" href="{{ .ActionLink }}" style="color: #00677f; word-break: break-all;">{{ .ActionLink }}</a>
</td>
</tr>
{{- end }}
{{- range .Notes }}
<tr>
<td class="oc-pad oc-muted" style="padding: 16px 40px 0; font-size: 12.25px; line-height: 17.5px; color: #40484c;">{{ template "lines" .Lines }}</td>
</tr>
{{- end }}
<tr>
<td style="height: 32px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
{{ end }}
+17 -4
View File
@@ -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
@@ -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,
@@ -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: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello,
<br><br>
Dr. S. Harer has shared "guest folder" with you.<br><br>The link below is personal and can only be used once. Please do not forward it.<br><br>The link is only valid for 30 minutes.
<br><br>
Click here to view it: <a href="https://cloud.example.com/g/v1.sharehash.secret">https://cloud.example.com/g/v1.sharehash.secret</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
OpenCloud - a safe home for all your data<br>
<a href="https://opencloud.eu">https://opencloud.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
expectedHTMLParts: []string{
`<p style="margin: 0;">Hello,</p>`,
`<p style="margin: 12px 0 0;">Dr. S. Harer has shared &#34;guest folder&#34; with you.</p>`,
`<p style="margin: 12px 0 0;">The link below is personal and can only be used once. Please do not forward it.</p>`,
`>Open link</a>`,
`The link is only valid for 30 minutes.</td>`,
},
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: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;
<br><br>
Dr. O&#39;reilly has shared "&lt;script&gt;alert(&#39;secrets of the board&#39;);&lt;/script&gt;" with you.
<br><br>
Click here to view it: <a href="https://cloud.example.com/files/shares/with-me">https://cloud.example.com/files/shares/with-me</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
OpenCloud - a safe home for all your data<br>
<a href="https://opencloud.eu">https://opencloud.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
expectedHTMLParts: []string{
`<p style="margin: 0;">Hello &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;</p>`,
`<p style="margin: 12px 0 0;">Dr. O&#39;reilly has shared &#34;&lt;script&gt;alert(&#39;secrets of the board&#39;);&lt;/script&gt;&#34; with you.</p>`,
`>View shares</a>`,
},
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: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;,
<br><br>
Dr. O&#39;reilly has invited you to join "&lt;script&gt;alert(&#39;secret space&#39;);&lt;/script&gt;".
<br><br>
Click here to view it: <a href="https://cloud.example.com/f/spaceid">https://cloud.example.com/f/spaceid</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
OpenCloud - a safe home for all your data<br>
<a href="https://opencloud.eu">https://opencloud.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
expectedHTMLParts: []string{
`<p style="margin: 0;">Hello &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;,</p>`,
`<p style="margin: 12px 0 0;">Dr. O&#39;reilly has invited you to join &#34;&lt;script&gt;alert(&#39;secret space&#39;);&lt;/script&gt;&#34;.</p>`,
`>Open space</a>`,
},
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("<script>"))
if tc.expectedLink != "" {
Expect(tc.expectedLink).To(HavePrefix(testOpenCloudURL + "/"))
Expect(m.TextBody).To(ContainSubstring(tc.expectedLink))
Expect(m.HTMLBody).To(ContainSubstring(`<a href="` + tc.expectedLink + `">`))
Expect(m.HTMLBody).To(ContainSubstring(`href="` + tc.expectedLink + `"`))
}
tc.done <- struct{}{}
return nil