diff --git a/.env.example b/.env.example index 72024e1..be971e5 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,24 @@ # Required for deployed passkeys and device authorization URLs. CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com -# Optional SMTP settings. The bundled Compose file defaults to its local -# Mailpit service so notifications can be inspected during initial deployment. +# Outbound provider: smtp, postmark, cloudflare, or noop. Compose defaults to +# smtp with Mailpit so messages can be inspected during initial deployment. +CAIRNQUIRE_EMAIL_PROVIDER=smtp CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit CAIRNQUIRE_EMAIL_SMTP_PORT=1025 CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local +# Postmark production delivery: +# CAIRNQUIRE_EMAIL_PROVIDER=postmark +# CAIRNQUIRE_EMAIL_POSTMARK_TOKEN=server-token +# CAIRNQUIRE_EMAIL_FROM=notifications@example.com + +# Cloudflare Email Service production delivery. Enable Email Sending for the +# sender domain first and use an API token with Email Sending permission. +# CAIRNQUIRE_EMAIL_PROVIDER=cloudflare +# CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID=cloudflare-account-id +# CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN=cloudflare-api-token +# CAIRNQUIRE_EMAIL_FROM=notifications@example.com + # Optional: INFO, DEBUG, WARN, or ERROR. CAIRNQUIRE_LOG_LEVEL=INFO diff --git a/.project/STATUS.md b/.project/STATUS.md index 1b88c22..9bcdbba 100644 --- a/.project/STATUS.md +++ b/.project/STATUS.md @@ -1,64 +1,74 @@ # Project Status Report -**Generated:** 2026-06-24 +**Updated:** 2026-07-22 **Branch:** main -**Last Commit:** b6d2ded - passkey existing (2026-06-16) +**Last Commit:** 09f51d1 - Add queued email notifications for collaboration events ## Quick Stats | Metric | Value | |--------|-------| -| Total Tracked Files | 189 | -| Go Files | 46 | -| Swift Files | 14 (macOS app) | -| Test Files | 10 | -| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) | +| Total Tracked Files | 201 | +| Go Files | 63 | +| Swift Files | 15 (macOS app and package manifest) | +| Go Test Files | 18 | +| Aggregate Go Test Coverage | 52.4% | | Milestones Complete | 3/6 (50%) | -## Recent Activity (since 2026-06-01 report) +## Verification -16 commits on `main` between `fb0673a` (2026-06-01) and `b6d2ded` (2026-06-16): -71 files changed, +7908 / -812 lines. +- `go test -coverpkg=./... -coverprofile=... ./...` passes with 52.4% aggregate statement coverage. +- `swift build` passes for the macOS app and CLI. +- Focused race tests pass for email, collaboration, and HTTP packages. A full `go test -race ./...` exceeds the current five-minute local timeout. +- `govulncheck` currently fails because Go 1.24.2 has 31 reachable standard-library vulnerabilities; the pinned Go toolchain must be upgraded before production sign-off. +- Highest package coverage: Markdown 87.0%, email 77.9%, store 70.0%, config 67.9%, auth 54.9%, and sync 54.2%. +- Collaboration, configuration, email-provider, and collaboration HTTP integration tests are present; database, logging, and realtime still lack direct suites. -Highlights: -- `b6d2ded` passkey existing-user login flow -- `4655008` text editor link autocomplete -- `0adb980` CodeMirror editor integration -- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI) -- `6c30fd8` sync service update -- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`) -- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon -- `ddc7d5c` accounts and email wiring -- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish -- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js` +## Recent Activity + +Recent work has moved the project beyond the previous 2026-06-24 report: + +- Durable email queue with retry/backoff and failed-delivery inspection. +- Postmark outbound sender with API-level tests. +- Cloudflare Email Service REST sender with retry/permanent-error classification and API-level tests. +- File-change and comment notifications now enqueue email delivery. +- Notification bell/list/read UI is active. +- Document watch/unwatch controls are active, watcher subscriptions are deduplicated, and ancestor-folder matching is fixed. +- Nested comment replies, whole-thread resolution, and resolved-history UI are active. +- Notification dispatch rechecks the watcher's current document permission. +- Existing users can add another passkey from their account page. +- Resource permission UI and private-by-default filtering cover documents, folders, navigation, search, API document lists, and native sync snapshots. +- CodeMirror editor, wiki-link autocomplete, keyboard navigation, mobile drawers, archive UI, and tag pages are present. +- The macOS menu-bar app and CLI compile successfully. ## Milestone Progress - [x] Milestone 1: Foundation - [x] Milestone 2: Sync Protocol - [x] Milestone 3: Authentication -- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending) -- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending) -- [ ] Milestone 6: Production (not started) +- [ ] Milestone 4: Collaboration (threaded comments, notification bell, watch controls, and queued Postmark/Cloudflare/SMTP delivery shipped; preferences, digest, inbound replies, mention/conflict triggers, and live-provider tests remain) +- [ ] Milestone 5: Search & UI (server FTS5, tags, keyboard navigation, and responsive foundations shipped; offline client search, `/design`, performance, and accessibility validation remain) +- [ ] Milestone 6: Production (health/readiness endpoints, CI, deployment scaffolding, and some integration tests exist; hardening acceptance criteria remain open) -**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry) +## Current Focus -## Untracked Subsystems (not in original plan) +Milestone 4 collaboration completion: -These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`: +- Add notification preferences and digest delivery. +- Add inbound email replies through a verified webhook flow. +- Add mention and conflict notification triggers. +- Test watcher-to-email delivery against live Postmark and Cloudflare accounts. -- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants. -- **Tagging** — `tags.js`, `tag.gohtml`. Document tags. -- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete. -- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope. -- **API tokens** — `token_create.gohtml`, `auth.APIKey` types. -- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods. +## Known Gaps Requiring Human Review -## Notes for Human Review - -- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient. -- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused. -- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark). +- Go 1.24.2 is below the patched versions reported by `govulncheck`, including fixes for `html/template`, `crypto/tls`, `net/http`, and `crypto/x509` vulnerabilities. +- WebSocket broadcasts are authenticated but not filtered by per-document permissions. +- Content-hash downloads are not tied back to effective document permission. +- Browser caches are not cleared on logout. +- Raw HTML is enabled in authored Markdown while the security specification says it should be disabled. +- Folder/collection permission administration is only fully discoverable for documents. +- The implementation uses JSON for SimpleSync while protocol documentation still leaves JSON versus protobuf unresolved. +- Backup documentation does not match the named data volume in Compose. --- -*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.* +*This report is maintained from the implementation and verification results, not milestone checkbox state alone.* diff --git a/.project/adrs/adr-006-email-strategy.md b/.project/adrs/adr-006-email-strategy.md index d4cec81..95408b3 100644 --- a/.project/adrs/adr-006-email-strategy.md +++ b/.project/adrs/adr-006-email-strategy.md @@ -7,7 +7,14 @@ Accepted The platform needs to send email notifications for: file changes (to watchers), new comments, conflict alerts, and digest summaries. It must also receive email replies and convert them to comments. Requirement: use Postmark email gateway with plain-text only. ## Decision -**Postmark HTTP API for outgoing email, Postmark inbound webhook for incoming email.** +**Postmark or Cloudflare Email Service HTTP API for outgoing email, with a +provider-specific verified inbound webhook for incoming email.** + +Amended 2026-07-22: Cloudflare Email Service is supported as an alternative +transactional outbound provider. The same durable queue and plain-text +templates are shared by Postmark, Cloudflare, and local SMTP. Cloudflare-hosted +Workers would normally use an Email binding, but Cairnquire is a standalone Go +application and therefore uses the authenticated REST API. ### Implementation diff --git a/.project/architecture-overview.md b/.project/architecture-overview.md index ab7ac10..d235c7b 100644 --- a/.project/architecture-overview.md +++ b/.project/architecture-overview.md @@ -97,12 +97,14 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT - Client syncs index to IndexedDB for offline search - Index rebuilds incrementally on file changes -### 7. Email (Postmark) -- Go adapter using Postmark HTTP API +### 7. Email (Postmark or Cloudflare Email Service) +- Go adapters use the Postmark or Cloudflare Email Sending REST API +- Local development uses SMTP with Mailpit +- A durable SQLite queue handles asynchronous delivery and bounded retries - Plain-text emails only (no HTML) -- Threading via Message-ID / In-Reply-To headers -- Webhook endpoint receives email replies, converts to comments -- Templates: notification, digest, conflict alert +- Planned: threading via Message-ID / In-Reply-To headers +- Planned: verified inbound webhook converts email replies to comments +- Implemented templates: file change, comment, mention, and conflict; digest remains planned ### 8. File Store (Content-Addressed) - Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...` diff --git a/.project/milestones/milestone-04-collaboration.md b/.project/milestones/milestone-04-collaboration.md index 1fea4e8..6a6030a 100644 --- a/.project/milestones/milestone-04-collaboration.md +++ b/.project/milestones/milestone-04-collaboration.md @@ -9,9 +9,9 @@ - [x] Comment system (backend + UI wired) - [x] Comment creation endpoint (web) - - [x] Comment threading (parent/child) - schema supports it + - [x] Comment threading (parent/child) with nested browser replies - [x] Line/section anchoring (hash-based) - - [x] Comment resolution (mark as resolved) + - [x] Whole-thread resolution with resolved-history view - [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines) - [x] Notification system (backend complete) @@ -21,10 +21,10 @@ - [ ] Global notification settings - [ ] Digest mode (hourly/daily batches) -- [x] Web notification UI (backend complete, JS partial) - - [x] Notification bell with unread count - API exists - - [x] Notification list dropdown - API exists - - [x] Mark as read functionality - API exists +- [x] Web notification UI + - [x] Notification bell with unread count + - [x] Notification list dropdown + - [x] Mark one/all as read functionality - [ ] Notification preferences page ### Week 8: Email Integration & Conflict Resolution @@ -34,6 +34,7 @@ - [x] Postmark adapter (`email/postmark.go`) - [x] Plain-text email templates (`email/templates.go`) - [x] Outgoing email queue with retry (`email/queue.go` + migration 000016) + - [x] Cloudflare Email Service REST adapter (`email/cloudflare.go`) - [ ] Webhook endpoint for inbound email - [x] Email notification types @@ -60,20 +61,20 @@ ### Functional - [x] Users can add comments to specific lines in a document (API exists) - [x] Comments are linked to document version (hash) -- [x] Comment threading works (reply to reply) (schema supports it) -- [x] Users can watch files or folders for changes (API exists) -- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry) +- [x] Comment threading works (reply to reply) in the browser and API +- [x] Users can watch files or folders for changes (document UI + APIs) +- [x] Email notifications queued within 1 minute of event (Postmark, Cloudflare, or SMTP sender) - [ ] Replying to notification email creates a comment - [ ] Digest emails batch notifications by time window -- [x] Conflicts display side-by-side diff (M2 sync) +- [x] Conflicts display a unified diff (M2 sync) - [x] Users can resolve conflicts via web UI (M2 sync) -- [ ] Resolved comments are hidden but accessible in history +- [x] Resolved comments are hidden but accessible in history ### Non-Functional - [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test - [ ] Email threading works in Gmail, Outlook, Apple Mail - [ ] Webhook signature verified for inbound email -- [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test +- [x] Temporary email failures retried 3 times with exponential backoff; permanent provider rejections fail immediately - [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine ### Email Template Examples @@ -166,7 +167,7 @@ CREATE TABLE watchers ( - [ ] All acceptance criteria pass - [ ] Email flows tested with real Postmark account -- [ ] Comment threading tested with 3+ levels +- [x] Comment threading tested with 3+ levels - [ ] Conflict resolution tested with 2+ concurrent editors - [ ] Notification preferences persist across sessions - [ ] Digest mode tested with 24-hour window diff --git a/.project/project-plan.md b/.project/project-plan.md index be86f9f..9e97186 100644 --- a/.project/project-plan.md +++ b/.project/project-plan.md @@ -150,19 +150,19 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown **Goal:** Comments, notifications, and conflict resolution - [x] Comment system linked to file versions (by hash) - backend complete -- [ ] Email notifications via Postmark adapter - SMTP stub exists +- [x] Queued email notifications via Postmark, Cloudflare Email Service, or local SMTP - [x] Web-based conflict resolution UI (Milestone 2) - [ ] Notification preferences (per-file, per-folder, global, digest) -- [x] Comment threading schema - UI needs wiring +- [x] Comment threading schema and browser reply UI - [ ] Email replies create comments **Acceptance Criteria:** - [x] Users can comment on specific lines/sections of a document (API) - [x] Comments persist and are linked to document versions -- [ ] Email notifications sent for watched file changes (NoOp sender) +- [x] Email notifications queued for watched file changes with retry - [ ] Replying to notification email creates a comment -- [x] Conflicts display side-by-side diff with resolution actions (M2) +- [x] Conflicts display a unified diff with resolution actions (M2) - [ ] Digest mode batches notifications by time window --- @@ -265,7 +265,7 @@ See [architecture-overview.md](architecture-overview.md) for detailed component | Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant | | WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library | | File Watch | `github.com/fsnotify/fsnotify` | Cross-platform, stdlib-adjacent | -| Email | `github.com/keighl/postmark` + `net/smtp` | Postmark adapter + SMTP fallback | +| Email | `net/http` + `net/smtp` | Postmark/Cloudflare REST adapters + local SMTP | | Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps | | Templates | `html/template` | Stdlib, auto-escaping, SSR-safe | @@ -371,7 +371,7 @@ cairnquire/ | WebAuthn/passkey adoption issues | Medium | Medium | Password fallback always available; extensive browser testing | | SQLite performance at scale | Low | High | libsql replication path; FTS5 for search; benchmark early | | Markdown extension parser bugs | Medium | Medium | Fuzz testing; strict CommonMark base; gradual feature rollout | -| Email deliverability | Medium | Medium | Postmark reputation monitoring; SPF/DKIM setup; SMTP fallback | +| Email deliverability | Medium | Medium | Provider reputation monitoring; SPF/DKIM/DMARC setup; provider fallback | | Frontend bundle bloat | Medium | Medium | Bundle analysis in CI; tree-shaking enforced; lazy loading | | File sync conflicts in teams | High | Medium | Clear UX for conflict resolution; email notification; automatic merge when possible | @@ -383,11 +383,9 @@ cairnquire/ 2. ✅ Complete Milestone 2: Sync Protocol 3. ✅ Complete Milestone 3: Authentication 4. 🔄 Finish Milestone 4: Collaboration - - Wire up comment UI in browser (JS exists, needs integration) - - Wire up notification bell UI - - Implement actual email sending (Postmark or SMTP) - - Add notification preferences page - - Test watcher-driven notifications end-to-end + - Complete inbound email replies, mention notifications, and conflict notifications + - Add global/per-resource notification preferences and digest scheduling + - Test watcher-driven notifications against live Postmark and Cloudflare accounts 5. 🔄 Finish Milestone 5: Search & UI Polish - Tag filtering in search - Design system /design route @@ -395,11 +393,11 @@ cairnquire/ - Mobile-responsive layout polish - Accessibility improvements 6. ⏳ Milestone 6: Production Hardening - - Increase test coverage from 39% to 80%+ + - Increase test coverage from 52.4% to 80%+ - Add integration tests for auth, sync, collaboration - Security audit checklist - Backup/restore scripts - - Health check and monitoring endpoints + - Complete dependency health checks and add monitoring endpoints --- diff --git a/.project/specs/deployment-guide.md b/.project/specs/deployment-guide.md index 789a0ca..abfa07e 100644 --- a/.project/specs/deployment-guide.md +++ b/.project/specs/deployment-guide.md @@ -53,8 +53,12 @@ deployment, only `CAIRNQUIRE_PUBLIC_ORIGIN` must be supplied in `.env`. | Variable | Required | Compose default | Description | |----------|----------|-----------------|-------------| | `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. | +| `CAIRNQUIRE_EMAIL_PROVIDER` | No | `smtp` | Outbound provider: `smtp`, `postmark`, `cloudflare`, or `noop`. | | `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. | | `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. | +| `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` | For Postmark | Empty | Postmark server token. | +| `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` | For Cloudflare | Empty | Account with Cloudflare Email Sending enabled. | +| `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` | For Cloudflare | Empty | API token with Email Sending permission. Store it as a deployment secret. | | `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. | | `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. | @@ -72,6 +76,23 @@ checked-in Compose deployment: | `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. | | `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. | +### Cloudflare Email Service + +Cairnquire is an external Go application, so it uses Cloudflare Email +Service's REST API rather than a Workers binding. Before enabling the provider: + +1. Enable Email Sending for the sender domain in Cloudflare, or run `npx wrangler email sending enable example.com`. +2. Create a Cloudflare API token with Email Sending permission for the account. +3. Set `CAIRNQUIRE_EMAIL_PROVIDER=cloudflare`, the account ID, API token, and a `CAIRNQUIRE_EMAIL_FROM` address on the onboarded domain. + +Cloudflare Email Service is used only for transactional notifications. API +tokens must be supplied through deployment secrets and must not be committed. +The durable queue retries Cloudflare `429` and server errors; validation and +permanent-bounce responses are retained immediately as failed deliveries. + +Cloudflare Email Routing for inbound comment replies is a separate Milestone 4 +feature and is not enabled by these outbound settings. + ## Persistent Data Compose mounts: diff --git a/README.md b/README.md index 732348b..f433ccc 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,12 @@ Supported variables: - `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary. - `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory. - `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store. -- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host. Set to an empty string in JSON config to disable delivery. +- `CAIRNQUIRE_EMAIL_PROVIDER` - `smtp`, `postmark`, `cloudflare`, or `noop`. Compose defaults to `smtp` with Mailpit. +- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host for local delivery. - `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`. +- `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` - Postmark server token when the provider is `postmark`. +- `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` - Cloudflare account with Email Sending enabled. +- `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` - Cloudflare token with Email Sending permission. - `CAIRNQUIRE_EMAIL_FROM` - Notification sender address. - `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`. - `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server. diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go index 5d0adb8..02f99c6 100644 --- a/apps/server/internal/app/app.go +++ b/apps/server/internal/app/app.go @@ -73,6 +73,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err var emailQueue *email.Queue emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName} switch cfg.Email.ResolvedProvider() { + case "cloudflare": + cf := email.NewCloudflareSender( + cfg.Email.CloudflareAccountID, + cfg.Email.CloudflareAPIToken, + cfg.Email.From, + cfg.Email.CloudflareAPIURL, + logger, + ) + emailSender = cf + authService.SetEmailSender(cf) case "postmark": pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger) emailSender = pm @@ -95,7 +105,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err EmailQueue: emailQueue, EmailRenderer: emailRenderer, UserLookup: authRepo, - PublicOrigin: cfg.Auth.PublicOrigin, + CanReadDocument: func(ctx context.Context, userID, documentID string) (bool, error) { + document, err := repo.GetDocumentByID(ctx, documentID) + if err != nil { + return false, err + } + principal, err := authService.PrincipalForUser(ctx, userID) + if err != nil { + return false, err + } + permission, err := authService.EffectiveResourcePermission(ctx, principal, auth.ResourceDocument, document.Path, document.Tags) + if err != nil { + return false, err + } + return auth.PermissionAllows(permission, auth.PermissionRead), nil + }, + PublicOrigin: cfg.Auth.PublicOrigin, }) service.OnChange(func(change docs.DocumentChange) { diff --git a/apps/server/internal/collaboration/repository.go b/apps/server/internal/collaboration/repository.go index b54c316..18b75de 100644 --- a/apps/server/internal/collaboration/repository.go +++ b/apps/server/internal/collaboration/repository.go @@ -63,14 +63,17 @@ func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error return &c, nil } -func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) { - rows, err := r.db.QueryContext(ctx, ` +func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) { + query := ` SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by FROM comments c JOIN users u ON u.id = c.author_id - WHERE c.document_id = ? - ORDER BY c.created_at ASC - `, documentID) + WHERE c.document_id = ?` + if !includeResolved { + query += ` AND c.resolved_at IS NULL` + } + query += ` ORDER BY c.created_at ASC` + rows, err := r.db.QueryContext(ctx, query, documentID) if err != nil { return nil, fmt.Errorf("list comments: %w", err) } @@ -79,14 +82,17 @@ func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID stri return scanComments(rows) } -func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { - rows, err := r.db.QueryContext(ctx, ` +func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) { + query := ` SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by FROM comments c JOIN users u ON u.id = c.author_id - WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL - ORDER BY c.created_at ASC - `, documentID, anchorHash) + WHERE c.document_id = ? AND c.anchor_hash = ?` + if !includeResolved { + query += ` AND c.resolved_at IS NULL` + } + query += ` ORDER BY c.created_at ASC` + rows, err := r.db.QueryContext(ctx, query, documentID, anchorHash) if err != nil { return nil, fmt.Errorf("list comments by anchor: %w", err) } @@ -95,13 +101,25 @@ func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string return scanComments(rows) } -func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error { - _, err := r.db.ExecContext(ctx, ` +func (r *Repository) ResolveCommentThread(ctx context.Context, commentID string, userID string) error { + result, err := r.db.ExecContext(ctx, ` + WITH RECURSIVE thread(id) AS ( + SELECT id FROM comments WHERE id = ? AND parent_id IS NULL + UNION ALL + SELECT c.id FROM comments c JOIN thread t ON c.parent_id = t.id + ) UPDATE comments SET resolved_at = ?, resolved_by = ? - WHERE id = ? - `, formatTime(time.Now().UTC()), userID, commentID) + WHERE id IN (SELECT id FROM thread) AND resolved_at IS NULL + `, commentID, formatTime(time.Now().UTC()), userID) if err != nil { - return fmt.Errorf("resolve comment: %w", err) + return fmt.Errorf("resolve comment thread: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("resolve comment thread result: %w", err) + } + if changed == 0 { + return fmt.Errorf("comment thread not found or already resolved") } return nil } @@ -228,10 +246,12 @@ func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string // Watchers func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error { + if (w.DocumentID == nil) == (w.FolderPath == nil) { + return fmt.Errorf("watcher requires exactly one target") + } _, err := r.db.ExecContext(ctx, ` - INSERT INTO watchers (user_id, document_id, folder_path, created_at) + INSERT OR IGNORE INTO watchers (user_id, document_id, folder_path, created_at) VALUES (?, ?, ?, ?) - ON CONFLICT(user_id, document_id, folder_path) DO NOTHING `, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt)) if err != nil { return fmt.Errorf("add watcher: %w", err) @@ -262,12 +282,12 @@ func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID stri return scanWatchers(rows) } -func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string) ([]Watcher, error) { +func (r *Repository) ListWatchersByFolder(ctx context.Context, documentPath string) ([]Watcher, error) { rows, err := r.db.QueryContext(ctx, ` SELECT user_id, document_id, folder_path, created_at FROM watchers - WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1) - `, folderPath, folderPath, folderPath) + WHERE folder_path = '' OR folder_path || '/' = substr(?, 1, length(folder_path) + 1) + `, documentPath) if err != nil { return nil, fmt.Errorf("list folder watchers: %w", err) } @@ -275,6 +295,24 @@ func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string return scanWatchers(rows) } +func (r *Repository) ListWatchersForDocument(ctx context.Context, documentID string) ([]Watcher, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT DISTINCT w.user_id, w.document_id, w.folder_path, w.created_at + FROM watchers w + JOIN documents d ON d.id = ? + WHERE w.document_id = d.id + OR (w.folder_path IS NOT NULL AND ( + w.folder_path = '' + OR w.folder_path || '/' = substr(d.path, 1, length(w.folder_path) + 1) + )) + `, documentID) + if err != nil { + return nil, fmt.Errorf("list watchers for document: %w", err) + } + defer rows.Close() + return scanWatchers(rows) +} + func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) { var count int err := r.db.QueryRowContext(ctx, ` diff --git a/apps/server/internal/collaboration/repository_test.go b/apps/server/internal/collaboration/repository_test.go new file mode 100644 index 0000000..619d4ef --- /dev/null +++ b/apps/server/internal/collaboration/repository_test.go @@ -0,0 +1,157 @@ +package collaboration + +import ( + "context" + "database/sql" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/tim/cairnquire/apps/server/internal/auth" + "github.com/tim/cairnquire/apps/server/internal/config" + "github.com/tim/cairnquire/apps/server/internal/database" +) + +func newCollaborationTestRepository(t *testing.T) (*Repository, *sql.DB) { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "test.sqlite")}) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.ApplyMigrations(ctx, db.SQL()); err != nil { + t.Fatalf("apply migrations: %v", err) + } + now := formatTime(time.Now().UTC()) + for _, user := range []struct{ id, email, name string }{ + {"user:author", "author@example.com", "Author"}, + {"user:watcher", "watcher@example.com", "Watcher"}, + } { + if _, err := db.SQL().ExecContext(ctx, ` + INSERT INTO users (id, email, display_name, role, created_at) VALUES (?, ?, ?, 'editor', ?) + `, user.id, user.email, user.name, now); err != nil { + t.Fatalf("insert user: %v", err) + } + } + for _, document := range []struct{ id, path, title string }{ + {"doc:guide/setup", "guide/setup.md", "Setup"}, + {"doc:other", "other.md", "Other"}, + } { + if _, err := db.SQL().ExecContext(ctx, ` + INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at) + VALUES (?, ?, 'hash', ?, '[]', ?, ?) + `, document.id, document.path, document.title, now, now); err != nil { + t.Fatalf("insert document: %v", err) + } + } + return NewRepository(db.SQL()), db.SQL() +} + +func TestWatcherTargetsAreIdempotentAndFolderMatchingUsesAncestors(t *testing.T) { + repo, db := newCollaborationTestRepository(t) + ctx := context.Background() + now := time.Now().UTC() + documentID := "doc:guide/setup" + folder := "guide" + for range 2 { + if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", DocumentID: &documentID, CreatedAt: now}); err != nil { + t.Fatalf("add document watcher: %v", err) + } + if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", FolderPath: &folder, CreatedAt: now}); err != nil { + t.Fatalf("add folder watcher: %v", err) + } + } + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM watchers`).Scan(&count); err != nil { + t.Fatalf("count watchers: %v", err) + } + if count != 2 { + t.Fatalf("watcher count = %d, want 2", count) + } + + tests := []struct { + path string + want int + }{ + {"guide/setup.md", 1}, + {"guide/deep/setup.md", 1}, + {"guidebook/setup.md", 0}, + {"other.md", 0}, + } + for _, test := range tests { + watchers, err := repo.ListWatchersByFolder(ctx, test.path) + if err != nil { + t.Fatalf("ListWatchersByFolder(%q): %v", test.path, err) + } + if len(watchers) != test.want { + t.Errorf("ListWatchersByFolder(%q) = %d, want %d", test.path, len(watchers), test.want) + } + } +} + +func TestCommentReplyValidationHistoryAndThreadResolution(t *testing.T) { + repo, _ := newCollaborationTestRepository(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + service := NewServiceWithOptions(repo, nil, logger, Options{}) + ctx := context.Background() + principal := auth.Principal{UserID: "user:author", DisplayName: "Author"} + anchor := "anchor" + + root, err := service.CreateComment(ctx, principal, CreateCommentInput{ + DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Root", AnchorHash: &anchor, + }) + if err != nil { + t.Fatalf("create root: %v", err) + } + reply, err := service.CreateComment(ctx, principal, CreateCommentInput{ + DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Reply", ParentID: &root.ID, + }) + if err != nil { + t.Fatalf("create reply: %v", err) + } + if reply.AnchorHash == nil || *reply.AnchorHash != anchor { + t.Fatalf("reply anchor = %#v, want inherited %q", reply.AnchorHash, anchor) + } + grandchild, err := service.CreateComment(ctx, principal, CreateCommentInput{ + DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Grandchild", ParentID: &reply.ID, + }) + if err != nil { + t.Fatalf("create grandchild: %v", err) + } + if _, err := service.CreateComment(ctx, principal, CreateCommentInput{ + DocumentID: "doc:other", VersionHash: "hash", Content: "Wrong document", ParentID: &root.ID, + }); err == nil { + t.Fatal("cross-document reply should fail") + } + + if err := service.ResolveComment(ctx, principal, root.ID); err != nil { + t.Fatalf("resolve thread: %v", err) + } + active, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, false) + if err != nil { + t.Fatalf("list active: %v", err) + } + if len(active) != 0 { + t.Fatalf("active comments = %d, want 0", len(active)) + } + history, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, true) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history) != 3 { + t.Fatalf("history comments = %d, want 3", len(history)) + } + for _, comment := range history { + if comment.ResolvedAt == nil { + t.Errorf("comment %s was not resolved", comment.ID) + } + } + if _, err := service.CreateComment(ctx, principal, CreateCommentInput{ + DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Too late", ParentID: &grandchild.ID, + }); err == nil { + t.Fatal("reply to resolved thread should fail") + } +} diff --git a/apps/server/internal/collaboration/service.go b/apps/server/internal/collaboration/service.go index 6541b0e..bb104f0 100644 --- a/apps/server/internal/collaboration/service.go +++ b/apps/server/internal/collaboration/service.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "net/url" + "strings" "time" "github.com/tim/cairnquire/apps/server/internal/auth" @@ -50,6 +52,8 @@ type UserLookup interface { GetUserByID(ctx context.Context, userID string) (auth.User, error) } +type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error) + type NoOpEmailSender struct{} func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error { @@ -58,22 +62,24 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s } type Service struct { - repo *Repository - email EmailSender - queue EmailQueue - renderer EmailRenderer - users UserLookup - hub *realtime.Hub - logger *slog.Logger - publicOrigin string + repo *Repository + email EmailSender + queue EmailQueue + renderer EmailRenderer + users UserLookup + canReadDocument DocumentReadAuthorizer + hub *realtime.Hub + logger *slog.Logger + publicOrigin string } type Options struct { - EmailSender EmailSender - EmailQueue EmailQueue - EmailRenderer EmailRenderer - UserLookup UserLookup - PublicOrigin string + EmailSender EmailSender + EmailQueue EmailQueue + EmailRenderer EmailRenderer + UserLookup UserLookup + CanReadDocument DocumentReadAuthorizer + PublicOrigin string } func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service { @@ -89,14 +95,15 @@ func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Log opts.EmailSender = &NoOpEmailSender{} } return &Service{ - repo: repo, - email: opts.EmailSender, - queue: opts.EmailQueue, - renderer: opts.EmailRenderer, - users: opts.UserLookup, - hub: hub, - logger: logger, - publicOrigin: opts.PublicOrigin, + repo: repo, + email: opts.EmailSender, + queue: opts.EmailQueue, + renderer: opts.EmailRenderer, + users: opts.UserLookup, + canReadDocument: opts.CanReadDocument, + hub: hub, + logger: logger, + publicOrigin: opts.PublicOrigin, } } @@ -106,12 +113,35 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i if principal.UserID == "" { return nil, fmt.Errorf("authentication required") } + input.Content = strings.TrimSpace(input.Content) if input.Content == "" { return nil, fmt.Errorf("content is required") } if input.DocumentID == "" { return nil, fmt.Errorf("document_id is required") } + if input.VersionHash == "" { + return nil, fmt.Errorf("version_hash is required") + } + + if input.ParentID != nil && *input.ParentID != "" { + parent, err := s.repo.GetComment(ctx, *input.ParentID) + if err != nil { + return nil, fmt.Errorf("parent comment not found") + } + if parent.DocumentID != input.DocumentID { + return nil, fmt.Errorf("parent comment belongs to another document") + } + if parent.ResolvedAt != nil { + return nil, fmt.Errorf("cannot reply to a resolved thread") + } + if input.AnchorHash == nil { + input.AnchorHash = parent.AnchorHash + } + if input.AnchorLine == nil { + input.AnchorLine = parent.AnchorLine + } + } comment := Comment{ ID: randomID("comment"), @@ -135,23 +165,27 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i return nil, err } - // Create notifications for watchers - if err := s.notifyWatchersOfComment(ctx, comment); err != nil { + created, err := s.repo.GetComment(ctx, comment.ID) + if err != nil { + return nil, err + } + + if err := s.notifyWatchersOfComment(ctx, *created); err != nil { s.logger.Warn("failed to notify watchers of comment", "error", err) } // Broadcast to connected clients s.broadcastNotification(principal.UserID) - return s.repo.GetComment(ctx, comment.ID) + return created, nil } -func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) { - return s.repo.ListCommentsByDocument(ctx, documentID) +func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) { + return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved) } -func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { - return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash) +func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) { + return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved) } func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) { @@ -162,7 +196,14 @@ func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, if principal.UserID == "" { return fmt.Errorf("authentication required") } - return s.repo.ResolveComment(ctx, commentID, principal.UserID) + comment, err := s.repo.GetComment(ctx, commentID) + if err != nil { + return fmt.Errorf("comment not found") + } + if comment.ParentID != nil { + return fmt.Errorf("only a thread root can be resolved") + } + return s.repo.ResolveCommentThread(ctx, commentID, principal.UserID) } // Notifications @@ -212,6 +253,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d if principal.UserID == "" { return fmt.Errorf("authentication required") } + if strings.TrimSpace(documentID) == "" { + return fmt.Errorf("document_id is required") + } return s.repo.AddWatcher(ctx, Watcher{ UserID: principal.UserID, DocumentID: &documentID, @@ -223,6 +267,9 @@ func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, if principal.UserID == "" { return fmt.Errorf("authentication required") } + if strings.TrimSpace(documentID) == "" { + return fmt.Errorf("document_id is required") + } return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil) } @@ -230,6 +277,10 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol if principal.UserID == "" { return fmt.Errorf("authentication required") } + folderPath, err := NormalizeFolderPath(folderPath) + if err != nil { + return err + } return s.repo.AddWatcher(ctx, Watcher{ UserID: principal.UserID, FolderPath: &folderPath, @@ -241,6 +292,10 @@ func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, f if principal.UserID == "" { return fmt.Errorf("authentication required") } + folderPath, err := NormalizeFolderPath(folderPath) + if err != nil { + return err + } return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath) } @@ -269,6 +324,9 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, continue } seen[w.UserID] = true + if !s.watcherCanRead(ctx, w.UserID, documentID) { + continue + } msg := fmt.Sprintf("%s updated %s", actorName, documentPath) notification := Notification{ @@ -298,15 +356,23 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, } func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error { - watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID) + watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID) if err != nil { return err } + seen := make(map[string]bool) for _, w := range watchers { if w.UserID == comment.AuthorID { continue } + if seen[w.UserID] { + continue + } + seen[w.UserID] = true + if !s.watcherCanRead(ctx, w.UserID, comment.DocumentID) { + continue + } msg := fmt.Sprintf("New comment on document") if comment.AnchorHash != nil { msg = fmt.Sprintf("New comment on a section of the document") @@ -325,9 +391,10 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) continue } s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{ - ActorName: comment.AuthorName, - CommentBody: comment.Content, - ViewURL: s.documentURL(comment.DocumentID), + ActorName: comment.AuthorName, + DocumentPath: documentPathFromID(comment.DocumentID) + ".md", + CommentBody: comment.Content, + ViewURL: s.documentURL(comment.DocumentID), }) } return nil @@ -349,7 +416,7 @@ func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, return } if data.ActorName == "" { - data.ActorName = user.DisplayName + data.ActorName = "Someone" } subject, body, err := s.renderer.Render(kind, data) if err != nil { @@ -365,7 +432,25 @@ func (s *Service) documentURL(documentPath string) string { if s.publicOrigin == "" { return "" } - return s.publicOrigin + "/" + documentPath + clean := strings.TrimPrefix(documentPath, "doc:") + clean = strings.TrimSuffix(strings.TrimPrefix(clean, "/"), ".md") + parts := strings.Split(clean, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.TrimRight(s.publicOrigin, "/") + "/docs/" + strings.Join(parts, "/") +} + +func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool { + if s.canReadDocument == nil { + return true + } + allowed, err := s.canReadDocument(ctx, userID, documentID) + if err != nil { + s.logger.Warn("authorize watcher", "user_id", userID, "document_id", documentID, "error", err) + return false + } + return allowed } func (s *Service) broadcastNotification(userID string) { diff --git a/apps/server/internal/collaboration/util.go b/apps/server/internal/collaboration/util.go index 9105b5d..19299c4 100644 --- a/apps/server/internal/collaboration/util.go +++ b/apps/server/internal/collaboration/util.go @@ -4,10 +4,27 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "fmt" + "path" "strings" "time" ) +func NormalizeFolderPath(raw string) (string, error) { + raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")) + if raw == "" || raw == "/" { + return "", nil + } + clean := path.Clean(strings.Trim(raw, "/")) + if clean == "." { + return "", nil + } + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("folder path must remain inside the content root") + } + return clean, nil +} + func randomID(prefix string) string { buf := make([]byte, 12) if _, err := rand.Read(buf); err != nil { diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index 9edbd98..a717a5a 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -39,15 +39,18 @@ type AuthConfig struct { type EmailConfig struct { // Provider selects the sender implementation. Valid values: "noop", - // "smtp", "postmark". When empty, the loader infers from the other - // fields: PostmarkToken wins, then SMTPHost, then NoOp. - Provider string `json:"provider"` - SMTPHost string `json:"smtpHost"` - SMTPPort int `json:"smtpPort"` - PostmarkToken string `json:"postmarkToken"` - PostmarkAPIURL string `json:"postmarkApiUrl"` - From string `json:"from"` - InstanceName string `json:"instanceName"` + // "smtp", "postmark", or "cloudflare". When empty, the loader infers + // from credentials, then SMTPHost, then NoOp. + Provider string `json:"provider"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + PostmarkToken string `json:"postmarkToken"` + PostmarkAPIURL string `json:"postmarkApiUrl"` + CloudflareAccountID string `json:"cloudflareAccountId"` + CloudflareAPIToken string `json:"cloudflareApiToken"` + CloudflareAPIURL string `json:"cloudflareApiUrl"` + From string `json:"from"` + InstanceName string `json:"instanceName"` } // ResolvedProvider returns the concrete provider choice after applying the @@ -56,6 +59,9 @@ func (e EmailConfig) ResolvedProvider() string { if e.Provider != "" { return e.Provider } + if e.CloudflareAPIToken != "" || e.CloudflareAccountID != "" { + return "cloudflare" + } if e.PostmarkToken != "" { return "postmark" } @@ -112,6 +118,9 @@ func Load() (Config, error) { overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST") overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN") overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL") + overrideString(&cfg.Email.CloudflareAccountID, "CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID") + overrideString(&cfg.Email.CloudflareAPIToken, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN") + overrideString(&cfg.Email.CloudflareAPIURL, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL") overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER") overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME") if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" { diff --git a/apps/server/internal/config/config_test.go b/apps/server/internal/config/config_test.go new file mode 100644 index 0000000..24a149f --- /dev/null +++ b/apps/server/internal/config/config_test.go @@ -0,0 +1,46 @@ +package config + +import "testing" + +func TestEmailConfigResolvedProvider(t *testing.T) { + tests := []struct { + name string + cfg EmailConfig + want string + }{ + {"explicit", EmailConfig{Provider: "noop", CloudflareAPIToken: "token"}, "noop"}, + {"cloudflare", EmailConfig{CloudflareAccountID: "account"}, "cloudflare"}, + {"postmark", EmailConfig{PostmarkToken: "token"}, "postmark"}, + {"smtp", EmailConfig{SMTPHost: "localhost"}, "smtp"}, + {"noop", EmailConfig{}, "noop"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.cfg.ResolvedProvider(); got != test.want { + t.Fatalf("ResolvedProvider() = %q, want %q", got, test.want) + } + }) + } +} + +func TestLoadCloudflareEmailEnvironment(t *testing.T) { + t.Setenv("CAIRNQUIRE_EMAIL_PROVIDER", "cloudflare") + t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID", "account") + t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN", "token") + t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL", "https://api.example.test") + t.Setenv("CAIRNQUIRE_EMAIL_FROM", "notifications@example.com") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Email.ResolvedProvider() != "cloudflare" { + t.Fatalf("provider = %q", cfg.Email.ResolvedProvider()) + } + if cfg.Email.CloudflareAccountID != "account" || cfg.Email.CloudflareAPIToken != "token" { + t.Fatalf("cloudflare config = %#v", cfg.Email) + } + if cfg.Email.CloudflareAPIURL != "https://api.example.test" || cfg.Email.From != "notifications@example.com" { + t.Fatalf("cloudflare endpoint/from = %#v", cfg.Email) + } +} diff --git a/apps/server/internal/database/migrations/000017_watcher_constraints.down.sql b/apps/server/internal/database/migrations/000017_watcher_constraints.down.sql new file mode 100644 index 0000000..7b6a8f0 --- /dev/null +++ b/apps/server/internal/database/migrations/000017_watcher_constraints.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_watchers_user_folder_unique; +DROP INDEX IF EXISTS idx_watchers_user_document_unique; diff --git a/apps/server/internal/database/migrations/000017_watcher_constraints.up.sql b/apps/server/internal/database/migrations/000017_watcher_constraints.up.sql new file mode 100644 index 0000000..81bc575 --- /dev/null +++ b/apps/server/internal/database/migrations/000017_watcher_constraints.up.sql @@ -0,0 +1,14 @@ +DELETE FROM watchers +WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM watchers + GROUP BY user_id, document_id, folder_path +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_document_unique +ON watchers(user_id, document_id) +WHERE document_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_folder_unique +ON watchers(user_id, folder_path) +WHERE folder_path IS NOT NULL; diff --git a/apps/server/internal/email/cloudflare.go b/apps/server/internal/email/cloudflare.go new file mode 100644 index 0000000..9c009e9 --- /dev/null +++ b/apps/server/internal/email/cloudflare.go @@ -0,0 +1,160 @@ +package email + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + defaultCloudflareAPIURL = "https://api.cloudflare.com/client/v4" + cloudflareTimeout = 15 * time.Second +) + +// CloudflareSender sends transactional email through Cloudflare Email +// Service's REST API. It is intended for deployments outside Workers. +type CloudflareSender struct { + accountID string + apiToken string + apiURL string + from string + client *http.Client + logger *slog.Logger +} + +func NewCloudflareSender(accountID, apiToken, from, apiURL string, logger *slog.Logger) *CloudflareSender { + if apiURL == "" { + apiURL = defaultCloudflareAPIURL + } + if logger == nil { + logger = slog.Default() + } + return &CloudflareSender{ + accountID: accountID, + apiToken: apiToken, + apiURL: strings.TrimRight(apiURL, "/"), + from: from, + client: &http.Client{ + Timeout: cloudflareTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + logger: logger, + } +} + +type cloudflareRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` +} + +type cloudflareResponse struct { + Success bool `json:"success"` + Errors []struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"errors"` + Result *struct { + Delivered []string `json:"delivered"` + MessageID string `json:"message_id"` + PermanentBounces []string `json:"permanent_bounces"` + Queued []string `json:"queued"` + } `json:"result"` +} + +func (c *CloudflareSender) Send(ctx context.Context, to []string, subject, body string) error { + if c.accountID == "" { + return &PermanentError{Cause: fmt.Errorf("cloudflare account id not configured")} + } + if c.apiToken == "" { + return &PermanentError{Cause: fmt.Errorf("cloudflare API token not configured")} + } + if c.from == "" { + return &PermanentError{Cause: fmt.Errorf("cloudflare from address not configured")} + } + if len(to) == 0 { + return &PermanentError{Cause: fmt.Errorf("cloudflare send requires at least one recipient")} + } + + payload, err := json.Marshal(cloudflareRequest{ + From: c.from, + To: to, + Subject: subject, + Text: body, + }) + if err != nil { + return fmt.Errorf("marshal cloudflare email request: %w", err) + } + + endpoint := fmt.Sprintf("%s/accounts/%s/email/sending/send", c.apiURL, url.PathEscape(c.accountID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("build cloudflare email request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return &TemporaryError{Cause: fmt.Errorf("cloudflare email request: %w", err)} + } + defer resp.Body.Close() + + responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) + if readErr != nil { + return &TemporaryError{Cause: fmt.Errorf("read cloudflare email response: %w", readErr)} + } + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError { + return &TemporaryError{Cause: fmt.Errorf("cloudflare email server error: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))} + } + if resp.StatusCode != http.StatusOK { + return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))} + } + + var parsed cloudflareResponse + if err := json.Unmarshal(responseBody, &parsed); err != nil { + return fmt.Errorf("decode cloudflare email response: %w", err) + } + if !parsed.Success { + return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: %s", cloudflareErrors(parsed.Errors))} + } + if parsed.Result == nil { + return fmt.Errorf("cloudflare email response missing result") + } + if len(parsed.Result.PermanentBounces) > 0 { + return &PermanentError{Cause: fmt.Errorf("cloudflare email permanently bounced for %d recipient(s)", len(parsed.Result.PermanentBounces))} + } + + c.logger.Info("cloudflare email accepted", + "message_id", parsed.Result.MessageID, + "delivered", len(parsed.Result.Delivered), + "queued", len(parsed.Result.Queued), + "subject", subject, + ) + return nil +} + +func cloudflareErrors(errors []struct { + Code int `json:"code"` + Message string `json:"message"` +}) string { + if len(errors) == 0 { + return "unknown API error" + } + parts := make([]string, 0, len(errors)) + for _, apiErr := range errors { + parts = append(parts, fmt.Sprintf("code=%d: %s", apiErr.Code, apiErr.Message)) + } + return strings.Join(parts, "; ") +} diff --git a/apps/server/internal/email/cloudflare_test.go b/apps/server/internal/email/cloudflare_test.go new file mode 100644 index 0000000..23b72d6 --- /dev/null +++ b/apps/server/internal/email/cloudflare_test.go @@ -0,0 +1,128 @@ +package email + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCloudflareSenderSend(t *testing.T) { + var got cloudflareRequest + var gotAuthorization string + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("Authorization") + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &got) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"errors":[],"messages":[],"result":{"delivered":["alice@example.com"],"message_id":"message-1","permanent_bounces":[],"queued":[]}}`)) + })) + defer server.Close() + + sender := NewCloudflareSender("account-1", "token-1", "notifications@example.com", server.URL, testLogger(t)) + if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil { + t.Fatalf("Send: %v", err) + } + if gotAuthorization != "Bearer token-1" { + t.Errorf("authorization = %q", gotAuthorization) + } + if gotPath != "/accounts/account-1/email/sending/send" { + t.Errorf("path = %q", gotPath) + } + if got.From != "notifications@example.com" || got.Subject != "Hello" || got.Text != "World" { + t.Errorf("request = %#v", got) + } + if len(got.To) != 1 || got.To[0] != "alice@example.com" { + t.Errorf("to = %#v", got.To) + } +} + +func TestCloudflareSenderRetryableResponses(t *testing.T) { + for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} { + t.Run(http.StatusText(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"success":false}`)) + })) + defer server.Close() + + sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t)) + err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body") + var temporary *TemporaryError + if !errors.As(err, &temporary) { + t.Fatalf("error = %T %v, want TemporaryError", err, err) + } + }) + } +} + +func TestCloudflareSenderPermanentRejection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}]}`)) + })) + defer server.Close() + + sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t)) + err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body") + if err == nil || !strings.Contains(err.Error(), "400") { + t.Fatalf("error = %v, want permanent 400 rejection", err) + } + var temporary *TemporaryError + if errors.As(err, &temporary) { + t.Fatalf("error = %T, should not be retryable", err) + } +} + +func TestCloudflareSenderAPIErrorAndBounce(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {"api error", `{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}],"result":null}`, "Sender domain not verified"}, + {"bounce", `{"success":true,"errors":[],"result":{"delivered":[],"message_id":"message-1","permanent_bounces":["to@example.com"],"queued":[]}}`, "permanently bounced"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(test.body)) + })) + defer server.Close() + sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t)) + err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body") + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestCloudflareSenderValidation(t *testing.T) { + tests := []struct { + name string + accountID string + token string + from string + to []string + }{ + {"account", "", "token", "from@example.com", []string{"to@example.com"}}, + {"token", "account", "", "from@example.com", []string{"to@example.com"}}, + {"from", "account", "token", "", []string{"to@example.com"}}, + {"recipient", "account", "token", "from@example.com", nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sender := NewCloudflareSender(test.accountID, test.token, test.from, "", testLogger(t)) + if err := sender.Send(context.Background(), test.to, "subject", "body"); err == nil { + t.Fatal("expected validation error") + } + }) + } +} diff --git a/apps/server/internal/email/postmark.go b/apps/server/internal/email/postmark.go index 6bbff11..cebaba0 100644 --- a/apps/server/internal/email/postmark.go +++ b/apps/server/internal/email/postmark.go @@ -36,16 +36,21 @@ func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *P serverToken: serverToken, apiURL: strings.TrimRight(apiURL, "/"), from: from, - client: &http.Client{Timeout: postmarkTimeout}, - logger: logger, + client: &http.Client{ + Timeout: postmarkTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + logger: logger, } } type postmarkRequest struct { - From string `json:"From"` - To string `json:"To"` - Subject string `json:"Subject"` - TextBody string `json:"TextBody"` + From string `json:"From"` + To string `json:"To"` + Subject string `json:"Subject"` + TextBody string `json:"TextBody"` Headers []postmarkHeader `json:"Headers,omitempty"` } @@ -66,13 +71,13 @@ type postmarkResponse struct { // 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") + return &PermanentError{Cause: fmt.Errorf("postmark server token not configured")} } if p.from == "" { - return fmt.Errorf("postmark from address not configured") + return &PermanentError{Cause: fmt.Errorf("postmark from address not configured")} } if len(to) == 0 { - return fmt.Errorf("postmark send requires at least one recipient") + return &PermanentError{Cause: fmt.Errorf("postmark send requires at least one recipient")} } payload := postmarkRequest{ @@ -96,7 +101,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st resp, err := p.client.Do(req) if err != nil { - return fmt.Errorf("postmark request: %w", err) + return &TemporaryError{Cause: fmt.Errorf("postmark request: %w", err)} } defer resp.Body.Close() @@ -107,7 +112,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes))) + return &PermanentError{Cause: fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))} } var parsed postmarkResponse @@ -117,7 +122,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st 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) + return &PermanentError{Cause: fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)} } p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject) @@ -139,3 +144,11 @@ type TemporaryError struct { func (t *TemporaryError) Error() string { return t.Cause.Error() } func (t *TemporaryError) Unwrap() error { return t.Cause } + +// PermanentError marks a provider rejection that cannot succeed on retry. +type PermanentError struct { + Cause error +} + +func (p *PermanentError) Error() string { return p.Cause.Error() } +func (p *PermanentError) Unwrap() error { return p.Cause } diff --git a/apps/server/internal/email/queue.go b/apps/server/internal/email/queue.go index ac5e8ee..6766640 100644 --- a/apps/server/internal/email/queue.go +++ b/apps/server/internal/email/queue.go @@ -3,6 +3,7 @@ package email import ( "context" "database/sql" + "errors" "fmt" "log/slog" "math" @@ -211,17 +212,28 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error { } // Failure: retry or give up. - if row.Attempts >= row.MaxAttempts { + var permanent *PermanentError + if errors.As(err, &permanent) { + 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 permanent failure: %w", ferr) + } + q.logger.Warn("email permanently rejected", "id", row.ID, "attempts", row.Attempts+1, "error", err) + return nil + } + currentAttempt := row.Attempts + 1 + if currentAttempt >= 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) + q.logger.Warn("email permanently failed", "id", row.ID, "attempts", currentAttempt, "error", err) return nil } - backoff := q.backoff(row.Attempts) + backoff := q.backoff(currentAttempt) 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 = ? @@ -229,9 +241,9 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error { 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) + // Temporary provider failures and unclassified sender failures are retried; + // PermanentError is handled above without wasting additional attempts. + q.logger.Info("email retry scheduled", "id", row.ID, "attempt", currentAttempt, "backoff", backoff, "error", err) return nil } diff --git a/apps/server/internal/email/queue_test.go b/apps/server/internal/email/queue_test.go index d14ef10..93dd845 100644 --- a/apps/server/internal/email/queue_test.go +++ b/apps/server/internal/email/queue_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "strings" + "sync/atomic" "testing" "time" @@ -152,9 +153,9 @@ func TestQueue_RetryThenSucceed(t *testing.T) { go q.Run(ctx) if err := waitFor(ctx, func() bool { - return sender.successCount >= 1 + return sender.successCount.Load() >= 1 }); err != nil { - t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err) + t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount.Load(), sender.attempts.Load(), err) } // Verify the row is marked sent. @@ -196,6 +197,32 @@ func TestQueue_PermanentFailure(t *testing.T) { } } +func TestQueue_ProviderRejectionFailsImmediately(t *testing.T) { + db := newTestDB(t) + sender := &recordingSender{err: &PermanentError{Cause: errors.New("sender domain not verified")}} + q := NewQueue(db, sender, testLogger(t)) + + id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body") + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + rows, err := q.claimBatch(context.Background(), 1) + if err != nil || len(rows) != 1 { + t.Fatalf("claimBatch = %#v, %v", rows, err) + } + if err := q.deliver(context.Background(), rows[0]); err != nil { + t.Fatalf("deliver: %v", err) + } + var status string + var attempts int + if err := db.QueryRow(`SELECT status, attempts FROM email_queue WHERE id = ?`, id).Scan(&status, &attempts); err != nil { + t.Fatalf("query row: %v", err) + } + if status != "failed" || attempts != 1 { + t.Fatalf("status=%q attempts=%d, want failed after one attempt", status, attempts) + } +} + func TestQueue_NilSenderLeavesPending(t *testing.T) { db := newTestDB(t) q := NewQueue(db, nil, testLogger(t)) @@ -218,17 +245,17 @@ func TestQueue_NilSenderLeavesPending(t *testing.T) { // flakySender fails the first N attempts then succeeds. type flakySender struct { - failTimes int - attempts int - successCount int + failTimes int32 + attempts atomic.Int32 + successCount atomic.Int32 } func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error { - f.attempts++ - if f.attempts <= f.failTimes { + attempt := f.attempts.Add(1) + if attempt <= f.failTimes { return errors.New("transient failure") } - f.successCount++ + f.successCount.Add(1) return nil } diff --git a/apps/server/internal/email/templates.go b/apps/server/internal/email/templates.go index 152ee2c..c12e2f6 100644 --- a/apps/server/internal/email/templates.go +++ b/apps/server/internal/email/templates.go @@ -9,16 +9,16 @@ import ( // 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 + ActorName string + ActorEmail string + DocumentPath string DocumentTitle string - CommentBody string - MentionText string - ConflictDesc string - ViewURL string - UnsubURL string - InstanceName 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 @@ -101,7 +101,7 @@ func renderComment(data TemplateData) (string, string, error) { } else { b.WriteString("> (no body)\n") } - b.WriteString("\nReply to this email to respond.\n") + b.WriteString("\nOpen the document to reply.\n") b.WriteString(footer(data)) return subject, b.String(), nil } diff --git a/apps/server/internal/email/templates_test.go b/apps/server/internal/email/templates_test.go index 0f9c363..4173047 100644 --- a/apps/server/internal/email/templates_test.go +++ b/apps/server/internal/email/templates_test.go @@ -7,12 +7,12 @@ import ( func TestRender_FileChanged(t *testing.T) { subject, body, err := Render("file_changed", TemplateData{ - ActorName: "Alice", - DocumentPath: "docs/api.md", + ActorName: "Alice", + DocumentPath: "docs/api.md", DocumentTitle: "API Reference", - ViewURL: "https://example.com/docs/api.md", - UnsubURL: "https://example.com/unsub/123", - InstanceName: "Cairnquire", + ViewURL: "https://example.com/docs/api.md", + UnsubURL: "https://example.com/unsub/123", + InstanceName: "Cairnquire", }) if err != nil { t.Fatalf("Render: %v", err) @@ -39,10 +39,10 @@ func TestRender_FileChanged(t *testing.T) { func TestRender_Comment(t *testing.T) { subject, body, err := Render("comment", TemplateData{ - ActorName: "Bob", + ActorName: "Bob", DocumentTitle: "Getting Started", - CommentBody: "This step is unclear.\nCan we add an example?", - ViewURL: "https://example.com/docs/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) @@ -56,16 +56,16 @@ func TestRender_Comment(t *testing.T) { 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) + if !strings.Contains(body, "Open the document to reply") { + t.Errorf("body should link users to the supported reply flow: %q", body) } } func TestRender_Mention(t *testing.T) { subject, body, err := Render("mention", TemplateData{ - ActorName: "Carol", + ActorName: "Carol", DocumentTitle: "Spec", - MentionText: "@dan please review", + MentionText: "@dan please review", }) if err != nil { t.Fatalf("Render: %v", err) diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go index aa25e8d..6c274f5 100644 --- a/apps/server/internal/httpserver/api_handlers_test.go +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/tim/cairnquire/apps/server/internal/auth" + "github.com/tim/cairnquire/apps/server/internal/collaboration" "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/docs" @@ -99,6 +100,13 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo } syncRepo := sync.NewRepository(db.SQL()) + hub := realtime.NewHub(logger) + collabService := collaboration.NewServiceWithOptions( + collaboration.NewRepository(db.SQL()), + hub, + logger, + collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"}, + ) handler, err := New(Dependencies{ Config: config.Config{ Content: config.ContentConfig{ @@ -108,14 +116,15 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, DevMode: devMode, }, - Logger: logger, - Documents: docService, - Repository: docRepo, - ContentStore: contentStore, - Hub: realtime.NewHub(logger), - SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger), - SyncRepo: syncRepo, - Auth: authService, + Logger: logger, + Documents: docService, + Repository: docRepo, + ContentStore: contentStore, + Hub: hub, + SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger), + SyncRepo: syncRepo, + Auth: authService, + Collaboration: collabService, }) if err != nil { t.Fatalf("create http server: %v", err) diff --git a/apps/server/internal/httpserver/collab_handlers.go b/apps/server/internal/httpserver/collab_handlers.go index 0dbaa84..74e1aa8 100644 --- a/apps/server/internal/httpserver/collab_handlers.go +++ b/apps/server/internal/httpserver/collab_handlers.go @@ -3,6 +3,8 @@ package httpserver import ( "encoding/json" "net/http" + "strconv" + "strings" "github.com/go-chi/chi/v5" "github.com/tim/cairnquire/apps/server/internal/collaboration" @@ -32,11 +34,12 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { } anchorHash := r.URL.Query().Get("anchor_hash") + includeResolved := r.URL.Query().Get("include_resolved") == "true" var comments []collaboration.Comment if anchorHash != "" { - comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash) + comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved) } else { - comments, err = s.collaboration.ListComments(r.Context(), documentID) + comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved) } if err != nil { s.logger.Warn("list comments", "error", err) @@ -128,8 +131,13 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) unreadOnly := r.URL.Query().Get("unread") == "true" limit := 50 - if r.URL.Query().Get("limit") != "" { - // Parse limit if needed, but for now just use default + if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" { + parsed, err := strconv.Atoi(rawLimit) + if err != nil || parsed < 1 || parsed > 100 { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"}) + return + } + limit = parsed } notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit) @@ -204,6 +212,21 @@ func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + req.DocumentID = strings.TrimSpace(req.DocumentID) + document, err := s.repository.GetDocumentByID(r.Context(), req.DocumentID) + if err != nil { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } + canRead, err := s.canReadDocumentRecord(r, *document) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !canRead { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil { s.logger.Warn("watch document", "error", err) @@ -227,6 +250,7 @@ func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + req.DocumentID = strings.TrimSpace(req.DocumentID) if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil { s.logger.Warn("unwatch document", "error", err) @@ -250,8 +274,17 @@ func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + normalized, err := collaboration.NormalizeFolderPath(req.FolderPath) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !s.canReadFolder(r, normalized) { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"}) + return + } - if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil { + if err := s.collaboration.WatchFolder(r.Context(), principal, normalized); err != nil { s.logger.Warn("watch folder", "error", err) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return @@ -273,8 +306,13 @@ func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + normalized, err := collaboration.NormalizeFolderPath(req.FolderPath) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } - if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil { + if err := s.collaboration.UnwatchFolder(r.Context(), principal, normalized); err != nil { s.logger.Warn("unwatch folder", "error", err) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return @@ -290,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) { } documentID := r.URL.Query().Get("document_id") + document, err := s.repository.GetDocumentByID(r.Context(), documentID) + if err != nil { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } + canRead, err := s.canReadDocumentRecord(r, *document) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !canRead { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID) if err != nil { s.logger.Warn("is watching", "error", err) diff --git a/apps/server/internal/httpserver/collab_handlers_test.go b/apps/server/internal/httpserver/collab_handlers_test.go new file mode 100644 index 0000000..b7b81ed --- /dev/null +++ b/apps/server/internal/httpserver/collab_handlers_test.go @@ -0,0 +1,141 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tim/cairnquire/apps/server/internal/auth" + "github.com/tim/cairnquire/apps/server/internal/collaboration" +) + +func loginTestUser(t *testing.T, server *apiTestServer) *http.Cookie { + t.Helper() + login := httptest.NewRecorder() + server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{ + "email": "editor@example.com", "password": "very secure password", + })) + if login.Code != http.StatusOK { + t.Fatalf("login response = %d %q", login.Code, login.Body.String()) + } + return cookieByName(t, login.Result().Cookies(), auth.SessionCookieName) +} + +func authenticatedJSONRequest(t *testing.T, method, target string, body any, session *http.Cookie) *http.Request { + t.Helper() + request := jsonRequest(method, target, body) + request.AddCookie(session) + return request +} + +func TestDocumentLoadsCollaborationControlsAndWatchFlow(t *testing.T) { + server := newAPITestServer(t) + session := loginTestUser(t, server) + + pageRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil) + pageRequest.AddCookie(session) + page := httptest.NewRecorder() + server.handler.ServeHTTP(page, pageRequest) + if page.Code != http.StatusOK { + t.Fatalf("document response = %d %q", page.Code, page.Body.String()) + } + for _, expected := range [][]byte{ + []byte(`/static/notification-bell.js`), + []byte(`data-document-id="doc:hello"`), + []byte(`data-document-watch`), + []byte(`data-comments-history`), + } { + if !bytes.Contains(page.Body.Bytes(), expected) { + t.Errorf("document page missing %q", expected) + } + } + + statusRequest := httptest.NewRequest(http.MethodGet, "/api/watch/status?document_id=doc%3Ahello", nil) + statusRequest.AddCookie(session) + status := httptest.NewRecorder() + server.handler.ServeHTTP(status, statusRequest) + if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":false`)) { + t.Fatalf("initial watch status = %d %q", status.Code, status.Body.String()) + } + + watch := httptest.NewRecorder() + server.handler.ServeHTTP(watch, authenticatedJSONRequest(t, http.MethodPost, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session)) + if watch.Code != http.StatusOK { + t.Fatalf("watch response = %d %q", watch.Code, watch.Body.String()) + } + + status = httptest.NewRecorder() + server.handler.ServeHTTP(status, statusRequest.Clone(statusRequest.Context())) + if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":true`)) { + t.Fatalf("watched status = %d %q", status.Code, status.Body.String()) + } + + unwatch := httptest.NewRecorder() + server.handler.ServeHTTP(unwatch, authenticatedJSONRequest(t, http.MethodDelete, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session)) + if unwatch.Code != http.StatusOK { + t.Fatalf("unwatch response = %d %q", unwatch.Code, unwatch.Body.String()) + } +} + +func TestCommentReplyResolveAndHistoryHTTPFlow(t *testing.T) { + server := newAPITestServer(t) + session := loginTestUser(t, server) + anchor := "section-anchor" + + create := func(content string, parentID *string) collaboration.Comment { + t.Helper() + response := httptest.NewRecorder() + server.handler.ServeHTTP(response, authenticatedJSONRequest(t, http.MethodPost, "/api/comments", collaboration.CreateCommentInput{ + DocumentID: "doc:hello", VersionHash: "hash", ParentID: parentID, Content: content, AnchorHash: &anchor, + }, session)) + if response.Code != http.StatusCreated { + t.Fatalf("create comment response = %d %q", response.Code, response.Body.String()) + } + var comment collaboration.Comment + if err := json.NewDecoder(response.Body).Decode(&comment); err != nil { + t.Fatalf("decode comment: %v", err) + } + return comment + } + + root := create("Root", nil) + reply := create("Reply", &root.ID) + _ = create("Grandchild", &reply.ID) + + resolve := httptest.NewRecorder() + server.handler.ServeHTTP(resolve, authenticatedJSONRequest(t, http.MethodPost, "/api/comments/"+root.ID+"/resolve", map[string]any{}, session)) + if resolve.Code != http.StatusOK { + t.Fatalf("resolve response = %d %q", resolve.Code, resolve.Body.String()) + } + + list := func(includeResolved bool) []collaboration.Comment { + t.Helper() + target := "/api/comments?document_id=doc%3Ahello&anchor_hash=" + anchor + if includeResolved { + target += "&include_resolved=true" + } + request := httptest.NewRequest(http.MethodGet, target, nil) + request.AddCookie(session) + response := httptest.NewRecorder() + server.handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("list comments response = %d %q", response.Code, response.Body.String()) + } + var body struct { + Comments []collaboration.Comment `json:"comments"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatalf("decode comments: %v", err) + } + return body.Comments + } + + if active := list(false); len(active) != 0 { + t.Fatalf("active comments = %d, want 0", len(active)) + } + if history := list(true); len(history) != 3 { + t.Fatalf("history comments = %d, want 3", len(history)) + } +} diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index a2e4e6b..e414747 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -39,6 +39,7 @@ type indexData struct { type documentData struct { Browser browserData + ID string Title string Path string Tags []string @@ -375,6 +376,7 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page BodyTemplate: "document_content", Data: documentData{ Browser: browser, + ID: record.ID, Title: page.Title, Path: page.Path, Tags: page.Tags, diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index daffbaa..7085d51 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -207,7 +207,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) { case strings.HasPrefix(path, "/api/content/"): return auth.ScopeSyncRead, true case strings.HasPrefix(path, "/api/comments"): - return auth.ScopeDocsRead, true + if method == http.MethodGet { + return auth.ScopeDocsRead, true + } + return auth.ScopeDocsWrite, true case strings.HasPrefix(path, "/api/notifications"): return auth.ScopeDocsRead, true case strings.HasPrefix(path, "/api/watch"): diff --git a/apps/server/internal/httpserver/static/comments.js b/apps/server/internal/httpserver/static/comments.js index 325e1fe..7d0edb8 100644 --- a/apps/server/internal/httpserver/static/comments.js +++ b/apps/server/internal/httpserver/static/comments.js @@ -1,23 +1,29 @@ (function () { 'use strict'; - const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath; - const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash; - const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true'; - const article = document.querySelector('[data-document-path]'); - const workspaceShell = article?.closest('.workspace-shell--document'); + const article = document.querySelector('[data-document-id]'); + if (!article) return; + + const documentId = article.dataset.documentId; + const documentHash = article.dataset.documentHash; + const canComment = article.dataset.canComment === 'true'; + const workspaceShell = article.closest('.workspace-shell--document'); const commentsAsideList = document.querySelector('[data-comments-aside-list]'); const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]'); const stripTrack = document.querySelector('[data-comments-strip-track]'); const commentsAside = document.querySelector('[data-comments-aside]'); const commentsSheetToggle = document.querySelector('[data-toggle-comments]'); const commentsSheetClose = document.querySelector('[data-close-comments]'); - const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null; const commentToggle = document.querySelector('[data-comment-toggle]'); - const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null; + const watchButton = document.querySelector('[data-document-watch]'); + const historyButtons = document.querySelectorAll('[data-comments-history]'); + const commentVisibilityKey = `cairnquire:document-comments:${documentId}`; + const commentsByAnchor = new Map(); + const elementsByAnchor = new Map(); + let includeResolved = false; + let watching = false; function readCommentVisibility() { - if (!commentVisibilityKey) return false; try { return window.localStorage.getItem(commentVisibilityKey) === 'hidden'; } catch (err) { @@ -25,29 +31,17 @@ } } - if (!documentId) return; - - function syncVisibility() { - const hidden = readCommentVisibility(); - if (article) article.classList.toggle('comments-hidden', hidden); - if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden); - } - - syncVisibility(); - function commentsHidden() { - return Boolean(workspaceShell?.classList.contains('comments-visible')) === false; + return !workspaceShell?.classList.contains('comments-visible'); } function setCommentsHidden(hidden) { - if (article) article.classList.toggle('comments-hidden', hidden); - if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden); - if (commentVisibilityKey) { - try { - window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible'); - } catch (err) { - // Ignore persistence failures - } + article.classList.toggle('comments-hidden', hidden); + workspaceShell?.classList.toggle('comments-visible', !hidden); + try { + window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible'); + } catch (err) { + // Storage can be unavailable in private browsing contexts. } if (commentToggle) { commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true'); @@ -60,173 +54,218 @@ } } - if (commentToggle) { - commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true'); - commentToggle.addEventListener('click', () => { - setCommentsHidden(!commentsHidden()); - }); - } + setCommentsHidden(readCommentVisibility()); + commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden())); function hashParagraph(el) { const text = (el.textContent || '').trim(); if (!text) return null; let hash = 0; for (let i = 0; i < text.length; i++) { - const char = text.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; + hash = ((hash << 5) - hash) + text.charCodeAt(i); + hash &= hash; } return 'h' + Math.abs(hash).toString(36); } - function addCommentButton(el, hash) { - const btn = document.createElement('button'); - btn.className = 'comment-anchor-btn'; - btn.type = 'button'; - btn.title = 'Add comment'; - btn.setAttribute('aria-label', 'Add comment'); - btn.innerHTML = ` + function addCommentButton(el, anchorHash) { + const button = document.createElement('button'); + button.className = 'comment-anchor-btn'; + button.type = 'button'; + button.title = 'Add comment'; + button.setAttribute('aria-label', 'Add comment'); + button.innerHTML = ` - Add comment - `; - btn.addEventListener('click', () => promptComment(el, hash)); - el.appendChild(btn); + Add comment`; + button.addEventListener('click', () => createComment(anchorHash)); + el.appendChild(button); } - function updateCommentButtonState(el, count) { - const btn = el.querySelector('.comment-anchor-btn'); - if (!btn) return; - const hasComments = count > 0; - btn.classList.toggle('comment-anchor-btn--has-comments', hasComments); - btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment'); - btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment'; - const countNode = btn.querySelector('.comment-anchor-count'); - if (countNode) { - countNode.hidden = !hasComments; - countNode.textContent = count > 99 ? '99+' : String(count); + function updateCommentButtonState(el, comments) { + const button = el.querySelector('.comment-anchor-btn'); + const count = comments.filter(comment => !comment.resolvedAt).length; + if (button) { + const hasComments = count > 0; + button.classList.toggle('comment-anchor-btn--has-comments', hasComments); + const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment'; + button.setAttribute('aria-label', label); + button.title = label; + const countNode = button.querySelector('.comment-anchor-count'); + if (countNode) { + countNode.hidden = !hasComments; + countNode.textContent = count > 99 ? '99+' : String(count); + } } + el.dataset.hasComments = comments.length > 0 ? 'true' : 'false'; } - async function promptComment(el, anchorHash) { - const text = window.prompt('Comment on this paragraph:'); - if (!text || !text.trim()) return; + async function createComment(anchorHash, parentId) { + const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:'; + const content = window.prompt(promptText); + if (!content || !content.trim()) return; try { - const res = await fetch('/api/comments', { + const response = await fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }), + body: JSON.stringify({ + documentId, + versionHash: documentHash, + content: content.trim(), + anchorHash, + parentId: parentId || undefined, + }), }); - if (!res.ok) throw new Error(await res.text()); - await loadCommentsForAnchor(el, anchorHash); + if (!response.ok) throw new Error(await response.text()); + await loadCommentsForAnchor(anchorHash); } catch (err) { window.alert('Failed to post comment: ' + err.message); } } - async function loadCommentsForAnchor(el, anchorHash) { + async function resolveThread(comment, anchorHash) { + if (!window.confirm('Resolve this comment thread?')) return; try { - const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`); - if (!res.ok) return; - const data = await res.json(); - renderComments(el, data.comments || []); + const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' }); + if (!response.ok) throw new Error(await response.text()); + await loadCommentsForAnchor(anchorHash); + } catch (err) { + window.alert('Failed to resolve comment: ' + err.message); + } + } + + async function loadCommentsForAnchor(anchorHash) { + const element = elementsByAnchor.get(anchorHash); + if (!element) return; + const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash }); + if (includeResolved) params.set('include_resolved', 'true'); + try { + const response = await fetch(`/api/comments?${params}`); + if (!response.ok) return; + const data = await response.json(); + const comments = data.comments || []; + commentsByAnchor.set(anchorHash, comments); + renderInlineComments(element, anchorHash, comments); + renderCommentsAside(); + updateStripMarkers(); } catch (err) { console.error('load comments', err); } } - function renderComments(el, comments) { - const existing = el.querySelector('.comment-list'); - if (existing) existing.remove(); - - updateCommentButtonState(el, comments.length); - el.dataset.hasComments = comments.length > 0 ? 'true' : 'false'; - - if (!comments.length) { - renderCommentsAside(); - updateStripMarkers(); - return; - } - - const list = document.createElement('div'); - list.className = 'comment-list'; - comments.forEach(c => { - const item = document.createElement('div'); - item.className = 'comment-item'; - item.innerHTML = ` -
${escapeHtml(c.authorName || 'Anonymous')}
-
${escapeHtml(c.content)}
- `; - list.appendChild(item); + function buildCommentForest(comments) { + const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }])); + const roots = []; + comments.forEach(comment => { + const node = nodes.get(comment.id); + const parent = comment.parentId ? nodes.get(comment.parentId) : null; + if (parent) parent.children.push(node); + else roots.push(node); }); - el.appendChild(list); - - renderCommentsAside(); - updateStripMarkers(); + return roots; } - function buildAsideItemFor(el) { - const comments = []; - el.querySelectorAll('.comment-item').forEach(item => { - const meta = item.querySelector('.comment-meta'); - const bodyEl = item.querySelector('.comment-body'); - comments.push({ - authorName: meta?.querySelector('strong')?.textContent || 'Anonymous', - createdAt: meta?.querySelector('time')?.textContent || '', - content: bodyEl?.textContent || '', - }); - }); + function renderThreadNode(node, anchorHash, variant) { + const comment = node.comment; + const wrapper = document.createElement('div'); + wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`; + wrapper.dataset.commentId = comment.id; + + const item = document.createElement('div'); + item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item'; + + const meta = document.createElement('div'); + meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta'; + const author = document.createElement('strong'); + author.textContent = comment.authorName || 'Anonymous'; + const time = document.createElement('time'); + time.textContent = formatDate(comment.createdAt); + meta.append(author, time); + if (comment.resolvedAt) { + const status = document.createElement('span'); + status.className = 'comment-status'; + status.textContent = 'Resolved'; + meta.appendChild(status); + } + + const body = document.createElement('div'); + body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body'; + body.textContent = comment.content; + item.append(meta, body); + + if (canComment && !comment.resolvedAt) { + const actions = document.createElement('div'); + actions.className = 'comment-actions'; + const reply = document.createElement('button'); + reply.type = 'button'; + reply.className = 'comment-action'; + reply.textContent = 'Reply'; + reply.addEventListener('click', () => createComment(anchorHash, comment.id)); + actions.appendChild(reply); + if (!comment.parentId) { + const resolve = document.createElement('button'); + resolve.type = 'button'; + resolve.className = 'comment-action'; + resolve.textContent = 'Resolve thread'; + resolve.addEventListener('click', () => resolveThread(comment, anchorHash)); + actions.appendChild(resolve); + } + item.appendChild(actions); + } + wrapper.appendChild(item); + + if (node.children.length) { + const replies = document.createElement('div'); + replies.className = 'comment-thread__replies'; + node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant))); + wrapper.appendChild(replies); + } + return wrapper; + } + + function renderInlineComments(el, anchorHash, comments) { + el.querySelector('.comment-list')?.remove(); + updateCommentButtonState(el, comments); + if (!comments.length) return; + const list = document.createElement('div'); + list.className = 'comment-list'; + buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline'))); + el.appendChild(list); + } + + function buildAsideItem(anchorHash, el, comments) { if (!comments.length) return null; - - const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120); - const truncated = targetText.length > 120 ? targetText + '…' : targetText; - const asideItem = document.createElement('div'); asideItem.className = 'comments-aside-item'; - asideItem.dataset.anchorHash = el.dataset.anchorHash; - - const targetEl = document.createElement('div'); - targetEl.className = 'comments-aside-item__target'; - targetEl.textContent = truncated || 'Commented section'; - targetEl.addEventListener('click', () => { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - asideItem.appendChild(targetEl); - - comments.forEach(c => { - const commentEl = document.createElement('div'); - commentEl.className = 'comments-aside-item__comment'; - commentEl.innerHTML = ` -
${escapeHtml(c.authorName)}
-
${escapeHtml(c.content)}
- `; - asideItem.appendChild(commentEl); - }); + asideItem.dataset.anchorHash = anchorHash; + const target = document.createElement('button'); + target.className = 'comments-aside-item__target'; + target.type = 'button'; + target.textContent = el.dataset.commentTarget || 'Commented section'; + target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' })); + asideItem.appendChild(target); + buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside'))); return asideItem; } function renderCommentsInto(container) { if (!container) return; container.innerHTML = ''; - - const body = document.querySelector('.markdown-body'); - if (!body) return; - - const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]'); - if (!commentables.length) { - container.innerHTML = '

