diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index a9e887f3b..96442e59c 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -21,11 +21,10 @@ import ( "strconv" "time" - "code.vikunja.io/api/pkg/utils" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/utils" ) // ReminderDueNotification represents a ReminderDueNotification notification @@ -38,6 +37,7 @@ type ReminderDueNotification struct { // ToMail returns the mail notification for ReminderDueNotification func (n *ReminderDueNotification) ToMail() *notifications.Mail { return notifications.NewMail(). + IncludeLinkToSettings(). To(n.User.Email). Subject(`Reminder for "`+n.Task.Title+`" (`+n.Project.Title+`)`). Greeting("Hi "+n.User.GetName()+","). @@ -227,6 +227,7 @@ type UndoneTaskOverdueNotification struct { func (n *UndoneTaskOverdueNotification) ToMail() *notifications.Mail { until := time.Until(n.Task.DueDate).Round(1*time.Hour) * -1 return notifications.NewMail(). + IncludeLinkToSettings(). Subject(`Task "`+n.Task.Title+`" (`+n.Project.Title+`) is overdue`). Greeting("Hi "+n.User.GetName()+","). Line(`This is a friendly reminder of the task "`+n.Task.Title+`" (`+n.Project.Title+`) which is `+getOverdueSinceString(until)+` and not yet done.`). @@ -270,6 +271,7 @@ func (n *UndoneTasksOverdueNotification) ToMail() *notifications.Mail { } return notifications.NewMail(). + IncludeLinkToSettings(). Subject(`Your overdue tasks`). Greeting("Hi "+n.User.GetName()+","). Line("You have the following overdue tasks:"). diff --git a/pkg/notifications/mail.go b/pkg/notifications/mail.go index da7a9fd05..88f50a76d 100644 --- a/pkg/notifications/mail.go +++ b/pkg/notifications/mail.go @@ -16,18 +16,22 @@ package notifications -import "code.vikunja.io/api/pkg/mail" +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/mail" +) // Mail is a mail message type Mail struct { - from string - to string - subject string - actionText string - actionURL string - greeting string - introLines []*mailLine - outroLines []*mailLine + from string + to string + subject string + actionText string + actionURL string + greeting string + introLines []*mailLine + outroLines []*mailLine + footerLines []*mailLine } type mailLine struct { @@ -76,6 +80,18 @@ func (m *Mail) Line(line string) *Mail { return m.appendLine(line, false) } +func (m *Mail) FooterLine(line string) *Mail { + m.footerLines = append(m.footerLines, &mailLine{ + Text: line, + }) + return m +} + +func (m *Mail) IncludeLinkToSettings() *Mail { + m.FooterLine("You can change your notification settings [here](" + config.ServicePublicURL.GetString() + "user/settings/general).") + return m +} + func (m *Mail) HTML(line string) *Mail { return m.appendLine(line, true) } diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 062bd9236..5c8265af9 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -41,6 +41,9 @@ const mailTemplatePlain = ` {{ .ActionURL }}{{end}} {{ range $line := .OutroLines}} {{ $line.Text }} +{{ end }} +{{ range $line := .FooterLines}} +{{ $line.Text }} {{ end }}` const mailTemplateHTML = ` @@ -76,10 +79,23 @@ const mailTemplateHTML = ` {{ end }} {{ if .ActionURL }} -

+

+

If the button above doesn't work, copy the url below and paste it in your browser's address bar:
{{ .ActionURL }}

