Skip to content

feat(notification): RFC 8058 one-click List-Unsubscribe, From-domain Message-ID, legal footer links#1020

Merged
cjimti merged 1 commit into
mainfrom
feat/1018-list-unsubscribe
Jul 22, 2026
Merged

feat(notification): RFC 8058 one-click List-Unsubscribe, From-domain Message-ID, legal footer links#1020
cjimti merged 1 commit into
mainfrom
feat/1018-list-unsubscribe

Conversation

@cjimti

@cjimti cjimti commented Jul 22, 2026

Copy link
Copy Markdown
Member

Closes #1018

Notification email now satisfies the RFC 8058 one-click unsubscribe requirement Gmail and Yahoo enforce for bulk senders, stamps Message-ID with the sending domain instead of the container hostname, and can carry operator legal links in the footer.

List-Unsubscribe / List-Unsubscribe-Post headers

Email carries the recipient's no-login unsubscribe URL (UnsubURL) whenever the message renders the footer opt-out link, and buildMessage emits the RFC 8058 header pair from it via go-mail's SetListUnsubscribeOneClick, which validates the https scheme the RFC requires and writes both headers preformatted. A non-https portal base URL advertises the plain List-Unsubscribe URI alone and drops the -Post header, so mail never ships a non-conformant one-click pair. Header presence tracks the footer link exactly: transactional sends (one-time guest view links, admin SMTP tests) set no UnsubURL and therefore carry neither footer nor headers, mirroring the existing UnsubURL presence logic in render.go.

UnsubscribeHandler now serves both methods on /portal/notifications/unsubscribe. GET keeps the branded confirmation page for a human following the footer link. POST is the RFC 8058 one-click path a mail provider calls on the recipient's behalf (body List-Unsubscribe=One-Click): it verifies the same HMAC token from the query string, records delivery mode off, and returns a bare status code with no page and no further interaction, as the RFC requires. Both routes are mounted in mountNotificationUnsubscribe.

Message-ID from the From-address domain

buildMessage previously left Message-ID generation to go-mail, which derives the right-hand side from os.Hostname(). In containerized deployments that is the pod name, producing IDs like <...@notification-worker-abc123> whose domain never resolves, which trips content-filter heuristics and leaks internal naming. The message now gets an explicit ID via SetMessageIDWithValue: a random left-hand side from crypto/rand.Text() (~130 bits) and the domain parsed from the configured From address (messageIDDomain, which handles both bare and name-addr forms). If no domain can be derived the code falls back to go-mail's default; in practice this branch is unreachable because the From address is validated earlier in buildMessage.

Operator legal links in the footer

New optional portal.terms_url and portal.privacy_url config fields thread through notification.Branding into both email templates. When set, they render as small Terms of Service / Privacy Policy footer links (with a separator only when both are present); unset fields are omitted entirely. When the portal runs on a different domain than the mail From address, these give recipients and content filters body links that associate with the sending identity.

Tests

  • Wire-level assertions (via msg.WriteTo, unfolding RFC 5322 header continuation lines): header pair present with the exact URL and fixed POST value, absent without UnsubURL, and the non-https fallback emits List-Unsubscribe without List-Unsubscribe-Post.
  • Message-ID: domain equals the From-address domain, left-hand side differs across messages; messageIDDomain table test covers bare, name-addr, and malformed inputs.
  • One-click POST: opt-out recorded and no page returned on success, 400 on a bad token, 500 on a store failure; GET behavior unchanged and still covered.
  • Renderer: Email.UnsubURL populated for notifications and empty for guest-link and test sends; legal links render with both, one, or neither URL configured, including separator presence and absence.
  • Integration: the mounted POST route is exercised end to end through the real mux in internal/httpserver.

Docs

docs/server/notifications.md (one-click flow, Message-ID behavior, legal links), docs/server/configuration.md and configs/platform.yaml (new portal fields), and both docs/llms.txt / docs/llms-full.txt.

make verify passes.

…Message-ID, legal footer links (#1018)

Notification emails that carry the footer unsubscribe link now emit
List-Unsubscribe and List-Unsubscribe-Post headers via go-mail's
SetListUnsubscribeOneClick; a non-https portal base URL advertises the
plain List-Unsubscribe URI alone rather than a non-conformant one-click
pair. The unsubscribe endpoint accepts the RFC 8058 provider POST and
records the opt-out with a bare status and no page; GET keeps the
branded confirmation page. Transactional sends (guest links, admin SMTP
tests) carry neither footer nor headers.

Message-ID is now set explicitly with a random left-hand side and the
domain of the configured From address, replacing go-mail's hostname
default that leaked pod names in containerized deployments.

New optional portal.terms_url and portal.privacy_url render as small
footer links in notification emails when set.
@cjimti
cjimti merged commit 899b882 into main Jul 22, 2026
8 checks passed
@cjimti
cjimti deleted the feat/1018-list-unsubscribe branch July 22, 2026 07:00
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.31%. Comparing base (09edf9d) to head (397bd3b).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1020      +/-   ##
==========================================
+ Coverage   89.29%   89.31%   +0.01%     
==========================================
  Files         458      458              
  Lines       50726    50757      +31     
==========================================
+ Hits        45298    45332      +34     
+ Misses       3616     3614       -2     
+ Partials     1812     1811       -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

Development

Successfully merging this pull request may close these issues.

notification: add RFC 8058 one-click List-Unsubscribe and stop deriving Message-ID from the container hostname

1 participant