Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions apps/api/internal/mail/notification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package mail

import "fmt"

// NotificationEmailData holds data for building notification email content.
type NotificationEmailData struct {
ReceiverName string
ActorName string
IssueRef string
IssueTitle string
IssueURL string
WorkspaceName string
CommentPreview string
FieldName string
OldValue string
NewValue string
}

// BuildNotificationEmail returns subject and body for a notification email.
// sender is one of: "assigned", "mentioned", "commented", "state_changed", "subscribed"
func BuildNotificationEmail(sender string, data NotificationEmailData) (subject, body string) {
subject = buildNotificationSubject(sender, data)
body = buildNotificationBody(sender, data)
return subject, body
}

func buildNotificationSubject(sender string, data NotificationEmailData) string {
switch sender {
case "assigned":
return fmt.Sprintf("%s assigned you to %s", data.ActorName, data.IssueRef)
case "mentioned":
return fmt.Sprintf("%s mentioned you in %s", data.ActorName, data.IssueRef)
case "commented":
return fmt.Sprintf("%s commented on %s", data.ActorName, data.IssueRef)
case "state_changed":
if data.OldValue != "" && data.NewValue != "" {
return fmt.Sprintf("%s moved %s from %s to %s", data.ActorName, data.IssueRef, data.OldValue, data.NewValue)
}
return fmt.Sprintf("%s changed the state of %s", data.ActorName, data.IssueRef)
case "subscribed":
if data.FieldName != "" {
return fmt.Sprintf("%s updated %s on %s", data.ActorName, data.FieldName, data.IssueRef)
}
return fmt.Sprintf("%s updated %s", data.ActorName, data.IssueRef)
default:
return fmt.Sprintf("Update on %s", data.IssueRef)
}
}

func buildNotificationBody(sender string, data NotificationEmailData) string {
greeting := fmt.Sprintf("Hi %s,\n\n", data.ReceiverName)
footer := fmt.Sprintf("\n\nView issue: %s\n\nWorkspace: %s\n\n---\nYou're receiving this because you're watching this issue.", data.IssueURL, data.WorkspaceName)

switch sender {
case "assigned":
return greeting + fmt.Sprintf("%s assigned you to %s: %s", data.ActorName, data.IssueRef, data.IssueTitle) + footer

case "mentioned":
return greeting + fmt.Sprintf("%s mentioned you in %s: %s", data.ActorName, data.IssueRef, data.IssueTitle) + footer

case "commented":
msg := fmt.Sprintf("%s commented on %s: %s", data.ActorName, data.IssueRef, data.IssueTitle)
if data.CommentPreview != "" {
msg += fmt.Sprintf("\n\nComment preview:\n%s", data.CommentPreview)
}
return greeting + msg + footer

case "state_changed":
msg := fmt.Sprintf("%s changed the state of %s: %s", data.ActorName, data.IssueRef, data.IssueTitle)
if data.OldValue != "" && data.NewValue != "" {
msg += fmt.Sprintf("\nFrom: %s\nTo: %s", data.OldValue, data.NewValue)
} else if data.NewValue != "" {
msg += fmt.Sprintf("\nTo: %s", data.NewValue)
}
return greeting + msg + footer

case "subscribed":
var msg string
if data.FieldName != "" {
msg = fmt.Sprintf("%s updated %s on %s: %s", data.ActorName, data.FieldName, data.IssueRef, data.IssueTitle)
} else {
msg = fmt.Sprintf("%s updated %s: %s", data.ActorName, data.IssueRef, data.IssueTitle)
}
if data.OldValue != "" && data.NewValue != "" {
msg += fmt.Sprintf("\nFrom: %s\nTo: %s", data.OldValue, data.NewValue)
} else if data.NewValue != "" {
msg += fmt.Sprintf("\nTo: %s", data.NewValue)
}
return greeting + msg + footer
Comment thread
coderabbitai[bot] marked this conversation as resolved.

default:
return greeting + fmt.Sprintf("%s updated %s: %s", data.ActorName, data.IssueRef, data.IssueTitle) + footer
}
}
189 changes: 189 additions & 0 deletions apps/api/internal/mail/notification_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package mail

