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.
This commit is contained in:
kolaente
2026-03-08 15:52:57 +01:00
parent d4b03026f0
commit b3572c5932
2 changed files with 113 additions and 23 deletions

View File

@@ -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)

View File

@@ -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