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

@@ -0,0 +1,149 @@
package email
import (
"fmt"
"strings"
"time"
)
// TemplateData carries the context required to render any notification email.
// Fields are optional per template; callers should populate what they have.
type TemplateData struct {
ActorName string
ActorEmail string
DocumentPath string
DocumentTitle string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
}
// Render returns a plain-text subject and body for the given notification
// type. Subjects are short and prefixed with the document path so email
// clients thread per-document.
func Render(kind string, data TemplateData) (subject, body string, err error) {
if data.InstanceName == "" {
data.InstanceName = "Cairnquire"
}
switch kind {
case "file_changed":
return renderFileChanged(data)
case "comment":
return renderComment(data)
case "mention":
return renderMention(data)
case "conflict":
return renderConflict(data)
default:
return "", "", fmt.Errorf("unknown email template kind %q", kind)
}
}
func subjectPrefix(data TemplateData) string {
if data.DocumentPath != "" {
return "[" + data.DocumentPath + "]"
}
return "[" + data.InstanceName + "]"
}
func footer(data TemplateData) string {
var b strings.Builder
b.WriteString("\n--\n")
if data.ViewURL != "" {
fmt.Fprintf(&b, "View online: %s\n", data.ViewURL)
}
if data.UnsubURL != "" {
fmt.Fprintf(&b, "Unsubscribe: %s\n", data.UnsubURL)
}
fmt.Fprintf(&b, "You receive this because you watch this document on %s.\n", data.InstanceName)
return b.String()
}
func renderFileChanged(data TemplateData) (string, string, error) {
title := data.DocumentTitle
if title == "" {
title = data.DocumentPath
}
actor := data.ActorName
if actor == "" {
actor = "Someone"
}
subject := fmt.Sprintf("%s Updated by %s", subjectPrefix(data), actor)
var b strings.Builder
fmt.Fprintf(&b, "%s updated %q at %s UTC.\n", actor, title, time.Now().UTC().Format("2006-01-02 15:04"))
if data.DocumentPath != "" {
fmt.Fprintf(&b, "Path: %s\n", data.DocumentPath)
}
b.WriteString("\nView the changes online to see what is new.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}
func renderComment(data TemplateData) (string, string, error) {
actor := data.ActorName
if actor == "" {
actor = "Someone"
}
subject := fmt.Sprintf("%s Comment from %s", subjectPrefix(data), actor)
var b strings.Builder
fmt.Fprintf(&b, "%s commented on %q:\n", actor, docLabel(data))
b.WriteString("\n")
if data.CommentBody != "" {
// Quote the comment body, line by line, as plain text.
for _, line := range strings.Split(data.CommentBody, "\n") {
fmt.Fprintf(&b, "> %s\n", line)
}
} else {
b.WriteString("> (no body)\n")
}
b.WriteString("\nReply to this email to respond.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}
func renderMention(data TemplateData) (string, string, error) {
actor := data.ActorName
if actor == "" {
actor = "Someone"
}
subject := fmt.Sprintf("%s Mention from %s", subjectPrefix(data), actor)
var b strings.Builder
fmt.Fprintf(&b, "%s mentioned you on %q.\n", actor, docLabel(data))
if data.MentionText != "" {
b.WriteString("\n")
for _, line := range strings.Split(data.MentionText, "\n") {
fmt.Fprintf(&b, "> %s\n", line)
}
}
b.WriteString(footer(data))
return subject, b.String(), nil
}
func renderConflict(data TemplateData) (string, string, error) {
subject := fmt.Sprintf("%s Sync conflict", subjectPrefix(data))
var b strings.Builder
fmt.Fprintf(&b, "A sync conflict was detected on %q.\n", docLabel(data))
if data.ConflictDesc != "" {
fmt.Fprintf(&b, "\nDetails: %s\n", data.ConflictDesc)
}
b.WriteString("\nOpen the document to review and resolve the conflict.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}
func docLabel(data TemplateData) string {
if data.DocumentTitle != "" {
return data.DocumentTitle
}
if data.DocumentPath != "" {
return data.DocumentPath
}
return "a document"
}