Skip to content

feat(notifications): per-recipient mute + List-Unsubscribe (closes #297)#828

Open
cristim wants to merge 2 commits into
feat/multicloud-web-frontendfrom
fix/297-wave9
Open

feat(notifications): per-recipient mute + List-Unsubscribe (closes #297)#828
cristim wants to merge 2 commits into
feat/multicloud-web-frontendfrom
fix/297-wave9

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 28, 2026

Summary

  • Adds migration 000061 (muted_recipients table keyed on (recipient_email, scope)) for per-recipient, per-scope opt-out persistence.
  • Adds GET /api/notifications/unsubscribe?token=...&email=...&scope=... (AuthPublic) that verifies an HMAC-SHA256 signed token, upserts the mute row, and returns a minimal HTML confirmation page (RFC 8058 one-click flow).
  • Wires mute checking and List-Unsubscribe / List-Unsubscribe-Post headers into SendPurchaseApprovalRequest; the SES send is skipped silently when the recipient is muted, and the headers are attached when unsubscribeBaseURL is configured.

Design notes

  • Token mechanism: HMAC-SHA256(NOTIFICATION_MUTE_SECRET, lower(email)+"|"+scope) in pkg/common.DeriveMuteToken; compared with hmac.Equal (constant time). Dev/test fallback key used when env var is unset.
  • Mute is fail-open: a DB error during the mute check is logged and the email is sent, so a transient outage never blocks approval notifications.
  • Scope whitelist enforced at the handler boundary (purchase_approvals / ri_exchange_approvals).
  • PII: emails are logged only as us***@example.com (redacted).

Test plan

  • go test github.com/LeanerCloud/CUDly/internal/email/... github.com/LeanerCloud/CUDly/internal/api/... github.com/LeanerCloud/CUDly/pkg/common/... passes (1867 tests)
  • TestUnsubscribeHandler_Success - valid signed token stores mute row, returns HTML page
  • TestUnsubscribeHandler_ForgedToken_Returns401 - bad token rejected
  • TestSendPurchaseApprovalRequest_MutedRecipient_NoSESCall - muted address skips SES call
  • TestSendPurchaseApprovalRequest_NotMuted_SendsEmail - normal path unchanged
  • TestSendPurchaseApprovalRequest_MuteCheckError_FailOpen - DB error doesn't block send
  • RFC 8058 compliance: List-Unsubscribe carries angle-bracketed HTTPS URL; List-Unsubscribe-Post: List-Unsubscribe=One-Click present when base URL configured

Migration 000061 adds muted_recipients table keyed on (email, scope).
GET /api/notifications/unsubscribe verifies an HMAC-signed token and
upserts the mute row (AuthPublic, mirrors approve/cancel pattern).
SendPurchaseApprovalRequest checks the mute table before each send and
attaches List-Unsubscribe + List-Unsubscribe-Post headers (RFC 8058)
when an unsubscribe base URL is configured. Token signing uses
HMAC-SHA256 over (lower(email)|scope) via NOTIFICATION_MUTE_SECRET.
@cristim cristim added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/few Limited audience effort/m Days type/feat New capability labels May 28, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 33 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a9063678-47b8-4b27-b6d2-0e3b281e0f45

📥 Commits

Reviewing files that changed from the base of the PR and between 4956d66 and 958203b.

📒 Files selected for processing (20)
  • internal/analytics/collector_test.go
  • internal/api/handler_notifications.go
  • internal/api/handler_notifications_test.go
  • internal/api/middleware.go
  • internal/api/mocks_test.go
  • internal/api/router.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/database/postgres/migrations/000061_muted_recipients.down.sql
  • internal/database/postgres/migrations/000061_muted_recipients.up.sql
  • internal/email/interfaces.go
  • internal/email/mute_test.go
  • internal/email/sender.go
  • internal/email/templates.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_overrides_test.go
  • internal/scheduler/scheduler_suppressions_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/test_helpers_test.go
  • pkg/common/tokens.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/297-wave9

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 28, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 30, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

✅ Actions performed

Full review triggered.

…828)

UpsertNotificationMute and IsNotificationMuted were added to
config.StoreInterface (issue #297) but the stub implementations in five
test files were not updated, causing go vet to fail with interface
satisfaction errors.

Affected mocks:
- internal/analytics/collector_test.go (mockConfigStore)
- internal/purchase/mocks_test.go (MockConfigStore)
- internal/scheduler/scheduler_overrides_test.go (mockOverrideStore)
- internal/scheduler/scheduler_suppressions_test.go (mockSuppressionStore)
- internal/scheduler/scheduler_test.go (MockConfigStore)
- internal/server/test_helpers_test.go (mockConfigStoreForHealth)
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Jun 1, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

Labels

effort/m Days impact/few Limited audience priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant