mirror of
https://github.com/go-vikunja/vikunja.git
synced 2025-12-05 19:16:51 -06:00
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:
4
go.sum
4
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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
85
pkg/models/notifications_test.go
Normal file
85
pkg/models/notifications_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user