Add queued email notifications for collaboration events
This commit is contained in:
29
apps/server/internal/email/collaboration_adapter.go
Normal file
29
apps/server/internal/email/collaboration_adapter.go
Normal 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)
|
||||
}
|
||||
14
apps/server/internal/email/internal.go
Normal file
14
apps/server/internal/email/internal.go
Normal 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
|
||||
}
|
||||
141
apps/server/internal/email/postmark.go
Normal file
141
apps/server/internal/email/postmark.go
Normal 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 }
|
||||
113
apps/server/internal/email/postmark_test.go
Normal file
113
apps/server/internal/email/postmark_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
344
apps/server/internal/email/queue.go
Normal file
344
apps/server/internal/email/queue.go
Normal 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
|
||||
}
|
||||
248
apps/server/internal/email/queue_test.go
Normal file
248
apps/server/internal/email/queue_test.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
149
apps/server/internal/email/templates.go
Normal file
149
apps/server/internal/email/templates.go
Normal 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"
|
||||
}
|
||||
119
apps/server/internal/email/templates_test.go
Normal file
119
apps/server/internal/email/templates_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/server/internal/email/test_helpers_test.go
Normal file
18
apps/server/internal/email/test_helpers_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user