Add queued email notifications for collaboration events

This commit is contained in:
2026-07-22 08:51:42 -04:00
parent b6d2ded141
commit 09f51d1ef4
20 changed files with 1507 additions and 102 deletions

View File

@@ -10,10 +10,46 @@ import (
"github.com/tim/cairnquire/apps/server/internal/realtime"
)
// EmailSender mirrors email.Sender without taking a direct dependency on the
// email package (which would create a cycle once the queue imports from
// collaboration's repository types).
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
// EmailQueue is the minimal contract the collaboration service needs to
// enqueue an outgoing email. The email.Queue type satisfies this.
type EmailQueue interface {
Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error)
}
// EmailRenderer renders a plain-text email for a notification kind. The
// email.Render function satisfies this.
type EmailRenderer interface {
Render(kind string, data EmailData) (subject, body string, err error)
}
// EmailData is the email-package TemplateData projected through the
// collaboration boundary to avoid an import cycle.
type EmailData struct {
ActorName string
ActorEmail string
DocumentPath string
DocumentTitle string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
}
// UserLookup resolves a user id to an email address and display name. The
// auth.Repository satisfies this.
type UserLookup interface {
GetUserByID(ctx context.Context, userID string) (auth.User, error)
}
type NoOpEmailSender struct{}
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
@@ -22,21 +58,45 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
}
type Service struct {
repo *Repository
email EmailSender
hub *realtime.Hub
logger *slog.Logger
repo *Repository
email EmailSender
queue EmailQueue
renderer EmailRenderer
users UserLookup
hub *realtime.Hub
logger *slog.Logger
publicOrigin string
}
type Options struct {
EmailSender EmailSender
EmailQueue EmailQueue
EmailRenderer EmailRenderer
UserLookup UserLookup
PublicOrigin string
}
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
if email == nil {
email = &NoOpEmailSender{}
return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
}
// NewServiceWithOptions constructs the service with email queue + renderer +
// user lookup wiring. Callers that want email delivery should pass all of
// EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay
// in-app only.
func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service {
if opts.EmailSender == nil {
opts.EmailSender = &NoOpEmailSender{}
}
return &Service{
repo: repo,
email: email,
hub: hub,
logger: logger,
repo: repo,
email: opts.EmailSender,
queue: opts.EmailQueue,
renderer: opts.EmailRenderer,
users: opts.UserLookup,
hub: hub,
logger: logger,
publicOrigin: opts.PublicOrigin,
}
}
@@ -224,6 +284,11 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
s.logger.Warn("create notification", "error", err)
continue
}
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
ActorName: actorName,
DocumentPath: documentPath,
ViewURL: s.documentURL(documentPath),
})
}
// Broadcast bell update to all connected clients
@@ -257,11 +322,52 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
}
if err := s.repo.CreateNotification(ctx, notification); err != nil {
s.logger.Warn("create comment notification", "error", err)
continue
}
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
ActorName: comment.AuthorName,
CommentBody: comment.Content,
ViewURL: s.documentURL(comment.DocumentID),
})
}
return nil
}
// enqueueNotificationEmail resolves the watcher's email address, renders the
// template, and enqueues a durable email. Failures are logged and swallowed
// so a bad email config never blocks the in-app notification path.
func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, userID, kind string, data EmailData) {
if s.queue == nil || s.renderer == nil || s.users == nil {
return
}
user, err := s.users.GetUserByID(ctx, userID)
if err != nil {
s.logger.Warn("email: lookup user", "user_id", userID, "error", err)
return
}
if user.Email == "" || user.Disabled {
return
}
if data.ActorName == "" {
data.ActorName = user.DisplayName
}
subject, body, err := s.renderer.Render(kind, data)
if err != nil {
s.logger.Warn("email: render", "kind", kind, "error", err)
return
}
if _, err := s.queue.Enqueue(ctx, notificationID, user.Email, subject, body); err != nil {
s.logger.Warn("email: enqueue", "user_id", userID, "error", err)
}
}
func (s *Service) documentURL(documentPath string) string {
if s.publicOrigin == "" {
return ""
}
return s.publicOrigin + "/" + documentPath
}
func (s *Service) broadcastNotification(userID string) {
if s.hub == nil {
return