feat: format user mentions with display names in email notifications (#1930)

Email notifications now display user mentions with inline avatar images for improved visual recognition and easier identification. Mentions gracefully fall back to display names if avatars are unavailable.
This commit is contained in:
kolaente 2025-12-10 12:39:05 +01:00 committed by GitHub
parent d4eccccbfe
commit fb7764d9f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 610 additions and 25 deletions

View File

@ -17,11 +17,15 @@
package models
import (
"bytes"
"strings"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/avatar"
"code.vikunja.io/api/pkg/user"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
"xorm.io/xorm"
)
@ -74,3 +78,188 @@ func extractMentionedUsernames(htmlText string) []string {
traverse(doc)
return usernames
}
// formatMentionsForEmail replaces mention-user tags with user avatars and names for email display.
// It converts <mention-user data-id="username" data-label="Display Name"> tags to
// <strong><img src="data:..."/> Display Name</strong> with a 20x20 avatar image.
// If data-label is missing, it falls back to data-id. Returns the original HTML unchanged on any error.
func formatMentionsForEmail(s *xorm.Session, htmlText string) string {
if htmlText == "" {
return htmlText
}
// Create a synthetic body node for fragment parsing
bodyNode := &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
}
fragments, err := html.ParseFragment(strings.NewReader(htmlText), bodyNode)
if err != nil {
log.Debugf("Failed to parse HTML fragment for mention formatting: %v", err)
return htmlText
}
// If no fragments, return original
if len(fragments) == 0 {
return htmlText
}
// Extract all usernames first to batch fetch users
usernames := extractMentionedUsernames(htmlText)
var usersMap map[int64]*user.User
var usernameToUser map[string]*user.User
if len(usernames) == 0 {
return htmlText
}
// Create maps for user data and avatar data URIs
usernameToAvatarURI := make(map[string]string)
// Only fetch users if we have a valid session
usersMap, err = user.GetUsersByUsername(s, usernames, true)
if err != nil {
log.Debugf("Failed to fetch users for mention formatting: %v", err)
// Continue without user data - we'll fall back to display names from attributes
} else {
// Create username -> user map for easy lookup and fetch avatar data URIs
usernameToUser = make(map[string]*user.User)
for _, u := range usersMap {
usernameToUser[u.Username] = u
// Fetch avatar data URI for this user
provider := avatar.GetProvider(u)
avatarDataURI, err := provider.AsDataURI(u, 20)
if err == nil && avatarDataURI != "" {
usernameToAvatarURI[u.Username] = avatarDataURI
}
}
}
// Track nodes to replace (can't modify while traversing)
type replacement struct {
oldNode *html.Node
newNode *html.Node
}
replacements := []replacement{}
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "mention-user" {
var dataLabel, dataID string
// Extract data-label and data-id attributes
for _, attr := range n.Attr {
switch attr.Key {
case "data-label":
dataLabel = attr.Val
case "data-id":
dataID = attr.Val
}
}
// Determine what to display
displayName := dataLabel
if displayName == "" {
displayName = dataID
}
// If still empty and has text content (old format), use that
if displayName == "" && n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
displayName = strings.TrimPrefix(n.FirstChild.Data, "@")
}
if displayName == "" {
log.Debugf("Mention node has no data-label, data-id, or text content, skipping")
// Continue traversing children in case there are nested elements
for child := n.FirstChild; child != nil; child = child.NextSibling {
traverse(child)
}
return
}
// Create <strong> wrapper
strongNode := &html.Node{
Type: html.ElementNode,
Data: "strong",
}
// Get pre-fetched avatar data URI for the user
var avatarDataURI string
if dataID != "" {
avatarDataURI = usernameToAvatarURI[dataID]
}
// If we have an avatar, add the img element
if avatarDataURI != "" {
imgNode := &html.Node{
Type: html.ElementNode,
Data: "img",
Attr: []html.Attribute{
{Key: "src", Val: avatarDataURI},
{Key: "width", Val: "20"},
{Key: "height", Val: "20"},
{Key: "style", Val: "border-radius: 50%; vertical-align: middle; margin-right: 4px;"},
{Key: "alt", Val: displayName},
},
}
strongNode.AppendChild(imgNode)
// Add display name without @ since we have the avatar
textNode := &html.Node{
Type: html.TextNode,
Data: displayName,
}
strongNode.AppendChild(textNode)
} else {
// Fall back to @DisplayName without avatar
textNode := &html.Node{
Type: html.TextNode,
Data: "@" + displayName,
}
strongNode.AppendChild(textNode)
}
// Schedule replacement
replacements = append(replacements, replacement{
oldNode: n,
newNode: strongNode,
})
// Don't traverse children of mention-user since we're replacing it
return
}
// Traverse child nodes
for child := n.FirstChild; child != nil; child = child.NextSibling {
traverse(child)
}
}
// Traverse all fragment nodes
for _, fragment := range fragments {
traverse(fragment)
}
// Apply replacements
for _, r := range replacements {
if r.oldNode.Parent != nil {
r.oldNode.Parent.InsertBefore(r.newNode, r.oldNode)
r.oldNode.Parent.RemoveChild(r.oldNode)
}
}
// Render each fragment node back to HTML
var buf bytes.Buffer
for _, fragment := range fragments {
err = html.Render(&buf, fragment)
if err != nil {
log.Debugf("Failed to render HTML fragment after mention formatting: %v", err)
return htmlText
}
}
return buf.String()
}

