From f2a1348c5182c0c96ff9c8695b0450da85ef3d9e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:58:32 +0100 Subject: [PATCH] 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 --- go.sum | 4 -- pkg/mail/send_mail.go | 6 +++ pkg/models/notifications.go | 44 ++++++++++++++++ pkg/models/notifications_test.go | 85 +++++++++++++++++++++++++++++++ pkg/notifications/mail.go | 7 +++ pkg/notifications/mail_render.go | 1 + pkg/notifications/mail_test.go | 15 ++++++ pkg/notifications/notification.go | 8 +++ 8 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 pkg/models/notifications_test.go diff --git a/go.sum b/go.sum index 023f1789f..dc3b1c02f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/mail/send_mail.go b/pkg/mail/send_mail.go index 1b386b7f2..57e02c1d6 100644 --- a/pkg/mail/send_mail.go +++ b/pkg/mail/send_mail.go @@ -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 { diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 56cd9b11c..bf7c17d9f 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -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("", 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"` diff --git a/pkg/models/notifications_test.go b/pkg/models/notifications_test.go new file mode 100644 index 000000000..526f39951 --- /dev/null +++ b/pkg/models/notifications_test.go @@ -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 . + +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, "", threadID) + }) + + t.Run("simple domain without port", func(t *testing.T) { + config.ServicePublicURL.Set("https://vikunja.example.com") + threadID := getThreadID(456) + assert.Equal(t, "", 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, "", 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, "", 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, "", 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, "", 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, "", 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, "", threadID) + }) +} diff --git a/pkg/notifications/mail.go b/pkg/notifications/mail.go index 36272290f..481553f62 100644 --- a/pkg/notifications/mail.go +++ b/pkg/notifications/mail.go @@ -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{ diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 0cb0957c9..0c483f0e2 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -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, }, diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go index cc1dfa5c3..d6578f023 100644 --- a/pkg/notifications/mail_test.go +++ b/pkg/notifications/mail_test.go @@ -407,4 +407,19 @@ This is a footer line `, 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("") + + 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, "", mailopts.ThreadID) + }) } diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go index 28b171d22..9f05c33a9 100644 --- a/pkg/notifications/notification.go +++ b/pkg/notifications/notification.go @@ -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()) }