Skip to content

feat(notification): confirm-before-unsubscribe, opt-out surfacing, and implementor email branding#1025

Merged
cjimti merged 2 commits into
mainfrom
feat/1022-1023-notification-followups
Jul 22, 2026
Merged

feat(notification): confirm-before-unsubscribe, opt-out surfacing, and implementor email branding#1025
cjimti merged 2 commits into
mainfrom
feat/1022-1023-notification-followups

Conversation

@cjimti

@cjimti cjimti commented Jul 22, 2026

Copy link
Copy Markdown
Member

Closes #1022, closes #1023.

Confirm-before-unsubscribe (scanner prefetch guard)

GET /portal/notifications/unsubscribe renders a confirmation page and performs no mutation: corporate mail security layers (Safe Links, Proofpoint, and similar) prefetch URLs in message bodies, and the token is a bearer credential, so a mutating GET let a recipient's own mail infrastructure silently opt them out. The opt-out now records only on POST: the confirmation page's single-button form submit, or the RFC 8058 one-click body (List-Unsubscribe=One-Click), which is unchanged and keeps its bare status codes. Invalid tokens keep their per-path handling, and the page CSP gains form-action 'self'.

Opt-out state surfacing

The admin SMTP test send deliberately bypasses preference gating, which previously made "receives test mail but never notifications" a troubleshooting mystery. A new GET /api/v1/admin/settings/smtp/recipient-status endpoint reports an address's opt-out state, and the settings page shows an informational notice next to the send action for an opted-out target. The send is never blocked.

The guest share landing page shows a notice when the share's recipient has opted out, with a "Resume notification emails" action posting to /portal/view/{token}/resubscribe. Opting back in mirrors the no-mutation-on-GET rule of the unsubscribe endpoint it reverses: rate limited alongside the other public share routes, a uniform response for every share state (no share-existence oracle), restores only the immediate-delivery default, and logs each successful flip. Since the one-time-link email is delivered only to the stored recipient address, the person holding that landing page URL is ordinarily the recipient themselves.

Implementor email branding: footer text and Reply-To

portal.about_text, portal.support_contact, and portal.reply_to join the YAML config beside terms_url and privacy_url. Branding and sending identity are implementor-owned: in fully managed deployments they are not editable from the admin settings page, which stays transport-only (host, port, credentials, TLS, From).

When set, the about/support block renders as a footer in both the HTML and plaintext parts of every mail type (notifications, one-time guest links, admin tests), giving first-contact recipients sender context and lifting short image-bearing messages out of the low-text band content filters penalize (SpamAssassin HTML_IMAGE_ONLY_*). An email support contact links as mailto:, an http(s) URL directly. reply_to is applied as the Reply-To header on every outgoing message, validated once at startup and dropped with a warning when invalid. With all three unset, rendered output and headers are byte-identical to prior behavior, pinned by template-seam tests.

pkg/admin decomposition

pkg/admin sat two lines under its TestPackageSizeBudget cap, so the SMTP settings surface moves to a new pkg/admin/settingsapi subpackage, with pkg/admin keeping a thin registration that injects the shared request helpers. The import ratchet records the two new edges (pkg/admin -> pkg/admin/settingsapi -> pkg/notification): this is the budget-sanctioned decomposition seam, not new cross-layer coupling, and the admin-is-a-leaf depguard rule now excludes pkg/admin's own tree so the leaf can compose its subpackages. One behavior alignment: a malformed PUT /api/v1/admin/settings/smtp body now returns 400 problem+json like every sibling admin handler instead of an empty 200.

Verification

make verify passes end to end (race tests, coverage and patch-coverage gates, lint, gosec, govulncheck, semgrep, CodeQL, GoReleaser dry-run). Every new function carries 85 to 100 percent coverage, the full UI suite (351 tests) and Playwright e2e pass, and swagger is regenerated. Docs updated: notifications, configuration, admin portal, portal user, threat model, and both llms files.

cjimti added 2 commits July 22, 2026 12:01
…oter text and Reply-To (#1022, #1023)

Unsubscribe endpoint: GET renders a confirmation page and performs no
mutation, so mail-scanner URL prefetch (Safe Links, Proofpoint) can no
longer silently opt recipients out; the opt-out records only on the
confirmation form POST or the unchanged RFC 8058 one-click POST.

Opt-out state surfacing: a new admin endpoint
GET /api/v1/admin/settings/smtp/recipient-status backs an informational
notice next to the test-send action when the target address has opted
out (the test still sends), and the guest share landing page shows an
opt-out notice with a deliberate POST-only "Resume notification emails"
action at /portal/view/{token}/resubscribe (uniform response, rate
limited, success audit-logged).

Sender options: reply_to, about_text, and support_contact join the
DB-backed SMTP settings, validated on save. Reply-To is applied to every
outgoing message; the help/about footer renders in the HTML and text
parts of all mail types via a Footer value threaded through the render
paths. With the options unset, rendered output and headers are
byte-identical to prior behavior, pinned by template-seam tests.

The SMTP settings surface moves to a new pkg/admin/settingsapi
subpackage: pkg/admin sat two lines under its size budget, and the
budget gate directs decomposition over squeezing. The import ratchet
records the two new edges (admin -> settingsapi -> notification), and
the admin-is-a-leaf depguard rule now excludes pkg/admin's own tree so
the leaf can compose its subpackages. A malformed PUT body now returns
400 problem+json, matching every sibling admin handler.
…act, reply_to) to portal YAML config

The three sender options added for #1023 move from the DB-backed admin
SMTP settings to the portal YAML config, beside terms_url and
privacy_url. Branding and sending identity are implementor-owned: in
fully managed deployments they must not be editable from the admin
settings page, and YAML is the implementor-controlled surface. The SMTP
settings, admin API, and settings UI return to transport-only fields.

The values flow through notification.Branding: the renderer stamps the
help/about footer into both body parts and Email.ReplyTo onto every
rendered message, and the sender emits the Reply-To header from the
Email rather than the settings. portal.reply_to is validated once at
startup, dropped with a warning when invalid, instead of failing every
send with a per-message error.
@cjimti
cjimti merged commit 7e36b97 into main Jul 22, 2026
8 checks passed
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.41270% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.34%. Comparing base (b6b75a3) to head (adc56b1).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/admin/settingsapi/settingsapi.go 96.10% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1025      +/-   ##
==========================================
+ Coverage   89.31%   89.34%   +0.03%     
==========================================
  Files         468      469       +1     
  Lines       51652    51789     +137     
==========================================
+ Hits        46132    46270     +138     
  Misses       3668     3668              
+ Partials     1852     1851       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant