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
14 changed files with 610 additions and 25 deletions

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