feat: add thread IDs to task notification emails for client-side threading (#1826)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kolaente <13721712+kolaente@users.noreply.github.com>
Co-authored-by: kolaente <k@knt.li>
This commit is contained in:
Copilot
2025-11-15 18:58:32 +01:00
committed by GitHub
parent 14c7bd88f2
commit f2a1348c51
8 changed files with 166 additions and 4 deletions

4
go.sum
View File

@@ -107,12 +107,8 @@ github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.36.2 h1:uhuxRPTrUy0dnSzTd0LrYXlBYygLkKY0hhlG5LXarzM=
github.com/getsentry/sentry-go v0.36.2/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c=
github.com/getsentry/sentry-go v0.37.0 h1:5bavywHxVkU/9aOIF4fn3s5RTJX5Hdw6K2W6jLYtM98=
github.com/getsentry/sentry-go v0.37.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/getsentry/sentry-go/echo v0.36.2 h1:H7bs/dVFm9vcVDqnkfQcXQvjpf/Sa5dIQ3E5Bv9FVcY=
github.com/getsentry/sentry-go/echo v0.36.2/go.mod h1:Nz4jnPokxo2zh03bOLbVNwewsKNC0OqHWFKjvCjkKx4=
github.com/getsentry/sentry-go/echo v0.37.0 h1:Lzpg9MVmMD9jPyuKyilyDtrH6dOU3luSLSjj+r5KfVI=
github.com/getsentry/sentry-go/echo v0.37.0/go.mod h1:wbh4ppYCgmnuoIMGu/DrQzD0NoX6vt2qfoRxMe2wkUQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=

View File

@@ -40,6 +40,7 @@ type Opts struct {
Headers []*header
Embeds map[string]io.Reader
EmbedFS map[string]*embed.FS
ThreadID string
}
// ContentType represents mail content types
@@ -88,6 +89,11 @@ func getMessage(opts *Opts) *mail.Msg {
m.SetGenHeader(h.Field, h.Content)
}
if opts.ThreadID != "" {
m.SetGenHeader(mail.HeaderInReplyTo, opts.ThreadID)
m.SetGenHeader(mail.HeaderReferences, opts.ThreadID)
}
for name, content := range opts.Embeds {
err := m.EmbedReader(name, content)
if err != nil {

View File

@@ -17,6 +17,8 @@
package models
import (
"fmt"
"net/url"
"sort"
"strconv"
"time"
@@ -28,6 +30,18 @@ import (
"code.vikunja.io/api/pkg/utils"
)
// getThreadID generates a Message-ID format thread ID for a task
func getThreadID(taskID int64) string {
domain := "vikunja"
publicURL := config.ServicePublicURL.GetString()
if publicURL != "" {
if parsedURL, err := url.Parse(publicURL); err == nil && parsedURL.Hostname() != "" {
domain = parsedURL.Hostname()
}
}
return fmt.Sprintf("<task-%d@%s>", taskID, domain)
}
// ReminderDueNotification represents a ReminderDueNotification notification
type ReminderDueNotification struct {
User *user.User `json:"user,omitempty"`
@@ -60,6 +74,11 @@ func (n *ReminderDueNotification) Name() string {
return "task.reminder"
}
// ThreadID returns the thread ID for email threading
func (n *ReminderDueNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// TaskCommentNotification represents a TaskCommentNotification notification
type TaskCommentNotification struct {
Doer *user.User `json:"doer"`
@@ -101,6 +120,11 @@ func (n *TaskCommentNotification) Name() string {
return "task.comment"
}
// ThreadID returns the thread ID for email threading
func (n *TaskCommentNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// TaskAssignedNotification represents a TaskAssignedNotification notification
type TaskAssignedNotification struct {
Doer *user.User `json:"doer"`
@@ -134,6 +158,11 @@ func (n *TaskAssignedNotification) Name() string {
return "task.assigned"
}
// ThreadID returns the thread ID for email threading
func (n *TaskAssignedNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// TaskDeletedNotification represents a TaskDeletedNotification notification
type TaskDeletedNotification struct {
Doer *user.User `json:"doer"`
@@ -157,6 +186,11 @@ func (n *TaskDeletedNotification) Name() string {
return "task.deleted"
}
// ThreadID returns the thread ID for email threading
func (n *TaskDeletedNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// ProjectCreatedNotification represents a ProjectCreatedNotification notification
type ProjectCreatedNotification struct {
Doer *user.User `json:"doer"`
@@ -245,6 +279,11 @@ func (n *UndoneTaskOverdueNotification) Name() string {
return "task.undone.overdue"
}
// ThreadID returns the thread ID for email threading
func (n *UndoneTaskOverdueNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// UndoneTasksOverdueNotification represents a UndoneTasksOverdueNotification notification
type UndoneTasksOverdueNotification struct {
User *user.User
@@ -330,6 +369,11 @@ func (n *UserMentionedInTaskNotification) Name() string {
return "task.mentioned"
}
// ThreadID returns the thread ID for email threading
func (n *UserMentionedInTaskNotification) ThreadID() string {
return getThreadID(n.Task.ID)
}
// DataExportReadyNotification represents a DataExportReadyNotification notification
type DataExportReadyNotification struct {
User *user.User `json:"user"`

View File

@@ -0,0 +1,85 @@
// 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 models
import (
"testing"
"code.vikunja.io/api/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestGetThreadID(t *testing.T) {
// Save original config value
originalPublicURL := config.ServicePublicURL.GetString()
defer func() {
config.ServicePublicURL.Set(originalPublicURL)
}()
t.Run("default domain when no public URL", func(t *testing.T) {
config.ServicePublicURL.Set("")
threadID := getThreadID(123)
assert.Equal(t, "<task-123@vikunja>", threadID)
})
t.Run("simple domain without port", func(t *testing.T) {
config.ServicePublicURL.Set("https://vikunja.example.com")
threadID := getThreadID(456)
assert.Equal(t, "<task-456@vikunja.example.com>", threadID)
})
t.Run("domain with standard HTTPS port", func(t *testing.T) {
config.ServicePublicURL.Set("https://vikunja.example.com:443")
threadID := getThreadID(789)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-789@vikunja.example.com>", threadID)
})
t.Run("domain with non-standard port", func(t *testing.T) {
config.ServicePublicURL.Set("http://localhost:8080")
threadID := getThreadID(999)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-999@localhost>", threadID)
})
t.Run("domain with port 3456", func(t *testing.T) {
config.ServicePublicURL.Set("http://vikunja.local:3456")
threadID := getThreadID(111)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-111@vikunja.local>", threadID)
})
t.Run("IP address with port", func(t *testing.T) {
config.ServicePublicURL.Set("http://192.168.1.100:8080")
threadID := getThreadID(222)
// Should strip port to create valid RFC 5322 domain
assert.Equal(t, "<task-222@192.168.1.100>", threadID)
})
t.Run("invalid URL falls back to default", func(t *testing.T) {
config.ServicePublicURL.Set("not a valid url")
threadID := getThreadID(333)
assert.Equal(t, "<task-333@vikunja>", threadID)
})
t.Run("URL with path", func(t *testing.T) {
config.ServicePublicURL.Set("https://example.com:9000/vikunja")
threadID := getThreadID(444)
// Should use hostname without port
assert.Equal(t, "<task-444@example.com>", threadID)
})
}

View File

@@ -35,6 +35,7 @@ type Mail struct {
introLines []*mailLine
outroLines []*mailLine
footerLines []*mailLine
threadID string
}
type mailLine struct {
@@ -100,6 +101,12 @@ func (m *Mail) HTML(line string) *Mail {
return m.appendLine(line, true)
}
// ThreadID sets the thread ID of the mail message for email threading
func (m *Mail) ThreadID(threadID string) *Mail {
m.threadID = threadID
return m
}
func (m *Mail) appendLine(line string, isHTML bool) *Mail {
if m.actionURL == "" {
m.introLines = append(m.introLines, &mailLine{

View File

@@ -195,6 +195,7 @@ func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) {
Message: plainContent.String(),
HTMLMessage: htmlContent.String(),
Boundary: boundary,
ThreadID: m.threadID,
EmbedFS: map[string]*embed.FS{
"logo.png": &logo,
},

View File

@@ -407,4 +407,19 @@ This is a footer line
</html>
`, 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("<task-123@vikunja>")
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, "<task-123@vikunja>", mailopts.ThreadID)
})
}

View File

@@ -39,6 +39,10 @@ type NotificationWithSubject interface {
SubjectID
}
type ThreadID interface {
ThreadID() string
}
// Notifiable is an entity which can be notified. Usually a user.
type Notifiable interface {
// RouteForMail should return the email address this notifiable has.
@@ -85,6 +89,10 @@ func notifyMail(notifiable Notifiable, notification Notification) error {
}
mail.To(to)
if threadID, is := notification.(ThreadID); is {
mail.ThreadID(threadID.ThreadID())
}
return SendMail(mail, notifiable.Lang())
}