No comments yet.

'; - return; - } - - commentables.forEach(el => { - const item = buildAsideItemFor(el); - if (item) container.appendChild(item); + let rendered = 0; + elementsByAnchor.forEach((el, anchorHash) => { + const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []); + if (item) { + container.appendChild(item); + rendered++; + } }); + if (!rendered) { + container.innerHTML = `

${includeResolved ? 'No comments found.' : 'No active comments yet.'}

`; + } } function renderCommentsAside() { @@ -237,177 +276,148 @@ function updateStripMarkers() { if (!stripTrack) return; stripTrack.innerHTML = ''; - - const body = document.querySelector('.markdown-body'); const shell = document.querySelector('.document-shell'); - if (!body || !shell) return; - - const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]'); - const shellRect = shell.getBoundingClientRect(); - const trackHeight = stripTrack.clientHeight; - - commentables.forEach(el => { + if (!shell) return; + elementsByAnchor.forEach((el, anchorHash) => { + if (!(commentsByAnchor.get(anchorHash) || []).length) return; const rect = el.getBoundingClientRect(); + const shellRect = shell.getBoundingClientRect(); const relativeTop = rect.top - shellRect.top + shell.scrollTop; const percent = (relativeTop / shell.scrollHeight) * 100; - const clamped = Math.max(0, Math.min(95, percent)); - const marker = document.createElement('button'); marker.className = 'document-comments-strip__marker'; marker.type = 'button'; - marker.style.top = clamped + '%'; - marker.dataset.anchorHash = el.dataset.anchorHash; + marker.style.top = Math.max(0, Math.min(95, percent)) + '%'; + marker.dataset.anchorHash = anchorHash; marker.title = 'Jump to comments'; marker.addEventListener('click', () => { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (commentsHidden()) setCommentsHidden(false); - // Highlight the corresponding sidebar item - const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`); - if (asideItem) { - asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' }); - asideItem.classList.add('is-highlighted'); - setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500); - } + commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); stripTrack.appendChild(marker); }); } - let bidirectionalHoverInitialized = false; function initBidirectionalHover() { - if (bidirectionalHoverInitialized) return; - bidirectionalHoverInitialized = true; - const body = document.querySelector('.markdown-body'); if (!body || !commentsAsideList) return; - - // Body → sidebar + marker - body.addEventListener('mouseenter', (e) => { - const el = e.target.closest('.commentable[data-has-comments="true"]'); - if (!el) return; - const hash = el.dataset.anchorHash; - const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`); - const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`); - if (asideItem) asideItem.classList.add('is-highlighted'); - if (marker) marker.classList.add('is-active'); - el.classList.add('is-highlighted'); - }, true); - - body.addEventListener('mouseleave', (e) => { - const el = e.target.closest('.commentable[data-has-comments="true"]'); - if (!el) return; - const hash = el.dataset.anchorHash; - const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`); - const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`); - if (asideItem) asideItem.classList.remove('is-highlighted'); - if (marker) marker.classList.remove('is-active'); - el.classList.remove('is-highlighted'); - }, true); - - // Sidebar → paragraph + marker (event delegation so it survives re-renders) - commentsAsideList.addEventListener('mouseenter', (e) => { - const item = e.target.closest('.comments-aside-item'); - if (!item) return; - const hash = item.dataset.anchorHash; - const para = body.querySelector(`[data-anchor-hash="${hash}"]`); - const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`); - if (para) para.classList.add('is-highlighted'); - if (marker) marker.classList.add('is-active'); - item.classList.add('is-highlighted'); - }, true); - - commentsAsideList.addEventListener('mouseleave', (e) => { - const item = e.target.closest('.comments-aside-item'); - if (!item) return; - const hash = item.dataset.anchorHash; - const para = body.querySelector(`[data-anchor-hash="${hash}"]`); - const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`); - if (para) para.classList.remove('is-highlighted'); - if (marker) marker.classList.remove('is-active'); - item.classList.remove('is-highlighted'); - }, true); + body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true)); + body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false)); + commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true)); + commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false)); } - function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; + function toggleHighlight(el, active) { + if (!el) return; + const hash = el.dataset.anchorHash; + el.classList.toggle('is-highlighted', active); + commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active); + stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active); } - function formatDate(iso) { - const d = new Date(iso); - return d.toLocaleString(); - } - - function commentsSheetOpen() { - return commentsAside?.classList.contains('is-open'); + function toggleAsideHighlight(item, active) { + if (!item) return; + toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active); } function setCommentsSheetOpen(open) { if (!commentsAside) return; commentsAside.classList.toggle('is-open', open); - if (commentsSheetToggle) { - commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); - commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments'); - } + commentsSheetToggle?.setAttribute('aria-expanded', String(open)); if (open) { document.body.classList.add('drawer-open'); renderCommentsAside(); - } else { - if (!pickerOpen() && !metaOpen()) { - document.body.classList.remove('drawer-open'); - } + } else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) { + document.body.classList.remove('drawer-open'); } } - function pickerOpen() { - return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open'); + commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open'))); + commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false)); + + function setWatchState(nextWatching) { + watching = nextWatching; + if (!watchButton) return; + watchButton.setAttribute('aria-pressed', String(watching)); + const label = watchButton.querySelector('[data-document-watch-label]'); + if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document'; } - function metaOpen() { - return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open'); + async function loadWatchStatus() { + if (!watchButton) return; + try { + const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`); + if (!response.ok) return; + const data = await response.json(); + setWatchState(Boolean(data.watching)); + } catch (err) { + console.error('load watch status', err); + } } - if (commentsSheetToggle) { - commentsSheetToggle.addEventListener('click', () => { - setCommentsSheetOpen(!commentsSheetOpen()); + async function toggleWatch() { + if (!watchButton) return; + watchButton.disabled = true; + try { + const response = await fetch('/api/watch/document', { + method: watching ? 'DELETE' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ documentId }), + }); + if (!response.ok) throw new Error(await response.text()); + setWatchState(!watching); + } catch (err) { + window.alert('Failed to update watch: ' + err.message); + } finally { + watchButton.disabled = false; + } + } + + watchButton?.addEventListener('click', toggleWatch); + + function updateHistoryButtons() { + historyButtons.forEach(button => { + button.setAttribute('aria-pressed', String(includeResolved)); + button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved'; }); } - if (commentsSheetClose) { - commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false)); + historyButtons.forEach(button => button.addEventListener('click', async () => { + includeResolved = !includeResolved; + updateHistoryButtons(); + await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor)); + })); + + function formatDate(iso) { + return new Date(iso).toLocaleString(); } function init() { const body = document.querySelector('.markdown-body'); if (!body) return; - - const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre'); - paragraphs.forEach(el => { - const hash = hashParagraph(el); - if (!hash) return; - el.dataset.anchorHash = hash; + const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre'); + commentables.forEach(el => { + const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim(); + const anchorHash = hashParagraph(el); + if (!anchorHash) return; + el.dataset.anchorHash = anchorHash; + el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText; el.classList.add('commentable'); - if (canComment) addCommentButton(el, hash); - loadCommentsForAnchor(el, hash); + elementsByAnchor.set(anchorHash, el); + if (canComment) addCommentButton(el, anchorHash); + loadCommentsForAnchor(anchorHash); }); - renderCommentsAside(); initBidirectionalHover(); - - // Update markers on scroll/resize + loadWatchStatus(); + updateHistoryButtons(); const shell = document.querySelector('.document-shell'); - if (shell) { - shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers)); - } + shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers)); window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers)); - // Initial marker update after layout settles setTimeout(updateStripMarkers, 100); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); \ No newline at end of file + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/apps/server/internal/httpserver/static/notification-bell.js b/apps/server/internal/httpserver/static/notification-bell.js index fd5d724..494fec1 100644 --- a/apps/server/internal/httpserver/static/notification-bell.js +++ b/apps/server/internal/httpserver/static/notification-bell.js @@ -7,6 +7,7 @@ const trigger = bell.querySelector('.notification-bell__trigger'); const countEl = bell.querySelector('[data-unread-count]'); const dropdown = bell.querySelector('[data-notification-dropdown]'); + if (!trigger || !countEl || !dropdown) return; let isOpen = false; trigger.addEventListener('click', (e) => { @@ -76,31 +77,42 @@ notifications.forEach(n => { const li = document.createElement('li'); li.className = n.readAt ? 'notification-item is-read' : 'notification-item'; - li.innerHTML = ` + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'notification-item__button'; + button.innerHTML = `
${escapeHtml(n.message)}
`; - if (!n.readAt) { - li.addEventListener('click', () => markRead(n.id)); - } + button.addEventListener('click', async () => { + if (!n.readAt) await markRead(n.id, false); + const target = notificationURL(n); + if (target) window.location.assign(target); + }); + li.appendChild(button); list.appendChild(li); }); // Add "Mark all read" button - const footer = document.createElement('li'); - footer.className = 'notification-footer'; - const btn = document.createElement('button'); - btn.textContent = 'Mark all read'; - btn.addEventListener('click', markAllRead); - footer.appendChild(btn); - list.appendChild(footer); + if (notifications.some(notification => !notification.readAt)) { + const footer = document.createElement('li'); + footer.className = 'notification-footer'; + const btn = document.createElement('button'); + btn.textContent = 'Mark all read'; + btn.addEventListener('click', markAllRead); + footer.appendChild(btn); + list.appendChild(footer); + } } - async function markRead(id) { + async function markRead(id, refresh = true) { try { - await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); - updateBell(); - loadNotifications(); + const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' }); + if (!response.ok) throw new Error(await response.text()); + if (refresh) { + updateBell(); + loadNotifications(); + } } catch (err) { console.error('mark read', err); } @@ -108,7 +120,8 @@ async function markAllRead() { try { - await fetch('/api/notifications/read-all', { method: 'POST' }); + const response = await fetch('/api/notifications/read-all', { method: 'POST' }); + if (!response.ok) throw new Error(await response.text()); updateBell(); loadNotifications(); } catch (err) { @@ -127,6 +140,12 @@ return d.toLocaleString(); } + function notificationURL(notification) { + if (notification.resourceType !== 'document' || !notification.resourceId) return ''; + const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/'); + return '/docs/' + path; + } + // Listen for WebSocket notification events if (window.__cairnquireRealtime) { window.__cairnquireRealtime.on('notification', () => { diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 1b3a0c0..9fb2f68 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -198,19 +198,27 @@ code { } .notification-item { - padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); - cursor: pointer; transition: background 0.1s; } +.notification-item__button { + display: block; + width: 100%; + padding: 0.75rem 1rem; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + .notification-item:hover { background: var(--accent-soft); } .notification-item.is-read { opacity: 0.7; - cursor: default; } .notification-message { @@ -1199,6 +1207,27 @@ code { padding: 0.5rem 0.75rem; } +.comments-toolbar { + display: flex; + justify-content: flex-end; + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.comments-toolbar button, +.comment-action { + padding: 0; + border: 0; + background: transparent; + color: var(--accent); + font: inherit; + cursor: pointer; +} + +.comments-toolbar button { + font-size: 0.75rem; +} + /* Bidirectional hover highlighting */ .comments-aside-item { margin-bottom: 0.5rem; @@ -1216,6 +1245,13 @@ code { } .comments-aside-item__target { + display: block; + width: calc(100% + 1.2rem); + text-align: left; + font: inherit; + border-top: 0; + border-right: 0; + border-left: 0; margin: -0.6rem -0.6rem 0.4rem; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); @@ -1259,6 +1295,39 @@ code { border-bottom: 0; } +.comment-thread__replies { + margin-left: 0.7rem; + padding-left: 0.65rem; + border-left: 2px solid var(--border); +} + +.comment-thread.is-resolved { + opacity: 0.7; +} + +.comment-status { + padding: 0.05rem 0.3rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.68rem; +} + +.comment-actions { + display: flex; + gap: 0.65rem; + margin-top: 0.3rem; +} + +.comment-action { + font-size: 0.75rem; +} + +.comment-action:hover, +.comments-toolbar button:hover { + text-decoration: underline; +} + .document-comments-aside__empty { margin: 0; padding: 1rem 0; diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index b7d4c90..8f2a1f1 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -107,6 +107,7 @@ + diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml index d4236e5..ee8d18f 100644 --- a/apps/server/internal/httpserver/templates/document.gohtml +++ b/apps/server/internal/httpserver/templates/document.gohtml @@ -4,7 +4,7 @@
{{ template "browser" .Browser }} -
+
{{ if .CanWrite }} @@ -106,6 +110,9 @@
+
+ +
@@ -126,6 +133,9 @@
+
+ +
diff --git a/docker-compose.yml b/docker-compose.yml index 4189d21..78a80c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,12 @@ services: CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content" CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files" CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}" + CAIRNQUIRE_EMAIL_PROVIDER: "${CAIRNQUIRE_EMAIL_PROVIDER:-smtp}" CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}" CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}" + CAIRNQUIRE_EMAIL_POSTMARK_TOKEN: "${CAIRNQUIRE_EMAIL_POSTMARK_TOKEN:-}" + CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID:-}" + CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN:-}" CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}" CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}" volumes: