You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a user shares an asset, collection, or prompt with another user, the recipient is never told. The share is recorded in portal_shares.shared_with_email and surfaces in the portal's "shared with me" view, but there is no out-of-band signal, so recipients only discover shared items if they happen to go looking. The same gap applies to thread comments and feedback (epic #600): a teammate can comment on something you own and you will not know.
This ticket builds the notification substrate the platform is missing: an admin SMTP configuration surface, a per-user notification preference store, a delivery worker (immediate or daily digest), and branded HTML emails. It is the prerequisite that #627 (@mention tagging on thread comments) calls out as out of its own scope.
Goals
Admin can configure SMTP from the portal, in a settings surface designed to hold future global settings (not SMTP-only).
Each user can manage notification preferences: turn notifications off entirely, receive them immediately, or batch them into a daily digest.
Recipients get a well-designed, portal-branded HTML email when something is shared with them and when a thread comment/feedback event targets them.
Email delivery never blocks the share/comment request path and survives a pod restart (queued + retried, not fire-and-forget).
Verified Current State
No email/SMTP code exists anywhere in the repo. grep -ri "smtp\|net/smtp\|sendmail" over pkg/ returns nothing but data fields. This is greenfield.
Share recipient is already persisted. Direct user shares set shared_with_email and run through ShareStore.Insert() at three handlers:
prompt: pkg/portal/prompt_handler.go (createPromptShare, ~line 635)
These three Insert call sites are the share trigger points.
Thread events exist. pkg/portal/thread_handler.go (createThread ~line 210, appendThreadEvent ~line 348) write to portal_threads / portal_thread_events. These are the comment/feedback trigger points.
Settings already have a DB-backed home. config_entries (migration 000026) + pkg/configstore is the admin-editable-config pattern, with an audit changelog and a file-mode vs database-mode gate (writes return 405 in file mode). Admin config routes live in pkg/admin/handler.go / config_handler.go.
No per-user preferences table exists. users (migration 000067) is an admin-maintained directory (name, confirmed, source) only.
Encryption helper exists. pkg/platform/fieldcrypt.goFieldEncryptor (AES-256-GCM, NewFieldEncryptor(key []byte), EncryptSensitiveFields/DecryptSensitiveFields) already encrypts connection secrets. The SMTP password reuses this, not a new scheme.
Background-worker pattern exists. pkg/indexjobs/worker.go polls a DB queue with a time.Ticker fallback plus LISTEN/NOTIFY wakeup and lease-based locking. The digest scheduler and the send worker follow this pattern.
Branding is configurable per deployment. PortalConfig (pkg/platform/config.go ~196-229) carries Title, light/dark logos, and Implementor (name/logo/url); resolved into SVGs at startup (cmd/mcp-data-platform/main.go ~620-653). Email templates pull from the same source so emails match the portal.
Sized so each can be a separate PR. Suggest splitting into child issues; this ticket is the epic.
A. Admin settings surface + SMTP config
New admin page /admin/settings (general settings, expandable), with an SMTP section. Register in ui/src/components/layout/Sidebar.tsxadminNavItems and ui/src/components/layout/AppShell.tsx.
Storage: a dedicated platform_settings table (decided; see Decisions Made), following the pkg/user/store.go interface+postgres pattern. Non-secret SMTP fields (host, port, from, username, TLS mode) stored plainly; password encrypted at rest via FieldEncryptor and write-only in the UI (never returned).
Admin REST endpoints under /api/v1/admin/... (mutable-only gated, like existing config routes). Include a "send test email" action.
Honor file-mode vs database-mode read-only gating, consistent with existing admin config behavior.
B. User notification preferences
New user_notification_prefs table keyed by email (FK to users.email): a global mode (off | immediate | daily) and per-category toggles (shares, comments). Default to immediate-on (per the "important features default ON" convention).
Store interface + postgres impl (pkg/notification or pkg/user), following pkg/user/store.go.
A non-admin portal settings page with a Notifications section (toggle off / immediate / daily digest). This is a user-facing page, not under /admin.
REST endpoints scoped to the authenticated user (self-scope server-side; a user can only read/write their own prefs).
C. Notification core (enqueue + store)
notifications queue table (recipient email, category, payload/refs, status, attempts, scheduled_for, created_at) with a status index, following migration conventions in pkg/database/migrate/migrations/.
Enqueue function that consults user_notification_prefs: drop if off, queue for immediate send, or stamp scheduled_for the next digest window if daily.
Wire enqueue into the three share Insert call sites and the thread-event call site. Non-blocking: enqueue is a cheap DB insert; actual send is the worker's job. A share/comment must still succeed if enqueue fails (log, do not fail the request).
D. Delivery worker + daily digest
Send worker following pkg/indexjobs/worker.go (poll + LISTEN/NOTIFY, lease locking, retry with backoff, heartbeat). Claims immediate rows and due daily rows.
Daily digest: groups a user's pending daily notifications into one email per window. Confirm window/timezone behavior (suggest a single configurable UTC send hour for v1).
Wire worker startup into platform lifecycle alongside the existing job worker.
E. Branded HTML email templates
Responsive HTML email (table-based for client compatibility) with a plaintext fallback part. Pull brand title, logo, and implementor from the same source the portal uses (PortalConfig resolved SVG/values).
Templates: "shared with you" (asset/collection/prompt variants) and "new comment/feedback". Digest template lists multiple items.
Deep links back into the portal using Portal.PublicBaseURL.
F. Config + docs
If any email settings live in platform.yaml (e.g., encryption key reference, digest send hour), add an EmailConfig/notification section in pkg/platform/config.go with defaults in applyDefaults, mirroring existing sections.
Update README.md, docs/, docs/llms.txt, docs/llms-full.txt (doc-check is a blocking gate for new config/migrations/pages/endpoints).
#627 (@mention tagging + notifications on thread comments) names "a notification-delivery mechanism and a per-user preferred channel setting" as a prerequisite it does not build. This ticket delivers exactly that, plus the generic "new comment/feedback" notification. #627 then becomes a thin consumer: parse @user tokens from the comment body and enqueue a mention notification through the substrate built here. This ticket should land first; #627 builds on workstreams B, C, D, E.
Decisions Made
Triggers in v1: direct shares (asset/collection/prompt) and thread comments/feedback.
Daily digest is in scope for v1 (worker + scheduler), alongside immediate and off.
SMTP password encrypted at rest via the existing FieldEncryptor, in a dedicated settings store; write-only in the UI.
Settings storage: a dedicated platform_settings table (typed sections, encrypted SMTP password via FieldEncryptor, password write-only in the UI), following the pkg/user/store.go interface + postgres pattern. Not config_entries — the value shape is typed and the secret needs encryption, which the untyped key/value store would special-case.
Email library: a vetted, pinned third-party SMTP library rather than stdlib net/smtp. Recommended: github.com/wneessen/go-mail (actively maintained, STARTTLS + implicit TLS, multipart HTML + plaintext, and a path to XOAUTH2 for future OAuth-based providers). Pin with a SHA per the project's pinned-dependency rule.
Open Questions
Digest window: single global UTC send hour, or per-user timezone? Suggest global UTC hour for v1.
Acceptance Criteria
Admin configures SMTP in the portal, sends a test email, and receives it.
Sharing an asset/collection/prompt with a user whose prefs are "immediate" produces a branded HTML email with a working deep link, within the worker poll interval; a notifications row transitions to a sent status.
A user who sets notifications to "daily" receives one digest email per window covering that window's events, and no per-event emails.
A user who turns notifications off receives nothing, and no notifications rows are queued for them.
A thread comment/feedback event targeting a user triggers the same path.
SMTP password is never returned by any API and is stored encrypted (verified by reading the row).
Enqueue failure does not fail the originating share/comment request.
Integration test wires the real share handler -> enqueue -> worker -> a captured/mock SMTP sink and asserts an email was rendered and "sent" end to end.
Problem Statement
When a user shares an asset, collection, or prompt with another user, the recipient is never told. The share is recorded in
portal_shares.shared_with_emailand surfaces in the portal's "shared with me" view, but there is no out-of-band signal, so recipients only discover shared items if they happen to go looking. The same gap applies to thread comments and feedback (epic #600): a teammate can comment on something you own and you will not know.This ticket builds the notification substrate the platform is missing: an admin SMTP configuration surface, a per-user notification preference store, a delivery worker (immediate or daily digest), and branded HTML emails. It is the prerequisite that #627 (@mention tagging on thread comments) calls out as out of its own scope.
Goals
Verified Current State
grep -ri "smtp\|net/smtp\|sendmail"overpkg/returns nothing but data fields. This is greenfield.shared_with_emailand run throughShareStore.Insert()at three handlers:pkg/portal/handler.go(createShare, ~line 1253)pkg/portal/collection_handler.go(createCollectionShare, ~line 716)pkg/portal/prompt_handler.go(createPromptShare, ~line 635)These three Insert call sites are the share trigger points.
pkg/portal/thread_handler.go(createThread~line 210,appendThreadEvent~line 348) write toportal_threads/portal_thread_events. These are the comment/feedback trigger points.config_entries(migration000026) +pkg/configstoreis the admin-editable-config pattern, with an audit changelog and a file-mode vs database-mode gate (writes return 405 in file mode). Admin config routes live inpkg/admin/handler.go/config_handler.go.users(migration000067) is an admin-maintained directory (name, confirmed, source) only.pkg/platform/fieldcrypt.goFieldEncryptor(AES-256-GCM,NewFieldEncryptor(key []byte),EncryptSensitiveFields/DecryptSensitiveFields) already encrypts connection secrets. The SMTP password reuses this, not a new scheme.pkg/indexjobs/worker.gopolls a DB queue with atime.Tickerfallback plus LISTEN/NOTIFY wakeup and lease-based locking. The digest scheduler and the send worker follow this pattern.PortalConfig(pkg/platform/config.go~196-229) carries Title, light/dark logos, and Implementor (name/logo/url); resolved into SVGs at startup (cmd/mcp-data-platform/main.go~620-653). Email templates pull from the same source so emails match the portal.Proposed Architecture
graph TB subgraph Triggers Share[Share created<br/>handler.go / collection / prompt] Comment[Thread event<br/>thread_handler.go] end subgraph Core Enqueue[Notification enqueue] Prefs[(user_notification_prefs)] Queue[(notifications queue)] end subgraph Delivery Worker[Send worker<br/>indexjobs pattern] Digest[Daily digest scheduler] Render[Branded HTML render] SMTP[SMTP client] end subgraph Config Settings[(platform_settings<br/>SMTP, encrypted pass)] end Share --> Enqueue Comment --> Enqueue Enqueue --> Prefs Prefs -->|off| Drop[Dropped] Prefs -->|immediate| Queue Prefs -->|daily| Queue Queue --> Worker Digest --> Worker Worker --> Render --> SMTP Settings --> SMTP Settings --> RenderWorkstreams
Sized so each can be a separate PR. Suggest splitting into child issues; this ticket is the epic.
A. Admin settings surface + SMTP config
/admin/settings(general settings, expandable), with an SMTP section. Register inui/src/components/layout/Sidebar.tsxadminNavItemsandui/src/components/layout/AppShell.tsx.platform_settingstable (decided; see Decisions Made), following thepkg/user/store.gointerface+postgres pattern. Non-secret SMTP fields (host, port, from, username, TLS mode) stored plainly; password encrypted at rest viaFieldEncryptorand write-only in the UI (never returned)./api/v1/admin/...(mutable-only gated, like existing config routes). Include a "send test email" action.B. User notification preferences
user_notification_prefstable keyed by email (FK tousers.email): a global mode (off|immediate|daily) and per-category toggles (shares, comments). Default to immediate-on (per the "important features default ON" convention).pkg/notificationorpkg/user), followingpkg/user/store.go./admin.C. Notification core (enqueue + store)
notificationsqueue table (recipient email, category, payload/refs, status, attempts, scheduled_for, created_at) with a status index, following migration conventions inpkg/database/migrate/migrations/.user_notification_prefs: drop if off, queue for immediate send, or stampscheduled_forthe next digest window if daily.D. Delivery worker + daily digest
pkg/indexjobs/worker.go(poll + LISTEN/NOTIFY, lease locking, retry with backoff, heartbeat). Claimsimmediaterows and duedailyrows.E. Branded HTML email templates
PortalConfigresolved SVG/values).Portal.PublicBaseURL.F. Config + docs
platform.yaml(e.g., encryption key reference, digest send hour), add anEmailConfig/notification section inpkg/platform/config.gowith defaults inapplyDefaults, mirroring existing sections.README.md,docs/,docs/llms.txt,docs/llms-full.txt(doc-check is a blocking gate for new config/migrations/pages/endpoints).Relationship to #627
#627 (@mention tagging + notifications on thread comments) names "a notification-delivery mechanism and a per-user preferred channel setting" as a prerequisite it does not build. This ticket delivers exactly that, plus the generic "new comment/feedback" notification. #627 then becomes a thin consumer: parse
@usertokens from the comment body and enqueue a mention notification through the substrate built here. This ticket should land first; #627 builds on workstreams B, C, D, E.Decisions Made
FieldEncryptor, in a dedicated settings store; write-only in the UI.platform_settingstable (typed sections, encrypted SMTP password viaFieldEncryptor, password write-only in the UI), following thepkg/user/store.gointerface + postgres pattern. Notconfig_entries— the value shape is typed and the secret needs encryption, which the untyped key/value store would special-case.net/smtp. Recommended:github.com/wneessen/go-mail(actively maintained, STARTTLS + implicit TLS, multipart HTML + plaintext, and a path to XOAUTH2 for future OAuth-based providers). Pin with a SHA per the project's pinned-dependency rule.Open Questions
Acceptance Criteria
notificationsrow transitions to a sent status.notificationsrows are queued for them.Scope
Portal / Backend / Frontend