Add queued email notifications for collaboration events
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user