import (
"strings"
"testing"
)

func TestBuildNotificationEmail_Assigned(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Alice",
ActorName: "Bob",
IssueRef: "DEV-123",
IssueTitle: "Fix login bug",
IssueURL: "https://app.devlane.io/issue/abc-123",
WorkspaceName: "Engineering",
}

subject, body := BuildNotificationEmail("assigned", data)

expectedSubject := "Bob assigned you to DEV-123"
if subject != expectedSubject {
t.Errorf("subject mismatch: got %q, want %q", subject, expectedSubject)
}

if !strings.Contains(body, "Hi Alice") {
t.Error("body should contain greeting")
}
if !strings.Contains(body, "Bob assigned you to DEV-123: Fix login bug") {
t.Error("body should contain assignment message")
}
if !strings.Contains(body, data.IssueURL) {
t.Error("body should contain issue URL")
}
if !strings.Contains(body, "Engineering") {
t.Error("body should contain workspace name")
}
}

func TestBuildNotificationEmail_Mentioned(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Charlie",
ActorName: "Dana",
IssueRef: "PROD-456",
IssueTitle: "Deploy to production",
IssueURL: "https://app.devlane.io/issue/def-456",
WorkspaceName: "Operations",
}

subject, body := BuildNotificationEmail("mentioned", data)

expectedSubject := "Dana mentioned you in PROD-456"
if subject != expectedSubject {
t.Errorf("subject mismatch: got %q, want %q", subject, expectedSubject)
}

if !strings.Contains(body, "Dana mentioned you in PROD-456") {
t.Error("body should contain mention message")
}
}

func TestBuildNotificationEmail_Commented(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Eve",
ActorName: "Frank",
IssueRef: "BUG-789",
IssueTitle: "Performance issue",
IssueURL: "https://app.devlane.io/issue/ghi-789",
WorkspaceName: "Backend",
CommentPreview: "I think we should optimize the database query here",
}

subject, body := BuildNotificationEmail("commented", data)

expectedSubject := "Frank commented on BUG-789"
if subject != expectedSubject {
t.Errorf("subject mismatch: got %q, want %q", subject, expectedSubject)
}

if !strings.Contains(body, "Frank commented on BUG-789") {
t.Error("body should contain comment message")
}
if !strings.Contains(body, "Comment preview:") {
t.Error("body should contain comment preview label")
}
if !strings.Contains(body, data.CommentPreview) {
t.Error("body should contain comment preview text")
}
}

func TestBuildNotificationEmail_StateChanged(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Grace",
ActorName: "Henry",
IssueRef: "FEAT-111",
IssueTitle: "Add dark mode",
IssueURL: "https://app.devlane.io/issue/jkl-111",
WorkspaceName: "Frontend",
OldValue: "In Progress",
NewValue: "Done",
}

subject, body := BuildNotificationEmail("state_changed", data)

expectedSubject := "Henry moved FEAT-111 from In Progress to Done"
if subject != expectedSubject {
t.Errorf("subject mismatch: got %q, want %q", subject, expectedSubject)
}

if !strings.Contains(body, "From: In Progress") {
t.Error("body should contain old state")
}
if !strings.Contains(body, "To: Done") {
t.Error("body should contain new state")
}
}

func TestBuildNotificationEmail_Subscribed(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Ivan",
ActorName: "Jane",
IssueRef: "TASK-222",
IssueTitle: "Update documentation",
IssueURL: "https://app.devlane.io/issue/mno-222",
WorkspaceName: "Documentation",
FieldName: "priority",
OldValue: "Low",
NewValue: "High",
}

subject, body := BuildNotificationEmail("subscribed", data)

expectedSubject := "Jane updated priority on TASK-222"
if subject != expectedSubject {
t.Errorf("subject mismatch: got %q, want %q", subject, expectedSubject)
}

