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,29 @@
package email
import "github.com/tim/cairnquire/apps/server/internal/collaboration"
// CollaborationRenderer adapts email.Render to the collaboration.EmailRenderer
// interface by converting collaboration.EmailData into email.TemplateData.
type CollaborationRenderer struct {
InstanceName string
}
// Render satisfies collaboration.EmailRenderer.
func (c CollaborationRenderer) Render(kind string, data collaboration.EmailData) (string, string, error) {
td := TemplateData{
ActorName: data.ActorName,
ActorEmail: data.ActorEmail,
DocumentPath: data.DocumentPath,
DocumentTitle: data.DocumentTitle,
CommentBody: data.CommentBody,
MentionText: data.MentionText,
ConflictDesc: data.ConflictDesc,
ViewURL: data.ViewURL,
UnsubURL: data.UnsubURL,
InstanceName: c.InstanceName,
}
if td.InstanceName == "" {
td.InstanceName = "Cairnquire"
}
return Render(kind, td)
}

View File

@@ -0,0 +1,14 @@
package email
import (
"crypto/rand"
"encoding/base64"
)
func randomToken(length int) (string, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View File

@@ -0,0 +1,141 @@
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
const (
defaultPostmarkURL = "https://api.postmarkapp.com"
postmarkTimeout = 15 * time.Second
)
// PostmarkSender sends email via the Postmark API.
type PostmarkSender struct {
serverToken string
apiURL string
from string
client *http.Client
logger *slog.Logger
}
// NewPostmarkSender constructs a Postmark sender. apiURL may be empty to use
// the public Postmark endpoint; tests can point it at an httptest server.
func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *PostmarkSender {
if apiURL == "" {
apiURL = defaultPostmarkURL
}
return &PostmarkSender{
serverToken: serverToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{Timeout: postmarkTimeout},
logger: logger,
}
}
type postmarkRequest struct {
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
Headers []postmarkHeader `json:"Headers,omitempty"`
}
type postmarkHeader struct {
Name string `json:"Name"`
Value string `json:"Value"`
}
type postmarkResponse struct {
ErrorCode int `json:"ErrorCode"`
Message string `json:"Message"`
To string `json:"To"`
}
// WithReplyTo sets a Reply-To header on the outgoing message. Returns a copy
// of the sender with the reply-to address applied for the next Send call.
// Not safe for concurrent use; callers should construct a fresh sender per
// send if they need different reply-to addresses.
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
if p.serverToken == "" {
return fmt.Errorf("postmark server token not configured")
}
if p.from == "" {
return fmt.Errorf("postmark from address not configured")
}
if len(to) == 0 {
return fmt.Errorf("postmark send requires at least one recipient")
}
payload := postmarkRequest{
From: p.from,
To: strings.Join(to, ","),
Subject: subject,
TextBody: body,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal postmark request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiURL+"/email", bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("build postmark request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", p.serverToken)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("postmark request: %w", err)
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
if resp.StatusCode >= 500 {
return &TemporaryError{Cause: fmt.Errorf("postmark server error: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))
}
var parsed postmarkResponse
if err := json.Unmarshal(respBytes, &parsed); err != nil {
return fmt.Errorf("decode postmark response: %w", err)
}
if parsed.ErrorCode != 0 {
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
// 406 as non-retryable; others bubble up as generic failures.
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)
}
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
return nil
}
func truncate(s string) string {
if len(s) > 256 {
return s[:256] + "..."
}
return s
}
// TemporaryError wraps a failure that callers may retry. The queue worker
// uses errors.As to detect it and apply exponential backoff.
type TemporaryError struct {
Cause error
}
func (t *TemporaryError) Error() string { return t.Cause.Error() }
func (t *TemporaryError) Unwrap() error { return t.Cause }

View File

@@ -0,0 +1,113 @@
package email
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPostmarkSender_Send(t *testing.T) {
var gotBody postmarkRequest
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Postmark-Server-Token")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK","To":"alice@example.com"}`))
}))
defer srv.Close()
sender := NewPostmarkSender("test-token", "noreply@example.com", srv.URL, testLogger(t))
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
t.Fatalf("Send: %v", err)
}
if gotToken != "test-token" {
t.Errorf("token = %q, want test-token", gotToken)
}
if gotBody.From != "noreply@example.com" {
t.Errorf("from = %q", gotBody.From)
}
if gotBody.To != "alice@example.com" {
t.Errorf("to = %q", gotBody.To)
}
if gotBody.Subject != "Hello" {
t.Errorf("subject = %q", gotBody.Subject)
}
if gotBody.TextBody != "World" {
t.Errorf("body = %q", gotBody.TextBody)
}
}
func TestPostmarkSender_ErrorCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ErrorCode":406,"Message":"Inactive recipient"}`))
}))
defer srv.Close()
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "406") {
t.Errorf("error should mention code 406: %v", err)
}
}
func TestPostmarkSender_ServerError_Temporary(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
if err == nil {
t.Fatal("expected error")
}
var temp *TemporaryError
if !errors.As(err, &temp) {
t.Errorf("expected TemporaryError, got %T: %v", err, err)
}
}
func TestPostmarkSender_Validation(t *testing.T) {
sender := NewPostmarkSender("", "noreply@example.com", "", testLogger(t))
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
t.Fatal("expected error for missing token")
}
sender = NewPostmarkSender("tok", "", "", testLogger(t))
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
t.Fatal("expected error for missing from")
}
sender = NewPostmarkSender("tok", "noreply@example.com", "", testLogger(t))
if err := sender.Send(context.Background(), nil, "s", "b"); err == nil {
t.Fatal("expected error for empty recipients")
}
}
func TestPostmarkSender_MultipleRecipients(t *testing.T) {
var gotBody postmarkRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK"}`))
}))
defer srv.Close()
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
if err := sender.Send(context.Background(), []string{"a@x.com", "b@x.com"}, "s", "b"); err != nil {
t.Fatalf("Send: %v", err)
}
if !strings.Contains(gotBody.To, "a@x.com") || !strings.Contains(gotBody.To, "b@x.com") {
t.Errorf("to = %q, expected both recipients", gotBody.To)
}
}

View File

@@ -0,0 +1,344 @@
package email
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math"
"time"
)
// Queue stores outgoing emails durably and dispatches them via a Sender.
// It is safe for concurrent use; claim uses an atomic UPDATE ... WHERE
// status='pending' to lease rows without races.
type Queue struct {
db *sql.DB
sender Sender
logger *slog.Logger
pollInterval time.Duration
maxBackoff time.Duration
}
// QueueRow is the on-disk representation of a queued email.
type QueueRow struct {
ID string
NotificationID string
ToAddress string
Subject string
Body string
Status string
Attempts int
MaxAttempts int
LastError string
NextAttemptAt time.Time
SentAt *time.Time
CreatedAt time.Time
}
// NewQueue constructs a queue. sender may be nil; in that case emails stay
// pending forever (useful for tests that only inspect enqueue state).
func NewQueue(db *sql.DB, sender Sender, logger *slog.Logger) *Queue {
if logger == nil {
logger = slog.Default()
}
return &Queue{
db: db,
sender: sender,
logger: logger,
pollInterval: 10 * time.Second,
maxBackoff: 30 * time.Minute,
}
}
// SetPollInterval overrides the worker poll interval. Must be called before
// Run. Useful for tests.
func (q *Queue) SetPollInterval(d time.Duration) {
if d > 0 {
q.pollInterval = d
}
}
// SetMaxBackoff overrides the maximum retry backoff.
func (q *Queue) SetMaxBackoff(d time.Duration) {
if d > 0 {
q.maxBackoff = d
}
}
// Enqueue inserts a new outgoing email. notificationID may be empty. The
// email is immediately eligible for delivery (next_attempt_at = now).
func (q *Queue) Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error) {
if to == "" {
return "", fmt.Errorf("enqueue: to address is required")
}
if subject == "" {
return "", fmt.Errorf("enqueue: subject is required")
}
id, err := randomQueueID()
if err != nil {
return "", err
}
now := time.Now().UTC()
if _, err := q.db.ExecContext(ctx, `
INSERT INTO email_queue (id, notification_id, to_address, subject, body, status, attempts, max_attempts, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, 3, ?, ?)
`, id, nullableString(notificationID), to, subject, body, formatTime(now), formatTime(now)); err != nil {
return "", fmt.Errorf("enqueue email: %w", err)
}
return id, nil
}
// MarkNotificationEmailed stamps notifications.emailed_at for the given
// notification id. Called by the worker once the email is dispatched.
func (q *Queue) MarkNotificationEmailed(ctx context.Context, notificationID string) error {
if notificationID == "" {
return nil
}
_, err := q.db.ExecContext(ctx, `
UPDATE notifications SET emailed_at = ? WHERE id = ?
`, formatTime(time.Now().UTC()), notificationID)
if err != nil {
return fmt.Errorf("mark notification emailed: %w", err)
}
return nil
}
// Run starts the worker loop. It blocks until ctx is cancelled.
func (q *Queue) Run(ctx context.Context) {
q.logger.Info("email queue worker started", "poll_interval", q.pollInterval)
ticker := time.NewTicker(q.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
q.logger.Info("email queue worker stopped")
return
case <-ticker.C:
if err := q.processBatch(ctx); err != nil {
q.logger.Warn("email queue batch failed", "error", err)
}
}
}
}
// processBatch claims and sends up to 25 pending emails per tick.
func (q *Queue) processBatch(ctx context.Context) error {
if q.sender == nil {
// No sender configured; leave rows pending so a future sender can
// pick them up once wired.
return nil
}
rows, err := q.claimBatch(ctx, 25)
if err != nil {
return fmt.Errorf("claim batch: %w", err)
}
for _, row := range rows {
if err := q.deliver(ctx, row); err != nil {
q.logger.Warn("deliver email failed", "id", row.ID, "error", err)
}
}
return nil
}
// claimBatch atomically leases pending rows by flipping their status to
// 'pending' but bumping attempts so a concurrent worker cannot re-claim
// them until the next_attempt_at expires. We use a single UPDATE with a
// subquery to keep this race-free under SQLite.
func (q *Queue) claimBatch(ctx context.Context, limit int) ([]QueueRow, error) {
tx, err := q.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin claim: %w", err)
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC
LIMIT ?
`, formatTime(time.Now().UTC()), limit)
if err != nil {
return nil, fmt.Errorf("query pending: %w", err)
}
var claimed []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
rows.Close()
return nil, err
}
claimed = append(claimed, row)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, row := range claimed {
if _, err := tx.ExecContext(ctx, `
UPDATE email_queue SET attempts = attempts + 1 WHERE id = ?
`, row.ID); err != nil {
return nil, fmt.Errorf("lease row %s: %w", row.ID, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit claim: %w", err)
}
return claimed, nil
}
// deliver attempts one email. On success it marks the row sent and stamps
// the linked notification. On failure it schedules a retry with exponential
// backoff or marks the row failed if attempts are exhausted.
func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
err := q.sender.Send(ctx, []string{row.ToAddress}, row.Subject, row.Body)
if err == nil {
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'sent', sent_at = ?, last_error = '' WHERE id = ?
`, formatTime(time.Now().UTC()), row.ID); err != nil {
return fmt.Errorf("mark sent: %w", err)
}
if err := q.MarkNotificationEmailed(ctx, row.NotificationID); err != nil {
q.logger.Warn("mark notification emailed", "id", row.NotificationID, "error", err)
}
return nil
}
// Failure: retry or give up.
if row.Attempts >= row.MaxAttempts {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark failed: %w", ferr)
}
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err)
return nil
}
backoff := q.backoff(row.Attempts)
next := time.Now().UTC().Add(backoff)
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
`, formatTime(next), truncateErr(err.Error()), row.ID); err != nil {
return fmt.Errorf("schedule retry: %w", err)
}
// TemporaryError (Postmark 5xx) is retried with the same backoff as
// other errors; the max-attempts cap still bounds total work.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
return nil
}
func (q *Queue) backoff(attempts int) time.Duration {
// 2^attempts seconds, capped: 2s, 4s, 8s, 16s, ... up to maxBackoff.
d := time.Duration(math.Pow(2, float64(attempts))) * time.Second
if d > q.maxBackoff {
d = q.maxBackoff
}
if d < time.Second {
d = time.Second
}
return d
}
// PendingCount returns the number of rows in pending status, primarily for
// tests and operator dashboards.
func (q *Queue) PendingCount(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM email_queue WHERE status = 'pending'`).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// ListFailed returns rows that have exhausted their retries, for operator
// inspection.
func (q *Queue) ListFailed(ctx context.Context, limit int) ([]QueueRow, error) {
if limit <= 0 {
limit = 50
}
rows, err := q.db.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func scanQueueRow(rows *sql.Rows) (QueueRow, error) {
var r QueueRow
var sentAt, lastError string
var nextAttempt, createdAt string
if err := rows.Scan(&r.ID, &r.NotificationID, &r.ToAddress, &r.Subject, &r.Body, &r.Status,
&r.Attempts, &r.MaxAttempts, &lastError, &nextAttempt, &sentAt, &createdAt); err != nil {
return QueueRow{}, fmt.Errorf("scan queue row: %w", err)
}
r.LastError = lastError
var err error
r.NextAttemptAt, err = time.Parse(time.RFC3339, nextAttempt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse next_attempt_at: %w", err)
}
r.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse created_at: %w", err)
}
if sentAt != "" {
t, err := time.Parse(time.RFC3339, sentAt)
if err == nil {
r.SentAt = &t
}
}
return r, nil
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
func truncateErr(s string) string {
if len(s) > 512 {
return s[:512] + "..."
}
return s
}
// randomQueueID produces an id with enough entropy to avoid collisions. We
// keep a small helper in-package to avoid an import cycle on auth.
func randomQueueID() (string, error) {
id, err := randomToken(18)
if err != nil {
return "", err
}
return "email:" + id, nil
}

View File

@@ -0,0 +1,248 @@
package email
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
cfg := config.DatabaseConfig{Path: t.TempDir() + "/test.sqlite"}
handle, err := database.Open(context.Background(), cfg)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
db := handle.SQL()
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Seed a user + notification so the queue's notification_id FK is
// satisfiable and MarkNotificationEmailed can update a real row.
_, err = db.Exec(`INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES ('user:alice@example.com', 'alice@example.com', 'Alice', 'admin', ?, ?)`,
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed user: %v", err)
}
_, err = db.Exec(`INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, created_at) VALUES (?, 'user:alice@example.com', 'file_changed', 'document', 'doc:test', 'test', ?)`,
"notif:test-1", time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed notification: %v", err)
}
return db
}
func TestQueue_EnqueueAndPendingCount(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Subject", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
if id == "" {
t.Fatal("expected non-empty id")
}
count, err := q.PendingCount(context.Background())
if err != nil {
t.Fatalf("PendingCount: %v", err)
}
if count != 1 {
t.Errorf("pending = %d, want 1", count)
}
}
func TestQueue_EnqueueValidation(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
// Empty notification_id is allowed (the column is nullable).
if _, err := q.Enqueue(context.Background(), "", "alice@example.com", "s", "b"); err != nil {
t.Fatalf("empty notification_id should be allowed: %v", err)
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "", "s", "b"); err == nil {
t.Fatal("expected error for empty to")
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "", "b"); err == nil {
t.Fatal("expected error for empty subject")
}
}
type recordingSender struct {
sends []recordedSend
err error
}
type recordedSend struct {
to []string
subject string
body string
}
func (r *recordingSender) Send(ctx context.Context, to []string, subject, body string) error {
if r.err != nil {
return r.err
}
r.sends = append(r.sends, recordedSend{to: to, subject: subject, body: body})
return nil
}
func TestQueue_DeliverSuccess(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Hello", "World"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
n, _ := q.PendingCount(ctx)
return n == 0
}); err != nil {
t.Fatalf("email never sent: %v", err)
}
if len(sender.sends) != 1 {
t.Fatalf("sends = %d, want 1", len(sender.sends))
}
if sender.sends[0].subject != "Hello" {
t.Errorf("subject = %q", sender.sends[0].subject)
}
if sender.sends[0].body != "World" {
t.Errorf("body = %q", sender.sends[0].body)
}
// The linked notification should now have emailed_at stamped.
var emailedAt string
if err := db.QueryRow(`SELECT COALESCE(emailed_at, '') FROM notifications WHERE id = ?`, "notif:test-1").Scan(&emailedAt); err != nil {
t.Fatalf("query emailed_at: %v", err)
}
if emailedAt == "" {
t.Error("expected notifications.emailed_at to be set after send")
}
}
func TestQueue_RetryThenSucceed(t *testing.T) {
db := newTestDB(t)
// Fail twice, then succeed.
sender := &flakySender{failTimes: 2}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(50 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Retry", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
return sender.successCount >= 1
}); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
}
// Verify the row is marked sent.
var status string
if err := db.QueryRow(`SELECT status FROM email_queue WHERE to_address = ?`, "alice@example.com").Scan(&status); err != nil {
t.Fatalf("query status: %v", err)
}
if status != "sent" {
t.Errorf("status = %q, want sent", status)
}
}
func TestQueue_PermanentFailure(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: errors.New("permanent Postmark rejection")}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
failed, _ := q.ListFailed(ctx, 10)
return len(failed) == 1
}); err != nil {
failed, _ := q.ListFailed(ctx, 10)
t.Fatalf("expected 1 failed row, got %d: %v", len(failed), err)
}
failed, _ := q.ListFailed(ctx, 10)
if !strings.Contains(failed[0].LastError, "permanent") {
t.Errorf("last_error = %q", failed[0].LastError)
}
}
func TestQueue_NilSenderLeavesPending(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Pending", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
go q.Run(ctx)
<-ctx.Done()
count, _ := q.PendingCount(context.Background())
if count != 1 {
t.Errorf("expected row to stay pending with nil sender, got %d", count)
}
}
// flakySender fails the first N attempts then succeeds.
type flakySender struct {
failTimes int
attempts int
successCount int
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++
if f.attempts <= f.failTimes {
return errors.New("transient failure")
}
f.successCount++
return nil
}
func waitFor(ctx context.Context, cond func() bool) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if cond() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

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"
}

