fix: use dark shadows for email template in dark mode (#2155)

This commit is contained in:
kolaente
2026-01-26 15:46:44 +01:00
committed by GitHub
parent 72a928dcce
commit 28593e6460
2 changed files with 83 additions and 187 deletions

View File

@@ -50,6 +50,23 @@ const mailTemplateHTML = `
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<style>
:root {
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
.email-card {
box-shadow: 0.3em 0.3em 0.8em rgba(0,0,0,0.3) !important;
-webkit-box-shadow: 0.3em 0.3em 0.8em rgba(0,0,0,0.3) !important;
}
.email-button {
box-shadow: 0 3px 6px rgba(0,0,0,0.2), 0 2px 4px rgba(0,0,0,0.15) !important;
-webkit-box-shadow: 0 3px 6px rgba(0,0,0,0.2), 0 2px 4px rgba(0,0,0,0.15) !important;
}
}
</style>
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
@@ -57,7 +74,7 @@ const mailTemplateHTML = `
<h1 style="font-size: 30px; Text-align: center;">
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
<div class="email-card" style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
<p>
{{ .Greeting }}
</p>
@@ -67,7 +84,7 @@ const mailTemplateHTML = `
{{ end }}
{{ if .ActionURL }}
<a href="{{ .ActionURL }}" title="{{ .ActionText }}"
<a class="email-button" href="{{ .ActionURL }}" title="{{ .ActionText }}"
style="position: relative;Text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;Text-align: center;white-space: nowrap;border: 0;Text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
{{ .ActionText }}
</a>

View File

@@ -17,6 +17,7 @@
package notifications
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -88,6 +89,24 @@ func TestNewMail(t *testing.T) {
})
}
// assertHTMLContainsDarkModeSupport checks that the HTML message contains
// the required dark mode meta tags and CSS
func assertHTMLContainsDarkModeSupport(t *testing.T, htmlMessage string) {
t.Helper()
// Check for dark mode meta tags
assert.Contains(t, htmlMessage, `<meta name="color-scheme" content="light dark">`)
assert.Contains(t, htmlMessage, `<meta name="supported-color-schemes" content="light dark">`)
// Check for dark mode CSS
assert.Contains(t, htmlMessage, `@media (prefers-color-scheme: dark)`)
assert.Contains(t, htmlMessage, `.email-card`)
assert.Contains(t, htmlMessage, `box-shadow: 0.3em 0.3em 0.8em rgba(0,0,0,0.3) !important`)
assert.Contains(t, htmlMessage, `.email-button`)
// Check for email-card class on the card div
assert.Contains(t, htmlMessage, `class="email-card"`)
}
func TestRenderMail(t *testing.T) {
t.Run("simple", func(t *testing.T) {
mail := NewMail().
@@ -110,41 +129,16 @@ This is a line
`, mailopts.Message)
assert.Equal(t, `
<!doctype html>
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; Text-align: justify;">
<h1 style="font-size: 30px; Text-align: center;">
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
<p>
Hi there,
</p>
// Check for dark mode support
assertHTMLContainsDarkModeSupport(t, mailopts.HTMLMessage)
<p>This is a line</p>
// Check for expected content
assert.Contains(t, mailopts.HTMLMessage, `<p>This is a line</p>`)
assert.Contains(t, mailopts.HTMLMessage, `Hi there,`)
</div>
</div>
</div>
</body>
</html>
`, mailopts.HTMLMessage)
// Verify no action button is present
assert.NotContains(t, mailopts.HTMLMessage, `class="email-button"`)
})
t.Run("with action", func(t *testing.T) {
mail := NewMail().
@@ -181,64 +175,25 @@ This should be an outro line
And one more, because why not?
`, mailopts.Message)
assert.Equal(t, `
<!doctype html>
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; Text-align: justify;">
<h1 style="font-size: 30px; Text-align: center;">
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
<p>
Hi there,
</p>
// Check for dark mode support
assertHTMLContainsDarkModeSupport(t, mailopts.HTMLMessage)
<p>This is a line</p>
// Check for action button with email-button class
assert.Contains(t, mailopts.HTMLMessage, `class="email-button"`)
assert.Contains(t, mailopts.HTMLMessage, `href="https://example.com"`)
assert.Contains(t, mailopts.HTMLMessage, `The action`)
// Check for markdown conversion
assert.Contains(t, mailopts.HTMLMessage, `<strong>line</strong>`)
assert.Contains(t, mailopts.HTMLMessage, `<a href="https://vikunja.io" rel="nofollow">a link</a>`)
<p>This <strong>line</strong> contains <a href="https://vikunja.io" rel="nofollow">a link</a></p>
// Check for outro lines
assert.Contains(t, mailopts.HTMLMessage, `This should be an outro line`)
assert.Contains(t, mailopts.HTMLMessage, `And one more, because why not?`)
<p>And another one</p>
<a href="https://example.com" title="The action"
style="position: relative;Text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;Text-align: center;white-space: nowrap;border: 0;Text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
The action
</a>
<p>This should be an outro line</p>
<p>And one more, because why not?</p>
<div style="color: #9CA3AF;font-size:12px;border-top: 1px solid #dbdbdb;margin-top:20px;padding-top:20px;">
<p>
If the button above doesn&#39;t work, copy the url below and paste it in your browser&#39;s address bar:<br/>
https://example.com
</p>
</div>
</div>
</div>
</div>
</body>
</html>
`, mailopts.HTMLMessage)
// Check for copy URL text
assert.Contains(t, mailopts.HTMLMessage, `https://example.com`)
})
t.Run("with footer", func(t *testing.T) {
mail := NewMail().
@@ -264,48 +219,16 @@ This is a line
This is a footer line
`, mailopts.Message)
assert.Equal(t, `
<!doctype html>
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; Text-align: justify;">
<h1 style="font-size: 30px; Text-align: center;">
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
<p>
Hi there,
</p>
// Check for dark mode support
assertHTMLContainsDarkModeSupport(t, mailopts.HTMLMessage)
<p>This is a line</p>
// Check for content
assert.Contains(t, mailopts.HTMLMessage, `<p>This is a line</p>`)
assert.Contains(t, mailopts.HTMLMessage, `<p>This is a footer line</p>`)
<div style="color: #9CA3AF;font-size:12px;border-top: 1px solid #dbdbdb;margin-top:20px;padding-top:20px;">
<p>This is a footer line</p>
</div>
</div>
</div>
</div>
</body>
</html>
`, mailopts.HTMLMessage)
// Verify no action button
assert.NotContains(t, mailopts.HTMLMessage, `class="email-button"`)
})
t.Run("with footer and action", func(t *testing.T) {
mail := NewMail().
@@ -345,67 +268,16 @@ And one more, because why not?
This is a footer line
`, mailopts.Message)
assert.Equal(t, `
<!doctype html>
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; Text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; Text-align: justify;">
<h1 style="font-size: 30px; Text-align: center;">
<img src="cid:logo.png" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">
<p>
Hi there,
</p>
// Check for dark mode support
assertHTMLContainsDarkModeSupport(t, mailopts.HTMLMessage)
<p>This is a line</p>
// Check for action button with email-button class
assert.Contains(t, mailopts.HTMLMessage, `class="email-button"`)
assert.Contains(t, mailopts.HTMLMessage, `href="https://example.com"`)
<p>This <strong>line</strong> contains <a href="https://vikunja.io" rel="nofollow">a link</a></p>
<p>And another one</p>
<a href="https://example.com" title="The action"
style="position: relative;Text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;Text-align: center;white-space: nowrap;border: 0;Text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
The action
</a>
<p>This should be an outro line</p>
<p>And one more, because why not?</p>
<div style="color: #9CA3AF;font-size:12px;border-top: 1px solid #dbdbdb;margin-top:20px;padding-top:20px;">
<p>
If the button above doesn&#39;t work, copy the url below and paste it in your browser&#39;s address bar:<br/>
https://example.com
</p>
<p>This is a footer line</p>
</div>
</div>
</div>
</div>
</body>
</html>
`, mailopts.HTMLMessage)
// Check for footer
assert.Contains(t, mailopts.HTMLMessage, `<p>This is a footer line</p>`)
})
t.Run("with thread ID", func(t *testing.T) {
mail := NewMail().
@@ -569,7 +441,7 @@ This is a footer line
// Task text should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
})
t.Run("with XSS attempt via style tag", func(t *testing.T) {
t.Run("with XSS attempt via style tag in user content", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
@@ -580,10 +452,17 @@ This is a footer line
mailopts, err := RenderMail(mail, "en")
require.NoError(t, err)
// Style tags should be stripped by bluemonday
assert.NotContains(t, mailopts.HTMLMessage, `<style>`)
// User-provided style tags should be stripped by bluemonday (different from template style)
// The template has a legitimate <style> block for dark mode in the <head>, but user content
// style tags in the body should be stripped
assert.NotContains(t, mailopts.HTMLMessage, `<style>body{background:url`)
// Task text should remain
assert.Contains(t, mailopts.HTMLMessage, `Task:`)
// The template's dark mode style block should still be present in the head
// Count <style> tags - there should be exactly one (the template's dark mode styles)
styleCount := strings.Count(mailopts.HTMLMessage, "<style>")
assert.Equal(t, 1, styleCount, "There should be exactly one <style> tag (the template's dark mode styles)")
})
t.Run("with mixed XSS and legitimate content", func(t *testing.T) {
mail := NewMail().