diff --git a/apps/api/internal/mail/notification.go b/apps/api/internal/mail/notification.go new file mode 100644 index 00000000..971a19d9 --- /dev/null +++ b/apps/api/internal/mail/notification.go @@ -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 + + default: + return greeting + fmt.Sprintf("%s updated %s: %s", data.ActorName, data.IssueRef, data.IssueTitle) + footer + } +} diff --git a/apps/api/internal/mail/notification_test.go b/apps/api/internal/mail/notification_test.go new file mode 100644 index 00000000..4af534ab --- /dev/null +++ b/apps/api/internal/mail/notification_test.go @@ -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") + } +} diff --git a/apps/api/internal/model/email_notification_log.go b/apps/api/internal/model/email_notification_log.go new file mode 100644 index 00000000..fa50df86 --- /dev/null +++ b/apps/api/internal/model/email_notification_log.go @@ -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 +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 648b2c41..9f32fa87 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -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) diff --git a/apps/api/internal/service/notification.go b/apps/api/internal/service/notification.go index 3462e94d..efe4fdb5 100644 --- a/apps/api/internal/service/notification.go +++ b/apps/api/internal/service/notification.go @@ -8,7 +8,9 @@ import ( "strings" "time" + "github.com/Devlaner/devlane/api/internal/mail" "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/queue" "github.com/Devlaner/devlane/api/internal/store" "github.com/google/uuid" ) @@ -21,15 +23,18 @@ import ( // own DB writes succeed. emit() returns are logged and swallowed: a transient // notifications-table failure must not roll back the user's actual change. type NotificationService struct { - ns *store.NotificationStore - ws *store.WorkspaceStore - is *store.IssueStore // for assignee + creator lookups (receiver computation) - ps *store.ProjectStore // for project-membership filter - us *store.UserStore // for actor display name - ss *store.StateStore // for state name resolution in Message payload - subs *store.IssueSubscriberStore // optional — subscriber-based receivers - prefs *store.UserNotificationPreferenceStore // optional — preference gating - log *slog.Logger + ns *store.NotificationStore + ws *store.WorkspaceStore + is *store.IssueStore // for assignee + creator lookups (receiver computation) + ps *store.ProjectStore // for project-membership filter + us *store.UserStore // for actor display name + ss *store.StateStore // for state name resolution in Message payload + subs *store.IssueSubscriberStore // optional — subscriber-based receivers + prefs *store.UserNotificationPreferenceStore // optional — preference gating + log *slog.Logger + emailLog *store.EmailNotificationLogStore // optional — email notification audit logging + queue *queue.Publisher // optional — RabbitMQ publisher for email notifications + appURL string // optional — base URL for issue links in notification emails } func NewNotificationService( @@ -40,7 +45,14 @@ func NewNotificationService( us *store.UserStore, ss *store.StateStore, ) *NotificationService { - return &NotificationService{ns: ns, ws: ws, is: is, ps: ps, us: us, ss: ss} + return &NotificationService{ + ns: ns, + ws: ws, + is: is, + ps: ps, + us: us, + ss: ss, + } } // SetSubscriberStore wires per-issue subscriber lookups so subscribers are @@ -58,6 +70,21 @@ func (s *NotificationService) SetPreferenceStore(p *store.UserNotificationPrefer // SetLogger lets the caller wire a request-scoped slog. Optional. func (s *NotificationService) SetLogger(l *slog.Logger) { s.log = l } +// SetEmailLogStore wires email notification audit logging. Optional. +func (s *NotificationService) SetEmailLogStore(e *store.EmailNotificationLogStore) { + s.emailLog = e +} + +// SetQueue wires the RabbitMQ publisher for email notifications. Optional. +func (s *NotificationService) SetQueue(q *queue.Publisher) { + s.queue = q +} + +// SetAppBaseURL wires the base URL for issue links in notification emails. Optional. +func (s *NotificationService) SetAppBaseURL(url string) { + s.appURL = url +} + func (s *NotificationService) logger() *slog.Logger { if s.log != nil { return s.log @@ -299,6 +326,104 @@ func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, p if err := s.ns.CreateMany(ctx, rows); err != nil { s.logger().Warn("notification fan-out failed", "err", err, "issue_id", params.issue.ID, "receivers", len(rows)) } + + // Queue notification emails if email infrastructure is available. + // This runs synchronously but logs+swallows errors to avoid breaking in-app notifications. + if s.queue != nil && s.emailLog != nil && s.appURL != "" { + s.enqueueNotificationEmails(ctx, allowed, params, actorName, issueRef) + } +} + +// enqueueNotificationEmails queues an email for each receiver who received an in-app notification. +// Errors are logged and swallowed — email delivery is best-effort and must not break in-app notifications. +func (s *NotificationService) enqueueNotificationEmails(ctx context.Context, receivers []uuid.UUID, params emitParams, actorName, issueRef string) { + if params.issue == nil || len(receivers) == 0 { + return + } + + issueURL := fmt.Sprintf("%s/issue/%s", strings.TrimSuffix(s.appURL, "/"), params.issue.ID) + + // Resolve workspace name for email footer + workspaceName := "Devlane" + if wrk, err := s.ws.GetByID(ctx, params.issue.WorkspaceID); err == nil && wrk != nil { + workspaceName = wrk.Name + } + + for _, receiverID := range receivers { + receiver, err := s.us.GetByID(ctx, receiverID) + if err != nil || receiver == nil || receiver.Email == nil || *receiver.Email == "" { + continue // Skip users without email + } + + // Determine effective sender (mention override) + sender := params.sender + if params.classifyMention != nil && params.classifyMention(receiverID) { + sender = model.NotificationSenderMentioned + } + + // Build receiver display name + receiverName := receiver.DisplayName + if receiverName == "" { + receiverName = strings.TrimSpace(receiver.FirstName + " " + receiver.LastName) + } + if receiverName == "" { + receiverName = receiver.Username + } + if receiverName == "" { + receiverName = "there" + } + + // Build email content + subject, body := mail.BuildNotificationEmail(sender, mail.NotificationEmailData{ + ReceiverName: receiverName, + ActorName: actorName, + IssueRef: issueRef, + IssueTitle: params.issue.Name, + IssueURL: issueURL, + WorkspaceName: workspaceName, + CommentPreview: params.commentPreview, + FieldName: humanFieldName(params.field), + OldValue: params.before, + NewValue: params.after, + }) + + // Create email log entry (for audit) + issueID := params.issue.ID + emailLog := &model.EmailNotificationLog{ + ReceiverID: receiverID, + TriggeredByID: ¶ms.actorID, + Subject: subject, + EntityIdentifier: &issueID, + EntityName: model.NotificationEntityIssue, + Data: model.JSONMap{ + "sender": sender, + "issue_ref": issueRef, + "issue_name": params.issue.Name, + }, + } + if err := s.emailLog.Create(ctx, emailLog); err != nil { + s.logger().Warn("email log create failed, skipping email", "err", err, "receiver", receiverID) + continue + } + + // Queue email via RabbitMQ + if err := s.queue.PublishSendEmail(ctx, queue.SendEmailPayload{ + To: *receiver.Email, + Subject: subject, + Body: body, + Kind: fmt.Sprintf("notification_%s", sender), + }); err != nil { + s.logger().Warn("email queue failed", "err", err, "receiver", receiverID, "log_id", emailLog.ID) + continue + } + + // Mark as sent (queued to RabbitMQ) + now := time.Now() + if err := s.emailLog.MarkSent(ctx, emailLog.ID, now); err != nil { + s.logger().Warn("email log mark sent failed", "err", err, "log_id", emailLog.ID) + // Non-fatal: email is queued, log update failure doesn't matter + } + } } // actorDisplayName returns the user's display name, falling back through diff --git a/apps/api/internal/store/email_notification_log.go b/apps/api/internal/store/email_notification_log.go new file mode 100644 index 00000000..563258cb --- /dev/null +++ b/apps/api/internal/store/email_notification_log.go @@ -0,0 +1,31 @@ +package store + +import ( + "context" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type EmailNotificationLogStore struct { + db *gorm.DB +} + +func NewEmailNotificationLogStore(db *gorm.DB) *EmailNotificationLogStore { + return &EmailNotificationLogStore{db: db} +} + +// Create inserts a single email notification log entry. +func (s *EmailNotificationLogStore) Create(ctx context.Context, log *model.EmailNotificationLog) error { + return s.db.WithContext(ctx).Create(log).Error +} + +// MarkSent updates sent_at to indicate the email was queued to RabbitMQ. +func (s *EmailNotificationLogStore) MarkSent(ctx context.Context, id uuid.UUID, sentAt time.Time) error { + return s.db.WithContext(ctx). + Model(&model.EmailNotificationLog{}). + Where("id = ?", id). + Update("sent_at", sentAt).Error +}