From fb7764d9f1cbad72fe54fbe37ece042979076150 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Dec 2025 12:39:05 +0100 Subject: [PATCH] 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. --- pkg/models/mentions.go | 189 ++++++++++++++++ pkg/models/mentions_test.go | 213 ++++++++++++++++++ pkg/models/notifications.go | 12 +- pkg/modules/avatar/avatar.go | 27 +++ pkg/modules/avatar/empty/empty.go | 16 +- pkg/modules/avatar/gravatar/gravatar.go | 16 ++ pkg/modules/avatar/initials/initials.go | 15 ++ .../avatar/inline_profile_picture_test.go | 77 +++++++ pkg/modules/avatar/ldap/ldap.go | 5 + pkg/modules/avatar/marble/marble.go | 16 ++ pkg/modules/avatar/openid/openid.go | 5 + pkg/modules/avatar/upload/upload.go | 15 ++ pkg/notifications/mail_render.go | 6 + pkg/routes/api/v1/avatar.go | 23 +- 14 files changed, 610 insertions(+), 25 deletions(-) create mode 100644 pkg/modules/avatar/inline_profile_picture_test.go diff --git a/pkg/models/mentions.go b/pkg/models/mentions.go index f602201e1..ae3610997 100644 --- a/pkg/models/mentions.go +++ b/pkg/models/mentions.go @@ -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 tags to +// Display Name 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 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() +} diff --git a/pkg/models/mentions_test.go b/pkg/models/mentions_test.go index e7da46c0e..58b01794f 100644 --- a/pkg/models/mentions_test.go +++ b/pkg/models/mentions_test.go @@ -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: "

Lorem Ipsum dolor sit amet

", + expected: "

Lorem Ipsum dolor sit amet

", + }, + { + name: "single mention with data-label (new format)", + input: `

hello

`, + expected: `

@Frederick hello

`, + }, + { + name: "single mention with full name in data-label", + input: `

please help

`, + expected: `

@John Doe please help

`, + }, + { + name: "mention without data-label (fallback to data-id)", + input: `

test

`, + expected: `

@johndoe test

`, + }, + { + name: "old format with text node inside", + input: `

@user1 Lorem Ipsum

`, + expected: `

user1user1 Lorem Ipsum

`, + useRegex: true, + }, + { + name: "old format with text node (data-id takes precedence over text)", + input: `

@differentuser text

`, + expected: `

@actualuser text

`, + }, + { + name: "multiple mentions in one paragraph", + input: `

Hey and , please review

`, + expected: `

Hey @John and @Jane Doe, please review

`, + }, + { + name: "mention at beginning", + input: `

Lorem Ipsum

`, + expected: `

User OneUser One Lorem Ipsum

`, + useRegex: true, + }, + { + name: "mention at end", + input: `

Lorem Ipsum

`, + expected: `

Lorem Ipsum User OneUser One

`, + useRegex: true, + }, + { + name: "mention in middle", + input: `

Lorem Ipsum

`, + expected: `

Lorem User OneUser One Ipsum

`, + useRegex: true, + }, + { + name: "same user mentioned multiple times", + input: `

and again

`, + expected: `

UserUser and UserUser again

`, + useRegex: true, + }, + { + name: "HTML preservation with links", + input: `

Check this link and ask

`, + expected: `

Check this link and ask @Expert

`, + }, + { + name: "HTML preservation with multiple paragraphs", + input: `

First paragraph with

Second paragraph

`, + expected: `

First paragraph with UserUser

Second paragraph

`, + useRegex: true, + }, + { + name: "HTML preservation with bold and italic", + input: `

Bold text and italic with

`, + expected: `

Bold text and italic with UserUser

`, + useRegex: true, + }, + { + name: "special characters in data-label", + input: `

test

`, + expected: `

O'BrienO'Brien test

`, + useRegex: true, + }, + { + name: "special characters - ampersand in data-label", + input: `

test

`, + expected: `

Tom & JerryTom & Jerry test

`, + useRegex: true, + }, + { + name: "special characters - quotes in data-label", + input: `

test

`, + expected: `

"Nickname""Nickname" test

`, + useRegex: true, + }, + { + name: "mixed old and new format", + input: `

and @old

`, + expected: `

@New User and @old

`, + }, + { + name: "self-closing tag format (XML-style)", + input: `

hello

`, + expected: `

@User