View File

@ -19,6 +19,8 @@ package models
import (
"testing"
"regexp"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
@ -179,3 +181,214 @@ func TestSendingMentionNotification(t *testing.T) {
assert.Len(t, dbNotifications, 1)
})
}
func TestFormatMentionsForEmail(t *testing.T) {
tests := []struct {
name string
input string
expected string
useRegex bool // If true, expected is treated as a regex pattern
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "no mentions",
input: "<p>Lorem Ipsum dolor sit amet</p>",
expected: "<p>Lorem Ipsum dolor sit amet</p>",
},
{
name: "single mention with data-label (new format)",
input: `<p><mention-user data-id="frederick" data-label="Frederick" data-mention-suggestion-char="@"></mention-user> hello</p>`,
expected: `<p><strong>@Frederick</strong> hello</p>`,
},
{
name: "single mention with full name in data-label",
input: `<p><mention-user data-id="johndoe" data-label="John Doe" data-mention-suggestion-char="@"></mention-user> please help</p>`,
expected: `<p><strong>@John Doe</strong> please help</p>`,
},
{
name: "mention without data-label (fallback to data-id)",
input: `<p><mention-user data-id="johndoe"></mention-user> test</p>`,
expected: `<p><strong>@johndoe</strong> test</p>`,
},
{
name: "old format with text node inside",
input: `<p><mention-user data-id="user1">@user1</mention-user> Lorem Ipsum</p>`,
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="user1"/>user1</strong> Lorem Ipsum</p>`,
useRegex: true,
},
{
name: "old format with text node (data-id takes precedence over text)",
input: `<p><mention-user data-id="actualuser">@differentuser</mention-user> text</p>`,
expected: `<p><strong>@actualuser</strong> text</p>`,
},
{
name: "multiple mentions in one paragraph",
input: `<p>Hey <mention-user data-id="john" data-label="John"></mention-user> and <mention-user data-id="jane" data-label="Jane Doe"></mention-user>, please review</p>`,
expected: `<p>Hey <strong>@John</strong> and <strong>@Jane Doe</strong>, please review</p>`,
},
{
name: "mention at beginning",
input: `<p><mention-user data-id="user1" data-label="User One"></mention-user> Lorem Ipsum</p>`,
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User One"/>User One</strong> Lorem Ipsum</p>`,
useRegex: true,
},
{
name: "mention at end",
input: `<p>Lorem Ipsum <mention-user data-id="user1" data-label="User One"></mention-user></p>`,
expected: `<p>Lorem Ipsum <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User One"/>User One</strong></p>`,
useRegex: true,
},
{
name: "mention in middle",
input: `<p>Lorem <mention-user data-id="user1" data-label="User One"></mention-user> Ipsum</p>`,
expected: `<p>Lorem <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User One"/>User One</strong> Ipsum</p>`,
useRegex: true,
},
{
name: "same user mentioned multiple times",
input: `<p><mention-user data-id="user1" data-label="User"></mention-user> and <mention-user data-id="user1" data-label="User"></mention-user> again</p>`,
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong> and <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong> again</p>`,
useRegex: true,
},
{
name: "HTML preservation with links",
input: `<p>Check <a href="http://example.com">this link</a> and ask <mention-user data-id="expert" data-label="Expert"></mention-user></p>`,
expected: `<p>Check <a href="http://example.com">this link</a> and ask <strong>@Expert</strong></p>`,
},
{
name: "HTML preservation with multiple paragraphs",
input: `<p>First paragraph with <mention-user data-id="user1" data-label="User"></mention-user></p><p>Second paragraph</p>`,
expected: `<p>First paragraph with <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong></p><p>Second paragraph</p>`,
useRegex: true,
},
{
name: "HTML preservation with bold and italic",
input: `<p><strong>Bold text</strong> and <em>italic</em> with <mention-user data-id="user1" data-label="User"></mention-user></p>`,
expected: `<p><strong>Bold text</strong> and <em>italic</em> with <strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="User"/>User</strong></p>`,
useRegex: true,
},
{
name: "special characters in data-label",
input: `<p><mention-user data-id="user1" data-label="O'Brien"></mention-user> test</p>`,
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="O&#39;Brien"/>O&#39;Brien</strong> test</p>`,
useRegex: true,
},
{
name: "special characters - ampersand in data-label",
input: `<p><mention-user data-id="user1" data-label="Tom &amp; Jerry"></mention-user> test</p>`,
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="Tom &amp; Jerry"/>Tom &amp; Jerry</strong> test</p>`,
useRegex: true,
},
{
name: "special characters - quotes in data-label",
input: `<p><mention-user data-id="user1" data-label="&quot;Nickname&quot;"></mention-user> test</p>`,
expected: `<p><strong><img src="data:image/svg\+xml;base64,[A-Za-z0-9+/=]+" width="20" height="20" style="border-radius: 50%; vertical-align: middle; margin-right: 4px;" alt="&#34;Nickname&#34;"/>&#34;Nickname&#34;</strong> test</p>`,
useRegex: true,
},
{
name: "mixed old and new format",
input: `<p><mention-user data-id="new" data-label="New User"></mention-user> and <mention-user data-id="old">@old</mention-user></p>`,
expected: `<p><strong>@New User</strong> and <strong>@old</strong></p>`,
},
{
name: "self-closing tag format (XML-style)",
input: `<p><mention-user data-id="user" data-label="User"/> hello</p>`,
expected: `<p><strong>@User</strong></p>`,
},
{
name: "mention with only text content (no attributes) - old format edge case",
input: `<p><mention-user>@someuser</mention-user> test</p>`,
expected: `<p><mention-user>@someuser</mention-user> test</p>`,
},
{
name: "data-label takes precedence over data-id",
input: `<p><mention-user data-id="username123" data-label="John Smith"></mention-user> test</p>`,
expected: `<p><strong>@John Smith</strong> test</p>`,
},
{
name: "unicode characters in data-label",
input: `<p><mention-user data-id="user" data-label="Müller François"></mention-user> test</p>`,
expected: `<p><strong>@Müller François</strong> test</p>`,
},
{
name: "emoji in data-label",
input: `<p><mention-user data-id="user" data-label="Cool User 😎"></mention-user> test</p>`,
expected: `<p><strong>@Cool User 😎</strong> test</p>`,
},
{
name: "nested HTML structure",
input: `<div><p>Text with <mention-user data-id="user" data-label="User"></mention-user> in div</p></div>`,
expected: `<div><p>Text with <strong>@User</strong> in div</p></div>`,
},
{
name: "mention in list",
input: `<ul><li>Item with <mention-user data-id="user" data-label="User"></mention-user></li></ul>`,
expected: `<ul><li>Item with <strong>@User</strong></li></ul>`,
},
{
name: "very long name",
input: `<p><mention-user data-id="user" data-label="Christopher Montgomery Bartholomew Johnson-Smith III"></mention-user> test</p>`,
expected: `<p><strong>@Christopher Montgomery Bartholomew Johnson-Smith III</strong> test</p>`,
},
{
name: "empty data-label and data-id with text content",
input: `<p><mention-user>@fallback</mention-user> test</p>`,
expected: `<p><mention-user>@fallback</mention-user> test</p>`,
},
{
name: "whitespace in data-label",
input: `<p><mention-user data-id="user" data-label=" Spaces "></mention-user> test</p>`,
expected: `<p><strong>@ Spaces </strong> test</p>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
result := formatMentionsForEmail(s, tt.input)
if tt.useRegex {
matched, err := regexp.MatchString(tt.expected, result)
require.NoError(t, err, "Invalid regex pattern: %s", tt.expected)
assert.True(t, matched, "Result does not match regex pattern.\nExpected pattern: %s\nActual result: %s", tt.expected, result)
} else {
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestFormatMentionsForEmail_MalformedHTML(t *testing.T) {
tests := []struct {
name string
input string
}{
{
name: "unclosed tag - returns original",
input: `<p>Test <mention-user data-id="user" data-label="User">`,
},
{
name: "invalid HTML entities",
input: `<p>Test &invalid; entity</p>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
result := formatMentionsForEmail(s, tt.input)
// For malformed HTML, we expect it to either be fixed by the parser or returned as-is
// The key is that it shouldn't panic or error
assert.NotEmpty(t, result)
})
}
}

