feat(notification): confirm-before-unsubscribe, opt-out surfacing, and implementor email branding#1025
Merged
Merged
Conversation
…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.
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1022, closes #1023.
Confirm-before-unsubscribe (scanner prefetch guard)
GET /portal/notifications/unsubscriberenders 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 gainsform-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-statusendpoint 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, andportal.reply_tojoin the YAML config besideterms_urlandprivacy_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 asmailto:, an http(s) URL directly.reply_tois 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
TestPackageSizeBudgetcap, so the SMTP settings surface moves to a newpkg/admin/settingsapisubpackage, 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 theadmin-is-a-leafdepguard rule now excludes pkg/admin's own tree so the leaf can compose its subpackages. One behavior alignment: a malformedPUT /api/v1/admin/settings/smtpbody now returns 400 problem+json like every sibling admin handler instead of an empty 200.Verification
make verifypasses 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.