feat(notifications): per-recipient mute + List-Unsubscribe (closes #297) - #828
Conversation
|
Important Review skippedReview was skipped due to path filters ⛔ Files ignored due to path filters (1)
CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds end-to-end notification muting and RFC 8058 one-click unsubscribe support through signed tokens, PostgreSQL persistence, email transport integration, public API routes, and application wiring. ChangesNotification Mute and One-Click Unsubscribe
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant EmailClient
participant Router
participant unsubscribeHandler
participant PostgresStore
EmailClient->>Router: GET or POST unsubscribe request
Router->>unsubscribeHandler: dispatch request
unsubscribeHandler->>PostgresStore: UpsertNotificationMute
PostgresStore-->>unsubscribeHandler: persistence result
unsubscribeHandler-->>EmailClient: HTML confirmation
sequenceDiagram
participant PurchaseApproval
participant MuteChecker
participant EmailSender
PurchaseApproval->>MuteChecker: check recipient and CC addresses
MuteChecker-->>PurchaseApproval: mute statuses
PurchaseApproval->>EmailSender: send filtered message with RFC 8058 headers
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai full review |
✅ Actions performedFull 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)
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
Rate Limit Exceeded
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
…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)
|
@coderabbitai full review |
✅ Action performedFull review finished. |
…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)
|
Rebased onto the current @coderabbitai review |
|
✅ Action performedReview finished.
|
…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)
…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)
|
@coderabbitai review |
✅ Action performedReview finished.
|
* feat(settings): configurable EC2 RI OfferingClass (closes #694) Add a GlobalConfig.OfferingClass field ("convertible" | "standard") that controls which EC2 Reserved Instance offering class is purchased. Empty / absent values default to "convertible" to preserve pre-694 behaviour. Backend: - pkg/common/types.go: OfferingClass field on PurchaseOptions - internal/config/types.go: OfferingClass on GlobalConfig - internal/config/store_postgres.go: read/write offering_class column ($20) - internal/purchase/execution.go: load GlobalConfig and propagate OfferingClass into PurchaseOptions before the fan-out - providers/aws/services/ec2/client.go: resolveOfferingClassType() fails loudly on unknown values (feedback_empty_string_vs_error.md); empty string maps to convertible so the DB default is safe - internal/database/postgres/migrations/000064_ec2_ri_offering_class: ADD COLUMN offering_class TEXT NOT NULL DEFAULT 'convertible' Frontend: - frontend/src/index.html: fieldset with <select> (Convertible / Standard) - frontend/src/settings.ts: load, save, and reset handlers - frontend/src/api/types.ts + types.ts: offering_class optional field Tests: - TestResolveOfferingClassType: unknown values error, "" = convertible - TestDescribeInputFromQuery_OfferingClass: empty / explicit convertible / standard - All findOfferingID call sites updated to 4-arg signature - store_postgres_pgxmock_test.go: +offering_class column in mock rows - settings.test.ts: offering_class: 'convertible' in saveGlobalSettings expected payload * fix(settings): validate OfferingClass on PUT + assert it reaches the EC2 SDK call (refs #694) - Add validateOfferingClass() to GlobalConfig.Validate() so an invalid offering_class is rejected at PUT time with a clear error rather than silently persisting and only failing at purchase time. Accepts "" | "convertible" | "standard" (case-sensitive, matching resolveOfferingClassType). - Add ValidOfferingClasses exported var for reference/documentation. - Add six table-driven test cases in TestGlobalConfig_Validate covering empty (valid), both valid values, wrong-case "Convertible", "STANDARD", and an unknown value. - Add capturingMockEC2Client embedding MockEC2Client that records the last DescribeReservedInstancesOfferingsInput to enable SDK-level assertions. - Add TestFindOfferingID_OfferingClassReachesSDKCall: three subtests ("", "convertible", "standard") each calling findOfferingID and asserting that the captured OfferingClass on the outbound SDK call equals the expected types.OfferingClassType. This test fails if the wiring from offeringClassStr through resolveOfferingClassType to describeInputFromQuery regresses. * refactor(config): extract validateScheduleAndNotifications to reduce cyclomatic complexity Validate had complexity 11 after adding the OfferingClass check in #694. Extract the CollectionSchedule + NotificationDaysBefore + GracePeriodDays checks into validateScheduleAndNotifications, keeping Validate under 10. * fix(test): avoid mutex copy in TestFindOfferingID_OfferingClassReachesSDKCall Construct capturingMockEC2Client directly instead of copying *MockEC2Client to silence the go vet copylocks warning (MockEC2Client embeds mock.Mock which contains sync.Mutex). * fix(purchase): fail EC2 RI recs on GlobalConfig load error instead of defaulting OfferingClass When GetGlobalConfig returns an error, the old code logged it and continued with an empty OfferingClass, which resolveOfferingClassType maps to "convertible". This caused a transient DB failure to silently buy the wrong (and more expensive, irreversible) RI class instead of the operator-configured "standard". Violates the no-silent-fallbacks-on-money-paths rule. Fix: change processPurchaseRecommendations to return (float64, float64, []string, error) and propagate a hard error on GetGlobalConfig failure. Both call sites (executeSingleAccount and executeForAccount) check the new error return and abort without making any cloud purchase. Regression test (HOLE 1): TestProcessPurchaseRecommendations_GlobalConfigError_FailsInsteadOfDefaulting confirms the function returns an error and never calls the provider factory when GetGlobalConfig fails. Verified red on pre-fix code, green after. Also add TestSaveGlobalConfig_OfferingClassBindsAt21 (HOLE 2): a pgxmock test against the real PostgresStore.SaveGlobalConfig verifying offering_class binds as the 21st positional arg. The hand-maintained testablePostgresStore in store_postgres_mock_test.go omits this field entirely, so no fast test previously guarded the 21-placeholder write query. * refactor(purchase): extract applyAccountOutcome to drop executeForAccount below gocyclo 10 The gocyclo pre-commit hook (-over 10) failed: executeForAccount was at cyclomatic complexity 11. Extract the per-account status-resolution switch (partially_completed / failed / completed stamping, #642) and the committed gate (#1014) into a cohesive applyAccountOutcome helper, dropping the function below the threshold without a //nolint or a threshold bump. Behavior is unchanged: same status transitions, same error-note appending, same committed semantics (anyRecPurchased is now evaluated once and reused for the partial flag). The OfferingClass typed-enum validation and no-silent-fallback behavior are untouched. * fix(test): update SQL parameter positions after laddering_enabled shift Rebasing onto main added laddering_enabled as $21 in global_config INSERT, shifting offering_class from $21 to $22. Update the pgxmock and coverage tests to reflect the new column count and binding positions. * style(purchase,config): clear new-from-rev lint on OfferingClass diff Resolve the three golangci-lint findings that --new-from-rev=origin/main attributes to the #694 OfferingClass changes: - errcheck: replace the silent `_ =` discard of SavePurchaseExecution on the processPurchaseRecommendations error path with the existing saveExecutionStatusBestEffort helper, which persists and logs the audit-save failure instead of dropping it. - gocritic unnamedResult: name the four results of processPurchaseRecommendations (its return set grew to include error); switch the trailing assignment from := to = accordingly. - misspell: pre-694 behaviour -> behavior in the ValidOfferingClasses doc. No behavior change to the purchase path; base-debt Lint/Security failures are pre-existing on main and out of scope. * chore(migration): renumber ec2_ri_offering_class from 000078 to 000083 Migration 000078 collided with PR #828. Main's highest is 000081; #1277 reserves 000082; 000083 is the next free slot for this PR. Renamed via git mv (up + down); no Go code referenced the number directly. * chore(migration): renumber ec2_ri_offering_class from 000083 to 000090 Migration 000083 is now taken by ladder_execution_enabled (merged to main while this PR was in review). Next free slot after 000089_rename_cancelled_to_canceled is 000090. * fix(rebase): restore saveExecutionStatusBestEffort; update param-23 guard test Two issues introduced when rebasing onto main (which added ladder_execution_enabled in parallel): 1. saveExecutionStatusBestEffort helper was dropped during conflict resolution of the applyAccountOutcome refactor commit; restored from the pre-rebase branch. 2. TestSaveGlobalConfig_OfferingClassBindsAt22 expected 22 args; with ladder_execution_enabled now at $22, offering_class moved to $23. Renamed the test to TestSaveGlobalConfig_OfferingClassBindsAt23 and added the $22 AnyArg placeholder.
…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)
…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)
|
@coderabbitai review |
✅ Action performedReview finished.
|
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.
…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)
…roduction sender (refs #297) The per-recipient mute + List-Unsubscribe feature was dead in production: the sender returned by email.NewSenderFromEnvironment was never decorated with the mute checker or the unsubscribe base URL, so the bare sender's mute checker was always nil and no List-Unsubscribe header was ever emitted. The SMTP transport bypassed the mute logic entirely. - app.go: decorate the factory-produced sender via a new decorateSenderWithMute helper that type-switches on *email.Sender / *email.SMTPSender and calls WithMuteChecker(configStore) + WithUnsubscribeBaseURL(trimmed DASHBOARD_URL). configStore (config.StoreInterface) satisfies email.MuteChecker via IsNotificationMuted. The base URL reuses the same DASHBOARD_URL the email templates already use, trimmed to match resolveOIDCIssuerURL. - email/mute.go: extract the transport-agnostic mute + List-Unsubscribe decision logic (isRecipientMuted, filterMutedRecipients, unsubscribeURLFor, unsubscribeHeaderValuesFor) into shared free functions so the SES and SMTP paths cannot diverge; *Sender delegates to them. - email/smtp_sender.go: add muteChecker + unsubscribeBaseURL fields and WithMuteChecker/WithUnsubscribeBaseURL mirroring *Sender, and apply the mute skip + CC filter + RFC 8058 List-Unsubscribe headers in SendPurchaseApprovalRequest. - Wiring tests exercise the real factory/decoration path (decorateSenderWithMute on a factory-shaped sender) and the SMTP transport: a muted recipient is suppressed and the List-Unsubscribe header is emitted with the wired base URL. Both fail against the pre-wiring behavior (nil checker, no base URL).
…ibe path buildSMTPMessageMultipartWithHeaders used a fixed boundary string instead of mimeRandBoundary(), unlike its sibling buildSMTPMessageMultipart. A body containing the literal boundary causes MIME corruption. Also fix two US-spelling violations (behaviour->behavior, Centralising->Centralizing) in pkg/common/tokens.go introduced by this PR.
Address CodeRabbit review findings on the per-recipient mute feature: - Fail closed on a missing mute secret. ResolveMuteSecret returns the NOTIFICATION_MUTE_SECRET bytes when set, the dev key only in non-production, and ErrMuteSecretMissing in production. DeriveMuteToken no longer silently signs with a well-known fallback on an empty key (returns ""), and VerifyMuteToken fails closed so a missing secret can never accept a forged token. The unsubscribe handler returns a 500 (not a 401) when the production secret is absent, and the send path emits no List-Unsubscribe header rather than a tokenless or forgeable one. - Suppress List-Unsubscribe when CC recipients share the envelope. The header token is bound to the primary recipient only; sending it to a CC list let any CC recipient one-click-mute the primary address. Both the SES and SMTP paths now omit the header whenever a CC list exists. - Redact the recipient email in the SMTP approval-delivery debug logs. - Correct the UpsertNotificationMute interface doc to match the implemented refresh-on-conflict semantics (muted_at = NOW()).
NewApplication no longer has a config store at startup (initConfigStore was refactored on main to return only dbConfig+secretResolver for lazy DB init). Move the decorateSenderWithMute call into reinitializeAfterConnect where pgStore is live, so the mute checker is wired once the DB connection is established rather than at cold-start with a nil store.
…ons PR Address four linter findings in the mute/unsubscribe feature: - smtp_sender: use %q instead of escaped-quote "%s" (sprintfQuotedString) - sender: remove unused mailtoURL return from buildUnsubscribeURL (unparam) - templates: remove dead sendPurchaseApprovalRequestVia function (unused) - migration: renumber 000078_muted_recipients to 000091 to resolve conflict with 000078_monthly_summary_nested_rollup already on main
|
@coderabbitai review |
✅ Action performedReview finished.
|
- accept and validate RFC 8058 POST requests using signed query tuples - apply RI exchange mute scopes and unsubscribe headers in SES and SMTP - require an explicit notification mute secret in every environment
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/handler_notifications.go`:
- Around line 86-127: Update internal/api/handler_notifications.go:86-127 in
unsubscribeHandler to branch on the validated HTTP method, allowing
UpsertNotificationMute and the unsubscribed response only for POST while
rendering a non-mutating confirmation form for GET; update
internal/api/handler_notifications_test.go:28-55 to assert GET does not mutate
and add GET coverage while preserving POST mutation assertions; make no change
to internal/api/router.go:323-327, since its shared GET/POST routing remains
valid.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 98c3be3e-6354-4266-84fb-afb01d852a62
📒 Files selected for processing (8)
.env.exampleinternal/analytics/collector_test.gointernal/api/handler_notifications.gointernal/api/handler_notifications_test.gointernal/api/middleware.gointernal/api/router.gointernal/config/interfaces.gointernal/config/store_postgres.go
🚧 Files skipped from review as they are similar to previous changes (3)
- internal/config/interfaces.go
- internal/api/middleware.go
- internal/analytics/collector_test.go
| func (h *Handler) unsubscribeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) { | ||
| if err := validateOneClickUnsubscribeBody(req); err != nil { | ||
| return nil, err | ||
| } | ||
| token, email, scope, err := unsubscribeRequestParams(req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Resolve the HMAC key with the fail-closed policy: a missing | ||
| // NOTIFICATION_MUTE_SECRET is a server misconfiguration, not a client error, | ||
| // and must never silently verify against a well-known key. | ||
| key, err := common.ResolveMuteSecret() | ||
| if err != nil { | ||
| logging.Errorf("notifications/unsubscribe: %v", err) | ||
| return nil, fmt.Errorf("unsubscribe is not configured: %w", err) | ||
| } | ||
| if !common.VerifyMuteToken(key, email, scope, token) { | ||
| logging.Warnf("notifications/unsubscribe: invalid token for scope=%s", scope) | ||
| return nil, NewClientError(401, "invalid or expired unsubscribe token") | ||
| } | ||
|
|
||
| if err := h.config.UpsertNotificationMute(ctx, email, scope, token); err != nil { | ||
| logging.Errorf("notifications/unsubscribe: store error: %v", err) | ||
| return nil, fmt.Errorf("could not save unsubscribe preference: %w", err) | ||
| } | ||
|
|
||
| logging.Infof("notifications/unsubscribe: muted scope=%s for %s", scope, redactEmailLocal(email)) | ||
|
|
||
| var buf strings.Builder | ||
| if err := unsubscribeConfirmTmpl.Execute(&buf, struct{ ScopeLabel string }{ | ||
| ScopeLabel: scopeLabel(scope), | ||
| }); err != nil { | ||
| return nil, fmt.Errorf("render unsubscribe page: %w", err) | ||
| } | ||
|
|
||
| return &rawResponse{ | ||
| contentType: "text/html; charset=utf-8", | ||
| body: buf.String(), | ||
| csp: mutePageCSP, | ||
| }, nil | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
GET-triggers-mutation bug spans handler, routing, and its own test coverage. The root cause is in unsubscribeHandler, which mutates unconditionally regardless of HTTP method; router.go routes both verbs to it unchanged (no fix needed there), and the test suite currently asserts the buggy behavior as correct.
internal/api/handler_notifications.go#L86-L127: gateh.config.UpsertNotificationMute(and the "you have been unsubscribed" response) behind a validated POST; render a non-mutating confirmation/landing page for GET.internal/api/router.go#L323-L327: no change required — once the handler branches on method internally, the existing GET+POST routing to the same handler is fine.internal/api/handler_notifications_test.go#L28-L55: updateTestUnsubscribeHandler_Success(and add a GET-specific test) to assert GET does not callUpsertNotificationMuteand instead returns a confirmation form; keep the existing POST-based mutation assertions inTestRouter_UnsubscribePOSTRoute_MutesSignedRecipientAndScope.
📍 Affects 3 files
internal/api/handler_notifications.go#L86-L127(this comment)internal/api/router.go#L323-L327internal/api/handler_notifications_test.go#L28-L55
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/api/handler_notifications.go` around lines 86 - 127, Update
internal/api/handler_notifications.go:86-127 in unsubscribeHandler to branch on
the validated HTTP method, allowing UpsertNotificationMute and the unsubscribed
response only for POST while rendering a non-mutating confirmation form for GET;
update internal/api/handler_notifications_test.go:28-55 to assert GET does not
mutate and add GET coverage while preserving POST mutation assertions; make no
change to internal/api/router.go:323-327, since its shared GET/POST routing
remains valid.
… unnamedResult golangci v2.10.1 flagged two unused methods on *Sender (isMuted, filterMutedAddresses) and an unnamedResult in renderRIExchangePendingApproval. The two methods were thin wrappers around package-level functions that are already used directly; removing them avoids the dead code. The return tuple is now named (textBody, htmlBody string, err error) and the first assignment changes from := to = accordingly.
|
Rebased onto main and pushed a fix commit for the three golangci-lint v2.10.1 failures:
All 370 email tests pass locally. CI watch armed. @coderabbitai review |
|
✅ Action performedReview finished.
|
npm audit flagged brace-expansion <1.1.16 as high severity DoS. This was disclosed 2026-07-21 and affects all PR branches equally. Lockfile-only change; no production API surface altered.
Summary
muted_recipientstable keyed on(recipient_email, scope)) for per-recipient, per-scope opt-out persistence.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).List-Unsubscribe/List-Unsubscribe-Postheaders intoSendPurchaseApprovalRequest; the SES send is skipped silently when the recipient is muted, and the headers are attached whenunsubscribeBaseURLis configured.Design notes
HMAC-SHA256(NOTIFICATION_MUTE_SECRET, lower(email)+"|"+scope)inpkg/common.DeriveMuteToken; compared withhmac.Equal(constant time). Dev/test fallback key used when env var is unset.purchase_approvals/ri_exchange_approvals).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 pageTestUnsubscribeHandler_ForgedToken_Returns401- bad token rejectedTestSendPurchaseApprovalRequest_MutedRecipient_NoSESCall- muted address skips SES callTestSendPurchaseApprovalRequest_NotMuted_SendsEmail- normal path unchangedTestSendPurchaseApprovalRequest_MuteCheckError_FailOpen- DB error doesn't block sendList-Unsubscribecarries angle-bracketed HTTPS URL;List-Unsubscribe-Post: List-Unsubscribe=One-Clickpresent when base URL configuredSummary by CodeRabbit
NOTIFICATION_MUTE_SECRETfor generating signed one-click unsubscribe links.