From b3572c5932ba9eb7159e48129c1e52f0333cf96e Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 8 Mar 2026 15:52:57 +0100 Subject: [PATCH] feat: convert notifications to conversational email style Convert task comment, mention, assignment, and reminder notifications to use the conversational email format. Add Project field to notification structs, include task identifiers in subjects and headers, add doer avatars, notification settings links in footers, and From headers. --- pkg/models/listeners.go | 43 ++++++++++++++--- pkg/models/notifications.go | 93 ++++++++++++++++++++++++++++++------- 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 4c456f82e..7459ea1e1 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -187,11 +187,17 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) { sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &TaskCommentNotification{ Doer: event.Doer, Task: event.Task, Comment: event.Comment, Mentioned: true, + Project: project, } mentionedUsers, err := notifyMentionedUsers(sess, event.Task, event.Comment.Comment, n) if err != nil { @@ -218,6 +224,7 @@ func (s *SendTaskCommentNotification) Handle(msg *message.Message) (err error) { Doer: event.Doer, Task: event.Task, Comment: event.Comment, + Project: project, } err = notifications.Notify(subscriber.User, n, sess) if err != nil { @@ -252,11 +259,17 @@ func (s *HandleTaskCommentEditMentions) Handle(msg *message.Message) (err error) sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &TaskCommentNotification{ Doer: event.Doer, Task: event.Task, Comment: event.Comment, Mentioned: true, + Project: project, } _, err = notifyMentionedUsers(sess, event.Task, event.Comment.Comment, n) if err != nil { @@ -297,6 +310,11 @@ func (s *SendTaskAssignedNotification) Handle(msg *message.Message) (err error) return err } + project, err := GetProjectSimpleByID(sess, task.ProjectID) + if err != nil { + return err + } + notifiedUsers := make(map[int64]bool) for _, subscriber := range subscribers { @@ -314,6 +332,7 @@ func (s *SendTaskAssignedNotification) Handle(msg *message.Message) (err error) Task: &task, Assignee: event.Assignee, Target: subscriber.User, + Project: project, } err = notifications.Notify(subscriber.User, n, sess) if err != nil { @@ -401,10 +420,16 @@ func (s *HandleTaskCreateMentions) Handle(msg *message.Message) (err error) { sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &UserMentionedInTaskNotification{ - Task: event.Task, - Doer: event.Doer, - IsNew: true, + Task: event.Task, + Doer: event.Doer, + IsNew: true, + Project: project, } _, err = notifyMentionedUsers(sess, event.Task, event.Task.Description, n) if err != nil { @@ -437,10 +462,16 @@ func (s *HandleTaskUpdatedMentions) Handle(msg *message.Message) (err error) { sess := db.NewSession() defer sess.Close() + project, err := GetProjectSimpleByID(sess, event.Task.ProjectID) + if err != nil { + return err + } + n := &UserMentionedInTaskNotification{ - Task: event.Task, - Doer: event.Doer, - IsNew: false, + Task: event.Task, + Doer: event.Doer, + IsNew: false, + Project: project, } _, err = notifyMentionedUsers(sess, event.Task, event.Task.Description, n) diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 78266b242..3d2e595bb 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -26,11 +26,22 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/i18n" + "code.vikunja.io/api/pkg/modules/avatar" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" ) +// getDoerAvatarDataURI returns the avatar data URI for a user, for use in email headers. +func getDoerAvatarDataURI(doer *user.User) string { + provider := avatar.GetProvider(doer) + dataURI, err := provider.AsDataURI(doer, 20) + if err != nil { + return "" + } + return dataURI +} + // getThreadID generates a Message-ID format thread ID for a task func getThreadID(taskID int64) string { domain := "vikunja" @@ -86,6 +97,7 @@ type TaskCommentNotification struct { Task *Task `json:"task"` Comment *TaskComment `json:"comment"` Mentioned bool `json:"mentioned"` + Project *Project `json:"project"` } func (n *TaskCommentNotification) SubjectID() int64 { @@ -99,19 +111,33 @@ func (n *TaskCommentNotification) ToMail(lang string) *notifications.Mail { formattedComment := formatMentionsForEmail(s, n.Comment.Comment) mail := notifications.NewMail(). + Conversational(). From(n.Doer.GetNameAndFromEmail()). - Subject(i18n.T(lang, "notifications.task.comment.subject", n.Task.Title)) + Subject(i18n.T(lang, "notifications.task.comment.subject", n.Task.Title, n.Task.GetFullIdentifier())) + // Add header line + action := i18n.T(lang, "notifications.common.actions.left_comment", n.Doer.GetName()) if n.Mentioned { - mail. - Line(i18n.T(lang, "notifications.task.comment.mentioned_message", n.Doer.GetName())). - Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title)) + action = i18n.T(lang, "notifications.common.actions.mentioned_you_comment", n.Doer.GetName()) + mail.Subject(i18n.T(lang, "notifications.task.comment.mentioned_subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier())) } + headerLine := notifications.CreateConversationalHeader( + getDoerAvatarDataURI(n.Doer), + action, + n.Task.GetFrontendURL(), + n.Project.Title, + n.Task.GetFullIdentifier(), + n.Task.Title, + ) + mail.HeaderLine(headerLine) + + // Add the actual comment content wrapped in a div for consistent spacing mail.HTML(formattedComment) return mail. - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // ToDB returns the TaskCommentNotification notification in a format which can be saved in the db @@ -135,29 +161,41 @@ type TaskAssignedNotification struct { Task *Task `json:"task"` Assignee *user.User `json:"assignee"` Target *user.User `json:"-"` + Project *Project `json:"project"` } // ToMail returns the mail notification for TaskAssignedNotification func (n *TaskAssignedNotification) ToMail(lang string) *notifications.Mail { if n.Target.ID == n.Assignee.ID { + // Notification to the assignee return notifications.NewMail(). + From(n.Doer.GetNameAndFromEmail()). Subject(i18n.T(lang, "notifications.task.assigned.subject_to_assignee", n.Task.Title, n.Task.GetFullIdentifier())). + Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())). Line(i18n.T(lang, "notifications.task.assigned.message_to_assignee", n.Doer.GetName(), n.Task.Title)). - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // Check if the doer assigned the task to themselves if n.Doer.ID == n.Assignee.ID { return notifications.NewMail(). + From(n.Doer.GetNameAndFromEmail()). Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others_self", n.Task.Title, n.Task.GetFullIdentifier(), n.Doer.GetName())). + Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())). Line(i18n.T(lang, "notifications.task.assigned.message_to_others_self", n.Doer.GetName())). - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } + // Notification to others about assignment return notifications.NewMail(). + From(n.Doer.GetNameAndFromEmail()). Subject(i18n.T(lang, "notifications.task.assigned.subject_to_others", n.Task.Title, n.Task.GetFullIdentifier(), n.Assignee.GetName())). + Greeting(i18n.T(lang, "notifications.greeting", n.Target.GetName())). Line(i18n.T(lang, "notifications.task.assigned.message_to_others", n.Doer.GetName(), n.Assignee.GetName())). - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // ToDB returns the TaskAssignedNotification notification in a format which can be saved in the db @@ -343,9 +381,10 @@ func (n *UndoneTasksOverdueNotification) Name() string { // UserMentionedInTaskNotification represents a UserMentionedInTaskNotification notification type UserMentionedInTaskNotification struct { - Doer *user.User `json:"doer"` - Task *Task `json:"task"` - IsNew bool `json:"is_new"` + Doer *user.User `json:"doer"` + Task *Task `json:"task"` + IsNew bool `json:"is_new"` + Project *Project `json:"project"` } func (n *UserMentionedInTaskNotification) SubjectID() int64 { @@ -360,19 +399,39 @@ func (n *UserMentionedInTaskNotification) ToMail(lang string) *notifications.Mai var subject string if n.IsNew { - subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title) + subject = i18n.T(lang, "notifications.task.mentioned.subject_new", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier()) } else { - subject = i18n.T(lang, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title) + subject = i18n.T(lang, "notifications.task.mentioned.subject", n.Doer.GetName(), n.Task.Title, n.Task.GetFullIdentifier()) } mail := notifications.NewMail(). + Conversational(). From(n.Doer.GetNameAndFromEmail()). - Subject(subject). - Line(i18n.T(lang, "notifications.task.mentioned.message", n.Doer.GetName())). - HTML(formattedDescription) + Subject(subject) + + // Add header line + action := i18n.T(lang, "notifications.common.actions.mentioned_you", n.Doer.GetName()) + if n.IsNew { + action = i18n.T(lang, "notifications.common.actions.mentioned_you_new_task", n.Doer.GetName()) + } + + headerLine := notifications.CreateConversationalHeader( + getDoerAvatarDataURI(n.Doer), + action, + n.Task.GetFrontendURL(), + n.Project.Title, + n.Task.GetFullIdentifier(), + n.Task.Title, + ) + mail.HeaderLine(headerLine) + + if formattedDescription != "" { + mail.HTML(formattedDescription) + } return mail. - Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()) + Action(i18n.T(lang, "notifications.common.actions.open_task"), n.Task.GetFrontendURL()). + IncludeLinkToSettings(lang) } // ToDB returns the UserMentionedInTaskNotification notification in a format which can be saved in the db