View File

@ -24,6 +24,7 @@ import (
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/i18n"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
@ -93,6 +94,9 @@ func (n *TaskCommentNotification) SubjectID() int64 {
// ToMail returns the mail notification for TaskCommentNotification
func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail {
s := db.NewSession()
defer s.Close()
formattedComment := formatMentionsForEmail(s, n.Comment.Comment)
mail := notifications.NewMail().
From(n.Doer.GetNameAndFromEmail()).
@ -104,7 +108,7 @@ func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail {
Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title))
}
mail.HTML(n.Comment.Comment)
mail.HTML(formattedComment)
return mail.
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL())
@ -350,6 +354,10 @@ func (n *UserMentionedInTaskNotification) SubjectID() int64 {
// ToMail returns the mail notification for UserMentionedInTaskNotification
func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mail {
s := db.NewSession()
defer s.Close()
formattedDescription := formatMentionsForEmail(s, n.Task.Description)
var subject string
if n.IsNew {
subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title)
@ -361,7 +369,7 @@ func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mai
From(n.Doer.GetNameAndFromEmail()).
Subject(subject).
Line(i18n.T(lang, "notifications.task.mentioned.message", n.Doer.GetName())).
HTML(n.Task.Description)
HTML(formattedDescription)
return mail.
Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL())

View File

@ -32,6 +32,8 @@ import (
type Provider interface {
// GetAvatar is the method used to get an actual avatar for a user
GetAvatar(user *user.User, size int64) (avatar []byte, mimeType string, err error)
// AsDataURI returns a base64-encoded string representation of the avatar suitable for inline use
AsDataURI(user *user.User, size int64) (inlineData string, err error)
// FlushCache removes cached avatar data for the user
FlushCache(u *user.User) error
}
@ -53,3 +55,28 @@ func FlushAllCaches(u *user.User) {
}
}
}
// GetProvider returns the appropriate avatar provider for a user
func GetProvider(u *user.User) Provider {
provider := u.AvatarProvider
if provider == "" {
provider = "empty"
}
switch provider {
case "gravatar":
return &gravatar.Provider{}
case "initials":
return &initials.Provider{}
case "upload":
return &upload.Provider{}
case "marble":
return &marble.Provider{}
case "ldap":
return &ldap.Provider{}
case "openid":
return &openid.Provider{}
default:
return &empty.Provider{}
}
}