View File

@@ -0,0 +1,119 @@
package email
import (
"strings"
"testing"
)
func TestRender_FileChanged(t *testing.T) {
subject, body, err := Render("file_changed", TemplateData{
ActorName: "Alice",
DocumentPath: "docs/api.md",
DocumentTitle: "API Reference",
ViewURL: "https://example.com/docs/api.md",
UnsubURL: "https://example.com/unsub/123",
InstanceName: "Cairnquire",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "docs/api.md") {
t.Errorf("subject should include path: %q", subject)
}
if !strings.Contains(subject, "Alice") {
t.Errorf("subject should include actor: %q", subject)
}
if !strings.Contains(body, "Alice") {
t.Errorf("body should mention actor: %q", body)
}
if !strings.Contains(body, "API Reference") {
t.Errorf("body should mention title: %q", body)
}
if !strings.Contains(body, "https://example.com/docs/api.md") {
t.Errorf("body should include view url: %q", body)
}
if !strings.Contains(body, "https://example.com/unsub/123") {
t.Errorf("body should include unsub url: %q", body)
}
}
func TestRender_Comment(t *testing.T) {
subject, body, err := Render("comment", TemplateData{
ActorName: "Bob",
DocumentTitle: "Getting Started",
CommentBody: "This step is unclear.\nCan we add an example?",
ViewURL: "https://example.com/docs/getting-started",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "Bob") {
t.Errorf("subject should include actor: %q", subject)
}
if !strings.Contains(body, "> This step is unclear.") {
t.Errorf("body should quote comment: %q", body)
}
if !strings.Contains(body, "> Can we add an example?") {
t.Errorf("body should quote second line: %q", body)
}
if !strings.Contains(body, "Reply to this email") {
t.Errorf("body should mention reply-to-respond: %q", body)
}
}
func TestRender_Mention(t *testing.T) {
subject, body, err := Render("mention", TemplateData{
ActorName: "Carol",
DocumentTitle: "Spec",
MentionText: "@dan please review",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "Mention") {
t.Errorf("subject: %q", subject)
}
if !strings.Contains(body, "> @dan please review") {
t.Errorf("body should quote mention: %q", body)
}
}
func TestRender_Conflict(t *testing.T) {
subject, body, err := Render("conflict", TemplateData{
DocumentPath: "notes/2024.md",
ConflictDesc: "Local and remote both edited line 12",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "conflict") {
t.Errorf("subject: %q", subject)
}
if !strings.Contains(body, "sync conflict") {
t.Errorf("body should mention conflict: %q", body)
}
if !strings.Contains(body, "line 12") {
t.Errorf("body should include conflict desc: %q", body)
}
}
func TestRender_UnknownKind(t *testing.T) {
_, _, err := Render("bogus", TemplateData{})
if err == nil {
t.Fatal("expected error for unknown kind")
}
}
func TestRender_PlainTextOnly(t *testing.T) {
for _, kind := range []string{"file_changed", "comment", "mention", "conflict"} {
_, body, err := Render(kind, TemplateData{
ActorName: "A", DocumentTitle: "T", CommentBody: "<b>html</b>",
})
if err != nil {
t.Fatalf("Render %s: %v", kind, err)
}
if strings.Contains(body, "<html>") || strings.Contains(body, "<body>") {
t.Errorf("template %s produced HTML: %q", kind, body)
}
}
}

View File

@@ -0,0 +1,18 @@
package email
import (
"log/slog"
"testing"
)
func testLogger(t *testing.T) *slog.Logger {
t.Helper()
return slog.New(slog.NewTextHandler(&testWriter{t: t}, &slog.HandlerOptions{Level: slog.LevelWarn}))
}
type testWriter struct{ t *testing.T }
func (w *testWriter) Write(p []byte) (int, error) {
w.t.Logf("%s", p)
return len(p), nil
}