// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package notifications import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewMail(t *testing.T) { t.Run("Full mail", 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("And another one"). Action("the actiopn", "https://example.com"). Line("This should be an outro line"). Line("And one more, because why not?") assert.Equal(t, "test@example.com", mail.from) assert.Equal(t, "test@otherdomain.com", mail.to) assert.Equal(t, "Testmail", mail.subject) assert.Equal(t, "Hi there,", mail.greeting) assert.Len(t, mail.introLines, 2) assert.Equal(t, "This is a line", mail.introLines[0].Text) assert.False(t, mail.introLines[0].isHTML) assert.Equal(t, "And another one", mail.introLines[1].Text) assert.False(t, mail.introLines[1].isHTML) assert.Len(t, mail.outroLines, 2) assert.Equal(t, "This should be an outro line", mail.outroLines[0].Text) assert.False(t, mail.outroLines[0].isHTML) assert.Equal(t, "And one more, because why not?", mail.outroLines[1].Text) assert.False(t, mail.outroLines[1].isHTML) }) t.Run("No greeting", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Line("This is a line"). Line("And another one") assert.Equal(t, "test@example.com", mail.from) assert.Equal(t, "test@otherdomain.com", mail.to) assert.Equal(t, "Testmail", mail.subject) assert.Empty(t, mail.greeting) assert.Len(t, mail.introLines, 2) assert.Equal(t, "This is a line", mail.introLines[0].Text) assert.Equal(t, "And another one", mail.introLines[1].Text) }) t.Run("No action", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Line("This is a line"). Line("And another one"). Line("This should be an outro line"). Line("And one more, because why not?") assert.Equal(t, "test@example.com", mail.from) assert.Equal(t, "test@otherdomain.com", mail.to) assert.Equal(t, "Testmail", mail.subject) assert.Len(t, mail.introLines, 4) assert.Equal(t, "This is a line", mail.introLines[0].Text) assert.Equal(t, "And another one", mail.introLines[1].Text) assert.Equal(t, "This should be an outro line", mail.introLines[2].Text) assert.Equal(t, "And one more, because why not?", mail.introLines[3].Text) }) } func TestRenderMail(t *testing.T) { 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, "en") 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 `, 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, "en") 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? `, 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

`, 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, "en") 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, "en") 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) }) t.Run("with thread ID", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line("This is a line"). ThreadID("") mailopts, err := RenderMail(mail, "en") require.NoError(t, err) assert.Equal(t, mail.from, mailopts.From) assert.Equal(t, mail.to, mailopts.To) assert.Equal(t, "", mailopts.ThreadID) }) t.Run("with special characters in task title", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line(`This is a friendly reminder of the task "Fix structured data Value in property "reviewCount" must be positive" (My Project).`) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) assert.Equal(t, mail.from, mailopts.From) assert.Equal(t, mail.to, mailopts.To) // Plain text should keep quotes as-is assert.Contains(t, mailopts.Message, `"Fix structured data Value in property "reviewCount" must be positive"`) // HTML should have proper HTML entities for quotes // " is the correct HTML entity for the quote character and will render as " in the browser assert.Contains(t, mailopts.HTMLMessage, `"Fix structured data Value in property "reviewCount" must be positive"`) }) t.Run("with pre-escaped HTML entities", func(t *testing.T) { // This tests the fix for issue #1664 where HTML entities were being double-escaped mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line(`Task with entity: "already escaped" should render correctly`) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // Plain text should contain the HTML entity as-is (it will be interpreted by email client) assert.Contains(t, mailopts.Message, `"`) // HTML should properly handle the pre-escaped entity without double-escaping // The entity should remain as " (not become &#34;) assert.Contains(t, mailopts.HTMLMessage, `"already escaped"`) // Should NOT double-escape to &#34; which would display as literal " assert.NotContains(t, mailopts.HTMLMessage, `&#34;`) }) t.Run("with XSS attempt via script tag", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line(`Task: `) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // Script tags should be stripped by bluemonday sanitization assert.NotContains(t, mailopts.HTMLMessage, ``) assert.NotContains(t, mailopts.HTMLMessage, `alert('XSS')`) // The text should be present but sanitized assert.Contains(t, mailopts.HTMLMessage, `Task:`) }) t.Run("with XSS attempt via img onerror", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line(`Task: `) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // The dangerous HTML should be escaped, not rendered as actual HTML // This makes it safe - it will display as text, not execute assert.Contains(t, mailopts.HTMLMessage, `<img`) assert.Contains(t, mailopts.HTMLMessage, `>`) // Verify it's not an actual executable img tag assert.NotContains(t, mailopts.HTMLMessage, `Click me`) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // JavaScript protocol should be stripped assert.NotContains(t, mailopts.HTMLMessage, `javascript:alert`) assert.NotContains(t, mailopts.HTMLMessage, `href="javascript:`) // Text content should remain assert.Contains(t, mailopts.HTMLMessage, `Task:`) }) t.Run("with XSS attempt via iframe", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line(`Task: `) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // Iframes should be completely stripped by bluemonday assert.NotContains(t, mailopts.HTMLMessage, `Dangerous`) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // onclick handler should be stripped assert.NotContains(t, mailopts.HTMLMessage, `onclick=`) assert.NotContains(t, mailopts.HTMLMessage, `onclick="alert`) // Text content may remain but without the dangerous attributes assert.Contains(t, mailopts.HTMLMessage, `Task:`) }) t.Run("with XSS attempt via data URI", func(t *testing.T) { mail := NewMail(). From("test@example.com"). To("test@otherdomain.com"). Subject("Testmail"). Greeting("Hi there,"). Line(`Task: `) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // Script tags should not appear in final HTML assert.NotContains(t, mailopts.HTMLMessage, ``) assert.NotContains(t, mailopts.HTMLMessage, ` priority & needs **attention**`) mailopts, err := RenderMail(mail, "en") require.NoError(t, err) // Malicious content should be stripped assert.NotContains(t, mailopts.HTMLMessage, `