View File

@ -16,7 +16,12 @@
package empty
import "code.vikunja.io/api/pkg/user"
import (
"encoding/base64"
"fmt"
"code.vikunja.io/api/pkg/user"
)
// Provider represents the empty avatar provider
type Provider struct {
@ -46,3 +51,12 @@ const defaultAvatar string = `<?xml version="1.0" encoding="UTF-8"?>
func (p *Provider) GetAvatar(_ *user.User, _ int64) (avatar []byte, mimeType string, err error) {
return []byte(defaultAvatar), "image/svg+xml", nil
}
// AsDataURI returns a data URI for the default SVG avatar
func (p *Provider) AsDataURI(_ *user.User, _ int64) (string, error) {
// Encode the SVG as base64 and create a data URI
base64Data := base64.StdEncoding.EncodeToString([]byte(defaultAvatar))
dataURI := fmt.Sprintf("data:image/svg+xml;base64,%s", base64Data)
return dataURI, nil
}

View File

@ -18,6 +18,8 @@ package gravatar
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"strconv"
@ -123,6 +125,20 @@ func (g *Provider) GetAvatar(user *user.User, size int64) ([]byte, string, error
return av.Content, av.MimeType, nil
}
// AsDataURI returns a base64 encoded data URI for the gravatar
func (g *Provider) AsDataURI(user *user.User, size int64) (string, error) {
avatarData, mimeType, err := g.GetAvatar(user, size)
if err != nil {
return "", err
}
// Encode the avatar data as base64 and create a data URI
base64Data := base64.StdEncoding.EncodeToString(avatarData)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
return dataURI, nil
}
func (g *Provider) avatarExpired(av avatar) bool {
return time.Since(av.LoadedAt) > time.Duration(config.AvatarGravaterExpiration.GetInt64())*time.Second
}

View File

@ -17,6 +17,7 @@
package initials
import (
"encoding/base64"
"fmt"
"html"
"strconv"
@ -81,3 +82,17 @@ func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType
return []byte(svg), "image/svg+xml", nil
}
// AsDataURI returns a data URI for the SVG avatar
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
avatarData, mimeType, err := p.GetAvatar(u, size)
if err != nil {
return "", err
}
// Encode the SVG as base64 and create a data URI
base64Data := base64.StdEncoding.EncodeToString(avatarData)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
return dataURI, nil
}

View File

@ -0,0 +1,77 @@
// 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 <https://www.gnu.org/licenses/>.
package avatar
import (
"strings"
"testing"
"code.vikunja.io/api/pkg/modules/avatar/empty"
"code.vikunja.io/api/pkg/modules/avatar/initials"
"code.vikunja.io/api/pkg/modules/avatar/marble"
"code.vikunja.io/api/pkg/user"
)
func TestAsDataURI(t *testing.T) {
testUser := &user.User{
ID: 1,
Username: "testuser",
Name: "Test User",
Email: "test@example.com",
}
// Table-driven test for SVG providers
testCases := []struct {
name string
provider Provider
}{
{
name: "Initials Provider",
provider: &initials.Provider{},
},
{
name: "Marble Provider",
provider: &marble.Provider{},
},
{
name: "Empty Provider",
provider: &empty.Provider{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := tc.provider.AsDataURI(testUser, 64)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if !strings.HasPrefix(result, "data:image/svg+xml;base64,") {
t.Errorf("Expected data URI with SVG base64, got: %s", result)
}
// Basic sanity check for reasonable length
if len(result) < 50 {
t.Errorf("Expected longer data URI, got: %s", result)
}
})
}
t.Run("Gravatar Provider - Base64 Format", func(t *testing.T) {
// Skip this test as it requires keyvalue store initialization
// and network access to gravatar service
t.Skip("Gravatar provider test requires full application setup")
})
}

View File

@ -29,6 +29,11 @@ func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeTy
return up.GetAvatar(user, size)
}
func (p *Provider) AsDataURI(user *user.User, size int64) (string, error) {
up := upload.Provider{}
return up.AsDataURI(user, size)
}
func (p *Provider) FlushCache(u *user.User) error {
up := upload.Provider{}
return up.FlushCache(u)

View File

@ -17,6 +17,8 @@
package marble
import (
"encoding/base64"
"fmt"
"math"
"strconv"
@ -123,3 +125,17 @@ func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType
</defs>
</svg>`), "image/svg+xml", nil
}
// AsDataURI returns a data URI for the SVG avatar
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
avatarData, mimeType, err := p.GetAvatar(u, size)
if err != nil {
return "", err
}
// Encode the SVG as base64 and create a data URI
base64Data := base64.StdEncoding.EncodeToString(avatarData)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
return dataURI, nil
}