`, + }, + { + name: "mention with only text content (no attributes) - old format edge case", + input: `

@someuser test

`, + expected: `

@someuser test

`, + }, + { + name: "data-label takes precedence over data-id", + input: `

test

`, + expected: `

@John Smith test

`, + }, + { + name: "unicode characters in data-label", + input: `

test

`, + expected: `

@Müller François test

`, + }, + { + name: "emoji in data-label", + input: `

test

`, + expected: `

@Cool User 😎 test

`, + }, + { + name: "nested HTML structure", + input: `

Text with in div

`, + expected: `

Text with @User in div

`, + }, + { + name: "mention in list", + input: `
  • Item with
`, + expected: `
  • Item with @User
`, + }, + { + name: "very long name", + input: `

test

`, + expected: `

@Christopher Montgomery Bartholomew Johnson-Smith III test

`, + }, + { + name: "empty data-label and data-id with text content", + input: `

@fallback test

`, + expected: `

@fallback test

`, + }, + { + name: "whitespace in data-label", + input: `

test

`, + expected: `

@ Spaces test

`, + }, + } + + 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: `

Test `, + }, + { + name: "invalid HTML entities", + input: `

Test &invalid; entity

`, + }, + } + + 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) + }) + } +} diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index bf61df46a..78266b242 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -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()) diff --git a/pkg/modules/avatar/avatar.go b/pkg/modules/avatar/avatar.go index 9a7ab0f55..aefb9d50f 100644 --- a/pkg/modules/avatar/avatar.go +++ b/pkg/modules/avatar/avatar.go @@ -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{} + } +} diff --git a/pkg/modules/avatar/empty/empty.go b/pkg/modules/avatar/empty/empty.go index 9c0134612..ef0dfd4dc 100644 --- a/pkg/modules/avatar/empty/empty.go +++ b/pkg/modules/avatar/empty/empty.go @@ -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 = ` 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 +} diff --git a/pkg/modules/avatar/gravatar/gravatar.go b/pkg/modules/avatar/gravatar/gravatar.go index 32666a75d..8f459e7b6 100644 --- a/pkg/modules/avatar/gravatar/gravatar.go +++ b/pkg/modules/avatar/gravatar/gravatar.go @@ -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 } diff --git a/pkg/modules/avatar/initials/initials.go b/pkg/modules/avatar/initials/initials.go index ef0189e58..beb657d23 100644 --- a/pkg/modules/avatar/initials/initials.go +++ b/pkg/modules/avatar/initials/initials.go @@ -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 +} diff --git a/pkg/modules/avatar/inline_profile_picture_test.go b/pkg/modules/avatar/inline_profile_picture_test.go new file mode 100644 index 000000000..5ee86c40b --- /dev/null +++ b/pkg/modules/avatar/inline_profile_picture_test.go @@ -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 . + +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") + }) +} diff --git a/pkg/modules/avatar/ldap/ldap.go b/pkg/modules/avatar/ldap/ldap.go index 4a4e24d37..28795c12c 100644 --- a/pkg/modules/avatar/ldap/ldap.go +++ b/pkg/modules/avatar/ldap/ldap.go @@ -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) diff --git a/pkg/modules/avatar/marble/marble.go b/pkg/modules/avatar/marble/marble.go index 8a89ac106..2fcad6be6 100644 --- a/pkg/modules/avatar/marble/marble.go +++ b/pkg/modules/avatar/marble/marble.go @@ -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 `), "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 +} diff --git a/pkg/modules/avatar/openid/openid.go b/pkg/modules/avatar/openid/openid.go index d7398b540..14bb6ad4d 100644 --- a/pkg/modules/avatar/openid/openid.go +++ b/pkg/modules/avatar/openid/openid.go @@ -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) diff --git a/pkg/modules/avatar/upload/upload.go b/pkg/modules/avatar/upload/upload.go index ddf5b52be..d79efb624 100644 --- a/pkg/modules/avatar/upload/upload.go +++ b/pkg/modules/avatar/upload/upload.go @@ -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 diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index c356e60e7..7407e99c2 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -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 { diff --git a/pkg/routes/api/v1/avatar.go b/pkg/routes/api/v1/avatar.go index 53e8e8b8e..3a40cdd50 100644 --- a/pkg/routes/api/v1/avatar.go +++ b/pkg/routes/api/v1/avatar.go @@ -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{}