+{{ range $line := .FooterLinesHTML}} + {{ $line }} + {{ end }} +
+{{ else }} +{{ if .FooterLinesHTML }} +
+ {{ range $line := .FooterLinesHTML }} + {{ $line }} + {{ end }} +
+{{ end }} {{ end }} @@ -91,6 +107,29 @@ const mailTemplateHTML = ` //go:embed logo.png var logo embed.FS +func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { + p := bluemonday.UGCPolicy() + + for _, line := range lines { + if line.isHTML { + // #nosec G203 -- the html is sanitized + linesHTML = append(linesHTML, templatehtml.HTML(p.Sanitize(line.Text))) + continue + } + + md := []byte(templatehtml.HTMLEscapeString(line.Text)) + var buf bytes.Buffer + err = goldmark.Convert(md, &buf) + if err != nil { + return nil, err + } + // #nosec G203 -- the html is sanitized + linesHTML = append(linesHTML, templatehtml.HTML(p.Sanitize(buf.String()))) + } + + return +} + // RenderMail takes a precomposed mail message and renders it into a ready to send mail.Opts object func RenderMail(m *Mail) (mailOpts *mail.Opts, err error) { @@ -114,50 +153,26 @@ func RenderMail(m *Mail) (mailOpts *mail.Opts, err error) { data["Greeting"] = m.greeting data["IntroLines"] = m.introLines data["OutroLines"] = m.outroLines + data["FooterLines"] = m.footerLines data["ActionText"] = m.actionText data["ActionURL"] = m.actionURL data["Boundary"] = boundary data["FrontendURL"] = config.ServicePublicURL.GetString() - p := bluemonday.UGCPolicy() - - var introLinesHTML []templatehtml.HTML - for _, line := range m.introLines { - if line.isHTML { - // #nosec G203 -- the html is sanitized - introLinesHTML = append(introLinesHTML, templatehtml.HTML(p.Sanitize(line.Text))) - continue - } - - md := []byte(templatehtml.HTMLEscapeString(line.Text)) - var buf bytes.Buffer - err = goldmark.Convert(md, &buf) - if err != nil { - return nil, err - } - // #nosec G203 -- the html is sanitized - introLinesHTML = append(introLinesHTML, templatehtml.HTML(p.Sanitize(buf.String()))) + data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines) + if err != nil { + return nil, err } - data["IntroLinesHTML"] = introLinesHTML - var outroLinesHTML []templatehtml.HTML - for _, line := range m.outroLines { - if line.isHTML { - // #nosec G203 -- the html is sanitized - outroLinesHTML = append(outroLinesHTML, templatehtml.HTML(p.Sanitize(line.Text))) - continue - } - - md := []byte(templatehtml.HTMLEscapeString(line.Text)) - var buf bytes.Buffer - err = goldmark.Convert(md, &buf) - if err != nil { - return nil, err - } - // #nosec G203 -- the html is sanitized - outroLinesHTML = append(outroLinesHTML, templatehtml.HTML(p.Sanitize(buf.String()))) + data["OutroLinesHTML"], err = convertLinesToHTML(m.outroLines) + if err != nil { + return nil, err + } + + data["FooterLinesHTML"], err = convertLinesToHTML(m.footerLines) + if err != nil { + return nil, err } - data["OutroLinesHTML"] = outroLinesHTML err = plain.Execute(&plainContent, data) if err != nil { diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go index c04d3be45..022be4a02 100644 --- a/pkg/notifications/mail_test.go +++ b/pkg/notifications/mail_test.go @@ -89,24 +89,82 @@ func TestNewMail(t *testing.T) { } func TestRenderMail(t *testing.T) { - mail := NewMail(). - From("test@example.com"). - To("test@otherdomain.com"). - Subject("Testmail"). - Greeting("Hi there,"). - Line("This is a line"). - Line("This **line** contains [a link](https://vikunja.io)"). - Line("And another one"). - Action("The action", "https://example.com"). - Line("This should be an outro line"). - Line("And one more, because why not?") + t.Run("simple", func(t *testing.T) { + mail := NewMail(). + From("test@example.com"). + To("test@otherdomain.com"). + Subject("Testmail"). + Greeting("Hi there,"). + Line("This is a line") - mailopts, err := RenderMail(mail) - require.NoError(t, err) - assert.Equal(t, mail.from, mailopts.From) - assert.Equal(t, mail.to, mailopts.To) + mailopts, err := RenderMail(mail) + require.NoError(t, err) + assert.Equal(t, mail.from, mailopts.From) + assert.Equal(t, mail.to, mailopts.To) - assert.Equal(t, ` + assert.Equal(t, ` +Hi there, + +This is a line + + + +`, mailopts.Message) + assert.Equal(t, ` + + + + + + +
+
+

+ Vikunja +

+
+

+ Hi there, +

+ + +

This is a line

+ + + + + + + + + + +
+
+
+ + +`, mailopts.HTMLMessage) + }) + t.Run("with action", func(t *testing.T) { + mail := NewMail(). + From("test@example.com"). + To("test@otherdomain.com"). + Subject("Testmail"). + Greeting("Hi there,"). + Line("This is a line"). + Line("This **line** contains [a link](https://vikunja.io)"). + Line("And another one"). + Action("The action", "https://example.com"). + Line("This should be an outro line"). + Line("And one more, because why not?") + + mailopts, err := RenderMail(mail) + require.NoError(t, err) + assert.Equal(t, mail.from, mailopts.From) + assert.Equal(t, mail.to, mailopts.To) + + assert.Equal(t, ` Hi there, This is a line @@ -121,8 +179,9 @@ https://example.com This should be an outro line And one more, because why not? + `, mailopts.Message) - assert.Equal(t, ` + assert.Equal(t, ` @@ -166,15 +225,186 @@ And one more, because why not? -

+

+

If the button above doesn't work, copy the url below and paste it in your browser's address bar:
https://example.com

+
+ `, mailopts.HTMLMessage) + }) + t.Run("with footer", func(t *testing.T) { + mail := NewMail(). + From("test@example.com"). + To("test@otherdomain.com"). + Subject("Testmail"). + Greeting("Hi there,"). + Line("This is a line"). + FooterLine("This is a footer line") + + mailopts, err := RenderMail(mail) + require.NoError(t, err) + assert.Equal(t, mail.from, mailopts.From) + assert.Equal(t, mail.to, mailopts.To) + + assert.Equal(t, ` +Hi there, + +This is a line + + + + +This is a footer line +`, mailopts.Message) + assert.Equal(t, ` + + + + + + +
+
+

+ Vikunja +

+
+

+ Hi there, +

+ + +

This is a line

+ + + + + + + + + +
+ +

This is a footer line

+ + +
+ + +
+
+
+ + +`, mailopts.HTMLMessage) + }) + t.Run("with footer and action", func(t *testing.T) { + mail := NewMail(). + From("test@example.com"). + To("test@otherdomain.com"). + Subject("Testmail"). + Greeting("Hi there,"). + Line("This is a line"). + Line("This **line** contains [a link](https://vikunja.io)"). + Line("And another one"). + Action("The action", "https://example.com"). + Line("This should be an outro line"). + Line("And one more, because why not?"). + FooterLine("This is a footer line") + + mailopts, err := RenderMail(mail) + require.NoError(t, err) + assert.Equal(t, mail.from, mailopts.From) + assert.Equal(t, mail.to, mailopts.To) + + assert.Equal(t, ` +Hi there, + +This is a line + +This **line** contains [a link](https://vikunja.io) + +And another one + +The action: +https://example.com + +This should be an outro line + +And one more, because why not? + + +This is a footer line +`, mailopts.Message) + assert.Equal(t, ` + + + + + + +
+
+

+ Vikunja +

+
+

+ Hi there, +

+ + +

This is a line

+ + +

This line contains a link

+ + +

And another one

+ + + + + + The action + + + + +

This should be an outro line

+ + +

And one more, because why not?

+ + + + +
+

+ If the button above doesn't work, copy the url below and paste it in your browser's address bar:
+ https://example.com +

+ +

This is a footer line

+ + +
+ +
+
+
+ + +`, mailopts.HTMLMessage) + }) }