View File

@ -29,6 +29,11 @@ func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeTy
return up.GetAvatar(user, size)
}
func (p *Provider) AsDataURI(user *user.User, size int64) (string, error) {
up := upload.Provider{}
return up.AsDataURI(user, size)
}
func (p *Provider) FlushCache(u *user.User) error {
up := upload.Provider{}
return up.FlushCache(u)

View File

@ -18,6 +18,7 @@ package upload
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
@ -125,6 +126,20 @@ func (p *Provider) getAvatarWithDepth(u *user.User, size int64, recursionDepth i
return cachedAvatar.Content, cachedAvatar.MimeType, nil
}
// AsDataURI returns a base64 encoded data URI for the uploaded avatar
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
avatarData, mimeType, err := p.GetAvatar(u, size)
if err != nil {
return "", err
}
// Encode the avatar data as base64 and create a data URI
base64Data := base64.StdEncoding.EncodeToString(avatarData)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
return dataURI, nil
}
func StoreAvatarFile(s *xorm.Session, u *user.User, src io.Reader) (err error) {
// Remove the old file if one exists

View File

@ -108,6 +108,12 @@ var logo embed.FS
func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) {
p := bluemonday.UGCPolicy()
// Allow data URI images for inline avatars in mentions
p.AllowDataURIImages()
// Allow style attribute on img elements for avatar styling
p.AllowAttrs("style").OnElements("img")
// Allow specific CSS properties for avatar styling
p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img")
for _, line := range lines {
if line.isHTML {

View File

@ -23,11 +23,6 @@ import (
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/avatar"
"code.vikunja.io/api/pkg/modules/avatar/empty"
"code.vikunja.io/api/pkg/modules/avatar/gravatar"
"code.vikunja.io/api/pkg/modules/avatar/initials"
"code.vikunja.io/api/pkg/modules/avatar/ldap"
"code.vikunja.io/api/pkg/modules/avatar/marble"
"code.vikunja.io/api/pkg/modules/avatar/openid"
"code.vikunja.io/api/pkg/modules/avatar/upload"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web/handler"
@ -68,23 +63,7 @@ func GetAvatar(c echo.Context) error {
found := err == nil || !user.IsErrUserDoesNotExist(err)
var avatarProvider avatar.Provider
switch u.AvatarProvider {
case "gravatar":
avatarProvider = &gravatar.Provider{}
case "initials":
avatarProvider = &initials.Provider{}
case "upload":
avatarProvider = &upload.Provider{}
case "marble":
avatarProvider = &marble.Provider{}
case "ldap":
avatarProvider = &ldap.Provider{}
case "openid":
avatarProvider = &openid.Provider{}
default:
avatarProvider = &empty.Provider{}
}
avatarProvider := avatar.GetProvider(u)
if !found {
avatarProvider = &empty.Provider{}