if !strings.Contains(body, "Jane updated priority on TASK-222") {
t.Error("body should contain field change message")
}
if !strings.Contains(body, "From: Low") {
t.Error("body should contain old value")
}
if !strings.Contains(body, "To: High") {
t.Error("body should contain new value")
}
}

func TestBuildNotificationEmail_CommentedWithoutPreview(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Kevin",
ActorName: "Laura",
IssueRef: "FIX-333",
IssueTitle: "Memory leak",
IssueURL: "https://app.devlane.io/issue/pqr-333",
WorkspaceName: "Infrastructure",
}

_, body := BuildNotificationEmail("commented", data)

if strings.Contains(body, "Comment preview:") {
t.Error("body should not contain comment preview label when no preview provided")
}
}

func TestBuildNotificationEmail_StateChangedWithoutOldValue(t *testing.T) {
data := NotificationEmailData{
ReceiverName: "Mike",
ActorName: "Nancy",
IssueRef: "ISSUE-444",
IssueTitle: "Test coverage",
IssueURL: "https://app.devlane.io/issue/stu-444",
WorkspaceName: "QA",
NewValue: "In Review",
}

subject, body := BuildNotificationEmail("state_changed", data)

expectedSubject := "Nancy changed the state of ISSUE-444"
if subject != expectedSubject {
t.Errorf("subject mismatch: got %q, want %q", subject, expectedSubject)
}

if !strings.Contains(body, "To: In Review") {
t.Error("body should contain new state")
}
if strings.Contains(body, "From:") {
t.Error("body should not contain 'From:' when old value is empty")
}
}
36 changes: 36 additions & 0 deletions apps/api/internal/model/email_notification_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package model

import (
"time"

"github.com/google/uuid"
"gorm.io/gorm"
)

// EmailNotificationLog tracks notification emails queued/sent for audit.
// Maps to the existing email_notification_logs table.
type EmailNotificationLog struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
ReceiverID uuid.UUID `gorm:"type:uuid;not null" json:"receiver_id"`
TriggeredByID *uuid.UUID `gorm:"type:uuid" json:"triggered_by_id,omitempty"`
Subject string `gorm:"type:text" json:"subject"`
EntityIdentifier *uuid.UUID `gorm:"type:uuid" json:"entity_identifier,omitempty"`
EntityName string `gorm:"type:varchar(255)" json:"entity_name,omitempty"`
Data JSONMap `gorm:"type:jsonb;serializer:json" json:"data,omitempty"`
ProcessedAt *time.Time `gorm:"type:timestamptz" json:"processed_at,omitempty"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at,omitempty"` // When queued to RabbitMQ
Entity string `gorm:"type:varchar(200)" json:"entity,omitempty"` // Legacy
OldValue string `gorm:"type:varchar(300)" json:"old_value,omitempty"` // Legacy
NewValue string `gorm:"type:varchar(300)" json:"new_value,omitempty"` // Legacy
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

func (EmailNotificationLog) TableName() string { return "email_notification_logs" }

func (e *EmailNotificationLog) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
7 changes: 7 additions & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ func New(cfg Config) *gin.Engine {
notificationSvc.SetLogger(cfg.Log)
notificationSvc.SetSubscriberStore(issueSubscriberStore)
notificationSvc.SetPreferenceStore(userNotifPrefStore)
// Wire email notification infrastructure if queue is available
if cfg.Queue != nil {
emailLogStore := store.NewEmailNotificationLogStore(cfg.DB)
notificationSvc.SetEmailLogStore(emailLogStore)
notificationSvc.SetQueue(cfg.Queue)
notificationSvc.SetAppBaseURL(appBaseURL)
}
issueSvc.SetNotificationService(notificationSvc)
issueSvc.SetSubscriberStore(issueSubscriberStore)
issueReactionStore := store.NewIssueReactionStore(cfg.DB)
Expand Down
Loading
Loading