diff --git a/.env.example b/.env.example index 8cea9a216..196f90174 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,9 @@ ADMIN_PASSWORD_SECRET=ADMIN_PASSWORD_DEV ADMIN_PASSWORD_DEV=LocalDev!Pass123 API_KEY_SECRET_ARN=ADMIN_API_KEY_DEV ADMIN_API_KEY_DEV=cudly-local-dev-api-key-not-for-prod +# Required for signed one-click notification unsubscribe links. Generate a +# unique value for every environment (for example: openssl rand -hex 32). +NOTIFICATION_MUTE_SECRET= # Production examples (override SECRET_PROVIDER and these): # ADMIN_PASSWORD_SECRET=arn:aws:secretsmanager:us-east-1:000000000000:secret:cudly-admin-password-PLACEHOLDER # API_KEY_SECRET_ARN=arn:aws:secretsmanager:us-east-1:000000000000:secret:cudly-api-key-PLACEHOLDER diff --git a/frontend/package-lock.json b/frontend/package-lock.json index adbfcc9e4..2064f1da4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,38 +8,38 @@ "name": "cudly-frontend", "version": "1.0.0", "dependencies": { - "@types/qrcode": "^1.5.6", - "chart.js": "^4.4.0", - "qrcode": "^1.5.4" + "@types/qrcode": "1.5.6", + "chart.js": "4.5.1", + "qrcode": "1.5.4" }, "devDependencies": { - "@babel/core": "^7.23.0", - "@babel/preset-env": "^7.23.0", - "@babel/preset-typescript": "^7.23.0", - "@testing-library/dom": "^9.3.0", - "@testing-library/jest-dom": "^6.1.0", - "@types/chart.js": "^2.9.41", - "@types/jest": "^29.5.0", - "@types/jsdom": "^21.1.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@ungap/structured-clone": "^1.3.1", - "babel-loader": "^9.1.0", - "copy-webpack-plugin": "^14.0.0", - "css-loader": "^6.8.0", - "css-minimizer-webpack-plugin": "^8.0.0", - "eslint": "^8.50.0", - "html-webpack-plugin": "^5.5.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jsdom": "^22.1.0", - "mini-css-extract-plugin": "^2.7.0", - "style-loader": "^3.3.0", - "ts-jest": "^29.1.0", - "ts-loader": "^9.5.0", - "typescript": "^5.3.0", - "webpack": "^5.88.0", - "webpack-cli": "^5.1.0" + "@babel/core": "7.29.7", + "@babel/preset-env": "7.28.5", + "@babel/preset-typescript": "7.28.5", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.9.1", + "@types/chart.js": "2.9.41", + "@types/jest": "29.5.14", + "@types/jsdom": "21.1.7", + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@ungap/structured-clone": "1.3.1", + "babel-loader": "9.2.1", + "copy-webpack-plugin": "14.0.0", + "css-loader": "6.11.0", + "css-minimizer-webpack-plugin": "8.0.0", + "eslint": "8.57.1", + "html-webpack-plugin": "5.6.5", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "jsdom": "22.1.0", + "mini-css-extract-plugin": "2.9.4", + "style-loader": "3.3.4", + "ts-jest": "29.4.6", + "ts-loader": "9.5.4", + "typescript": "5.9.3", + "webpack": "5.108.3", + "webpack-cli": "5.1.4" } }, "node_modules/@adobe/css-tools": { @@ -3837,9 +3837,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index a988e7a87..25e9f34db 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -429,6 +429,12 @@ func (m *mockConfigStore) GetRIUtilizationCache(_ context.Context, _ string, _ i func (m *mockConfigStore) UpsertRIUtilizationCache(_ context.Context, _ string, _ int, _ []byte, _ time.Time) error { return nil } +func (m *mockConfigStore) UpsertNotificationMute(_ context.Context, _, _, _ string) error { + return nil +} +func (m *mockConfigStore) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) { + return false, nil +} func (m *mockConfigStore) UpdatePurchaseHistoryListing(_ context.Context, _, _, _ string) error { return nil diff --git a/internal/api/handler_notifications.go b/internal/api/handler_notifications.go new file mode 100644 index 000000000..1352d3f5e --- /dev/null +++ b/internal/api/handler_notifications.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "fmt" + "html/template" + "net/url" + "strings" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/logging" + "github.com/aws/aws-lambda-go/events" +) + +// mutePageCSP is the Content-Security-Policy for the unsubscribe confirmation +// page. The page is intentionally minimal (no external scripts or styles), so +// we can lock it down tightly. +const mutePageCSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'" + +// unsubscribeConfirmTmpl is the HTML confirmation page rendered after a +// successful one-click unsubscribe. No user-supplied data is interpolated via +// {{.}} without html/template escaping, so there is no XSS vector. +var unsubscribeConfirmTmpl = template.Must(template.New("unsub").Parse(` + +
+ + +You will no longer receive {{.ScopeLabel}} emails at this address.
+This preference is saved. You do not need to click again.
+ + +`)) + +// scopeLabel returns a human-readable label for a notification scope. +func scopeLabel(scope string) string { + switch scope { + case string(common.ScopePurchaseApprovals): + return "purchase approval request" + case string(common.ScopeRIExchangeApprovals): + return "RI exchange approval request" + default: + return "notification" + } +} + +func validateOneClickUnsubscribeBody(req *events.LambdaFunctionURLRequest) error { + if req.RequestContext.HTTP.Method != "POST" { + return nil + } + values, err := url.ParseQuery(req.Body) + if err != nil || len(values) != 1 || len(values["List-Unsubscribe"]) != 1 || + values.Get("List-Unsubscribe") != "One-Click" { + return NewClientError(400, "invalid one-click unsubscribe body") + } + return nil +} + +func unsubscribeRequestParams(req *events.LambdaFunctionURLRequest) (token, email, scope string, err error) { + token = req.QueryStringParameters["token"] + email = req.QueryStringParameters["email"] + scope = req.QueryStringParameters["scope"] + if token == "" || email == "" || scope == "" { + return "", "", "", NewClientError(400, "token, email and scope are required") + } + if scope != string(common.ScopePurchaseApprovals) && scope != string(common.ScopeRIExchangeApprovals) { + return "", "", "", NewClientError(400, fmt.Sprintf("unknown notification scope: %s", scope)) + } + return token, email, scope, nil +} + +// unsubscribeHandler handles GET and RFC 8058 POST requests to +// /api/notifications/unsubscribe. +// The URL carries a signed token that encodes (email, scope); the handler +// verifies the HMAC, upserts the mute row, and returns a confirmation page. +// +// Auth: AuthPublic (token-based, no login required — mirrors approve/cancel). +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 +} + +// redactEmailLocal returns just the domain part with the local masked, e.g. +// "us***@example.com". Reuses the same masking logic as email/sender.go but +// without importing that package into api (avoids a dependency cycle). +func redactEmailLocal(email string) string { + at := strings.LastIndex(email, "@") + if at < 0 { + return "***" + } + local := email[:at] + domain := email[at:] // includes '@' + if len(local) <= 2 { + return "***" + domain + } + return local[:2] + "***" + domain +} diff --git a/internal/api/handler_notifications_test.go b/internal/api/handler_notifications_test.go new file mode 100644 index 000000000..aaa524fa1 --- /dev/null +++ b/internal/api/handler_notifications_test.go @@ -0,0 +1,300 @@ +package api + +import ( + "context" + "strings" + "testing" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// GET /api/notifications/unsubscribe +// --------------------------------------------------------------------------- + +func validUnsubToken(t *testing.T, email, scope string) string { + t.Helper() + t.Setenv("NOTIFICATION_MUTE_SECRET", "handler-notifications-test-secret") + // Resolve the key the same way the handler does so the generated token verifies. + key, err := common.ResolveMuteSecret() + require.NoError(t, err) + return common.DeriveMuteToken(key, email, scope) +} + +func TestUnsubscribeHandler_Success(t *testing.T) { + ctx := context.Background() + email := "user@example.com" + scope := string(common.ScopePurchaseApprovals) + token := validUnsubToken(t, email, scope) + + mockStore := new(MockConfigStore) + mockStore.On("UpsertNotificationMute", ctx, email, scope, token).Return(nil) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + h := &Handler{config: mockStore} + r := newTestRouter(h) + + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + "token": token, + "email": email, + "scope": scope, + }, + } + result, err := r.unsubscribeHandler(ctx, req, nil) + require.NoError(t, err) + raw, ok := result.(*rawResponse) + require.True(t, ok, "expected *rawResponse") + assert.Equal(t, "text/html; charset=utf-8", raw.contentType) + assert.Contains(t, raw.body, "Unsubscribed") + assert.Contains(t, raw.body, "purchase approval request") +} + +func TestUnsubscribeHandler_POSTRejectsInvalidOneClickBody(t *testing.T) { + ctx := context.Background() + email := "user@example.com" + scope := string(common.ScopePurchaseApprovals) + token := validUnsubToken(t, email, scope) + + mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertNotCalled(t, "UpsertNotificationMute") }) + h := &Handler{config: mockStore} + + req := &events.LambdaFunctionURLRequest{ + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "POST"}, + }, + QueryStringParameters: map[string]string{ + "token": token, + "email": email, + "scope": scope, + }, + Body: "List-Unsubscribe=Not-One-Click", + } + _, err := h.unsubscribeHandler(ctx, req, nil) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 400, ce.code) +} + +func TestUnsubscribeHandler_ForgedToken_Returns401(t *testing.T) { + t.Setenv("NOTIFICATION_MUTE_SECRET", "handler-forged-token-test-secret") + ctx := context.Background() + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + "token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "email": "attacker@example.com", + "scope": string(common.ScopePurchaseApprovals), + }, + } + h := &Handler{config: new(MockConfigStore)} + r := newTestRouter(h) + + _, err := r.unsubscribeHandler(ctx, req, nil) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 401, ce.code) +} + +func TestUnsubscribeHandler_MissingSecret_FailsClosed(t *testing.T) { + // A missing NOTIFICATION_MUTE_SECRET must fail closed in every environment. + // It returns a server-side error (500), never a 401/200, and never reaches + // the store. + t.Setenv("NOTIFICATION_MUTE_SECRET", "") + ctx := context.Background() + + mockStore := new(MockConfigStore) + t.Cleanup(func() { + mockStore.AssertNotCalled(t, "UpsertNotificationMute") + }) + h := &Handler{config: mockStore} + r := newTestRouter(h) + + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + "token": strings.Repeat("a", 64), + "email": "user@example.com", + "scope": string(common.ScopePurchaseApprovals), + }, + } + _, err := r.unsubscribeHandler(ctx, req, nil) + require.Error(t, err) + _, isClient := IsClientError(err) + assert.False(t, isClient, "missing secret in production is a 500, not a client error") +} + +func TestUnsubscribeHandler_MissingParams_Returns400(t *testing.T) { + ctx := context.Background() + h := &Handler{config: new(MockConfigStore)} + r := newTestRouter(h) + + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + "token": "something", + // email and scope missing + }, + } + _, err := r.unsubscribeHandler(ctx, req, nil) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 400, ce.code) +} + +func TestUnsubscribeHandler_UnknownScope_Returns400(t *testing.T) { + ctx := context.Background() + email := "user@example.com" + scope := "unknown_scope" + + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + // The scope guard fires before token verification, so any non-empty + // token reaches it; the value is irrelevant to this test. + "token": strings.Repeat("a", 64), + "email": email, + "scope": scope, + }, + } + h := &Handler{config: new(MockConfigStore)} + r := newTestRouter(h) + + _, err := r.unsubscribeHandler(ctx, req, nil) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 400, ce.code) +} + +func TestUnsubscribeHandler_StoreError_Returns500(t *testing.T) { + ctx := context.Background() + email := "user@example.com" + scope := string(common.ScopePurchaseApprovals) + token := validUnsubToken(t, email, scope) + + mockStore := new(MockConfigStore) + mockStore.On("UpsertNotificationMute", mock.Anything, email, scope, token). + Return(assert.AnError) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + h := &Handler{config: mockStore} + r := newTestRouter(h) + + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + "token": token, + "email": email, + "scope": scope, + }, + } + _, err := r.unsubscribeHandler(ctx, req, nil) + require.Error(t, err) + _, isClient := IsClientError(err) + assert.False(t, isClient, "store error should be a 500, not a client error") +} + +// --------------------------------------------------------------------------- +// scopeLabel helper +// --------------------------------------------------------------------------- + +func TestScopeLabel_KnownScopes(t *testing.T) { + assert.Equal(t, "purchase approval request", scopeLabel(string(common.ScopePurchaseApprovals))) + assert.Equal(t, "RI exchange approval request", scopeLabel(string(common.ScopeRIExchangeApprovals))) + assert.Equal(t, "notification", scopeLabel("bogus")) +} + +// --------------------------------------------------------------------------- +// redactEmailLocal +// --------------------------------------------------------------------------- + +func TestRedactEmailLocal(t *testing.T) { + cases := []struct { + in, want string + }{ + {"user@example.com", "us***@example.com"}, + {"ab@x.com", "***@x.com"}, + {"a@x.com", "***@x.com"}, + {"noemail", "***"}, + } + for _, c := range cases { + assert.Equal(t, c.want, redactEmailLocal(c.in), "input: %s", c.in) + } +} + +// --------------------------------------------------------------------------- +// isPublicEndpoint includes /api/notifications/unsubscribe +// --------------------------------------------------------------------------- + +func TestIsPublicEndpoint_UnsubscribePath(t *testing.T) { + h := &Handler{} + assert.True(t, h.isPublicEndpoint("/api/notifications/unsubscribe")) + assert.True(t, h.isPublicEndpoint("/api/notifications/unsubscribe?token=x&email=y&scope=z")) +} + +// --------------------------------------------------------------------------- +// Route is registered (router smoke test) +// --------------------------------------------------------------------------- + +func TestRouter_UnsubscribeRoute_Registered(t *testing.T) { + t.Setenv("NOTIFICATION_MUTE_SECRET", "router-unsubscribe-test-secret") + // Verify the route is wired: an unsigned token returns 401, which can only + // happen if the router dispatched to the correct handler. + ctx := context.Background() + h := &Handler{config: new(MockConfigStore)} + r := NewRouter(h) + + req := &events.LambdaFunctionURLRequest{ + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{ + Method: "GET", + Path: "/api/notifications/unsubscribe", + }, + }, + QueryStringParameters: map[string]string{ + "token": strings.Repeat("a", 64), + "email": "user@example.com", + "scope": string(common.ScopePurchaseApprovals), + }, + } + _, err := r.Route(ctx, "GET", "/api/notifications/unsubscribe", req) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 401, ce.code) +} + +func TestRouter_UnsubscribePOSTRoute_MutesSignedRecipientAndScope(t *testing.T) { + ctx := context.Background() + email := "ri-approver@example.com" + scope := string(common.ScopeRIExchangeApprovals) + token := validUnsubToken(t, email, scope) + + mockStore := new(MockConfigStore) + mockStore.On("UpsertNotificationMute", ctx, email, scope, token).Return(nil).Once() + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + r := NewRouter(&Handler{config: mockStore}) + + req := &events.LambdaFunctionURLRequest{ + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{ + Method: "POST", + Path: "/api/notifications/unsubscribe", + }, + }, + QueryStringParameters: map[string]string{ + "token": token, + "email": email, + "scope": scope, + }, + Body: "List-Unsubscribe=One-Click", + } + result, err := r.Route(ctx, "POST", "/api/notifications/unsubscribe", req) + require.NoError(t, err) + _, ok := result.(*rawResponse) + require.True(t, ok) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index ee28a9d3a..b170d9003 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -31,6 +31,7 @@ func (h *Handler) isPublicEndpoint(path string) bool { "/api/auth/forgot-password", "/api/auth/reset-password", "/api/register/", // GET /api/register/:token (trailing slash avoids matching /api/registrations) + "/api/notifications/unsubscribe", "/docs", "/api/docs", } diff --git a/internal/api/router.go b/internal/api/router.go index 81e5c5ffc..22455cd61 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -320,6 +320,11 @@ func (r *Router) registerRoutes() { {ExactPath: "/api/ladder/configs", Method: "GET", Handler: r.getLadderConfigsHandler, Auth: AuthUser}, {ExactPath: "/api/ladder/configs", Method: "PUT", Handler: r.upsertLadderConfigHandler, Auth: AuthUser}, + // Notification one-click unsubscribe (RFC 8058). AuthPublic: the signed + // token in the query string is the credential (mirrors approve/cancel). + {ExactPath: "/api/notifications/unsubscribe", Method: "GET", Handler: r.unsubscribeHandler, Auth: AuthPublic}, + {ExactPath: "/api/notifications/unsubscribe", Method: "POST", Handler: r.unsubscribeHandler, Auth: AuthPublic}, + // Account self-registration (public, called by Terraform during federation IaC apply) {ExactPath: "/api/register", Method: "POST", Handler: r.submitRegistrationHandler, Auth: AuthPublic}, {PathPrefix: "/api/register/", Method: "GET", Handler: r.getRegistrationStatusHandler, Auth: AuthPublic}, @@ -962,3 +967,7 @@ func (r *Router) getLadderConfigsHandler(ctx context.Context, req *events.Lambda func (r *Router) upsertLadderConfigHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) { return r.h.upsertLadderConfig(ctx, req) } + +func (r *Router) unsubscribeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + return r.h.unsubscribeHandler(ctx, req, params) +} diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index f2f726336..bd7bc3317 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -436,4 +436,13 @@ type StoreInterface interface { SaveLadderTranches(ctx context.Context, tranches []LadderTrancheDB) error LatestLadderRunStartedAt(ctx context.Context, configID string) (*time.Time, error) TransitionLadderRunStatus(ctx context.Context, id string, fromStatuses []ladder.RunStatus, toStatus ladder.RunStatus) (*LadderRunDB, error) + + // Notification mutes (issue #297 / migration 000091). + // UpsertNotificationMute inserts or updates a mute row for (email, scope). + // Idempotent for row existence: calling it again for an already-muted + // address refreshes muted_at and replaces unmute_token if the token changes. + UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error + // IsNotificationMuted returns true when (email, scope) has a row in + // muted_recipients. The email comparison is case-insensitive. + IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error) } diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index d7cbfbfd8..7412b0353 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -3457,3 +3457,40 @@ func nullPtrFromNullString(ns sql.NullString) *string { s := ns.String return &s } + +// ========================================== +// NOTIFICATION MUTES (issue #297) +// ========================================== + +// UpsertNotificationMute inserts or replaces the mute row for (recipientEmail, +// scope). ON CONFLICT updates muted_at and unmute_token so a repeated +// one-click opt-out resets the audit timestamp without error. +func (s *PostgresStore) UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO muted_recipients (recipient_email, scope, muted_at, unmute_token) + VALUES (LOWER($1), $2, NOW(), $3) + ON CONFLICT (recipient_email, scope) + DO UPDATE SET muted_at = NOW(), unmute_token = EXCLUDED.unmute_token + `, recipientEmail, scope, unmuteToken) + if err != nil { + return fmt.Errorf("upsert notification mute: %w", err) + } + return nil +} + +// IsNotificationMuted returns true when (email, scope) has a matching row +// in muted_recipients. The email lookup is case-insensitive (LOWER on insert +// plus LOWER($1) here). +func (s *PostgresStore) IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error) { + var exists bool + err := s.db.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM muted_recipients + WHERE recipient_email = LOWER($1) AND scope = $2 + ) + `, recipientEmail, scope).Scan(&exists) + if err != nil { + return false, fmt.Errorf("check notification mute: %w", err) + } + return exists, nil +} diff --git a/internal/database/postgres/migrations/000091_muted_recipients.down.sql b/internal/database/postgres/migrations/000091_muted_recipients.down.sql new file mode 100644 index 000000000..2c582be69 --- /dev/null +++ b/internal/database/postgres/migrations/000091_muted_recipients.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS muted_recipients; diff --git a/internal/database/postgres/migrations/000091_muted_recipients.up.sql b/internal/database/postgres/migrations/000091_muted_recipients.up.sql new file mode 100644 index 000000000..41d398a07 --- /dev/null +++ b/internal/database/postgres/migrations/000091_muted_recipients.up.sql @@ -0,0 +1,20 @@ +-- Per-recipient notification mute table (issue #297). +-- +-- A row here means the named recipient has opted out of a notification +-- scope via the List-Unsubscribe one-click link. The send path consults +-- this table before adding any address to To/Cc; muted addresses are +-- silently skipped. The mute is per-scope so opting out of +-- "purchase_approvals" does NOT suppress "ri_exchange_approvals". +-- +-- unmute_token is the HMAC-signed token embedded in the unsubscribe URL +-- and is stored here only for auditability / idempotency (the handler +-- derives the same token on every request and compares in constant time; +-- storing it does NOT make the endpoint stateful in the sense of +-- single-use tokens). +CREATE TABLE IF NOT EXISTS muted_recipients ( + recipient_email TEXT NOT NULL, + scope TEXT NOT NULL, + muted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + unmute_token TEXT NOT NULL, + CONSTRAINT muted_recipients_pkey PRIMARY KEY (recipient_email, scope) +); diff --git a/internal/email/interfaces.go b/internal/email/interfaces.go index 2fb1eae3e..08438fdb8 100644 --- a/internal/email/interfaces.go +++ b/internal/email/interfaces.go @@ -54,6 +54,13 @@ type SESEmailSender interface { CreateEmailIdentity(ctx context.Context, params *sesv2.CreateEmailIdentityInput, optFns ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) } +// MuteChecker is a narrow interface the send path uses to consult the +// muted_recipients table. Isolating it from the full config.StoreInterface +// keeps the email package free of a direct dependency on the config package. +type MuteChecker interface { + IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error) +} + // Ensure concrete types implement interfaces. var _ SNSPublisher = (*sns.Client)(nil) var _ SESEmailSender = (*sesv2.Client)(nil) diff --git a/internal/email/mute.go b/internal/email/mute.go new file mode 100644 index 000000000..34ca20343 --- /dev/null +++ b/internal/email/mute.go @@ -0,0 +1,91 @@ +package email + +import ( + "context" + "net/url" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/logging" +) + +// This file holds the transport-agnostic mute + List-Unsubscribe logic shared +// by the SES (*Sender) and SMTP (*SMTPSender) paths. Both transports must apply +// the same per-recipient mute suppression, CC filtering, and RFC 8058 +// List-Unsubscribe headers; keeping the logic here (instead of duplicating it +// per transport) prevents the two paths from diverging. + +// isRecipientMuted returns true when (email, scope) is muted. A nil checker or a +// store error is treated as "not muted" (fail-open) so a transient DB outage +// does not silently block approval emails. +func isRecipientMuted(ctx context.Context, mc MuteChecker, email, scope string) bool { + if mc == nil { + return false + } + muted, err := mc.IsNotificationMuted(ctx, email, scope) + if err != nil { + logging.Warnf("email: mute check failed for scope=%s: %v", scope, err) + return false + } + return muted +} + +// filterMutedRecipients returns a copy of addrs with any address muted for scope +// removed. The original slice is not modified. Errors from the mute store are +// treated as "not muted" (fail-open). +func filterMutedRecipients(ctx context.Context, mc MuteChecker, addrs []string, scope string) []string { + if mc == nil || len(addrs) == 0 { + return addrs + } + out := make([]string, 0, len(addrs)) + for _, addr := range addrs { + if !isRecipientMuted(ctx, mc, addr, scope) { + out = append(out, addr) + } + } + return out +} + +// prepareMuteAwareDelivery applies the shared approval-email mute policy and +// returns the filtered CC list plus RFC 8058 header values. Headers are omitted +// for shared envelopes because their token is bound to the primary recipient. +func prepareMuteAwareDelivery(ctx context.Context, mc MuteChecker, baseURL, recipient string, cc []string, scope string) (filteredCC []string, headerValue, postValue string, muted bool) { + if isRecipientMuted(ctx, mc, recipient, scope) { + return nil, "", "", true + } + filteredCC = filterMutedRecipients(ctx, mc, cc, scope) + if len(filteredCC) == 0 { + headerValue, postValue = unsubscribeHeaderValuesFor(baseURL, recipient, scope) + } + return filteredCC, headerValue, postValue, false +} + +// unsubscribeURLFor constructs the one-click unsubscribe URL for the given +// (email, scope) pair. Returns "" when baseURL is empty or when no signing key +// is available (e.g. NOTIFICATION_MUTE_SECRET is unset), so a +// tokenless, non-functional unsubscribe link is never emitted. +func unsubscribeURLFor(baseURL, email, scope string) string { + if baseURL == "" { + return "" + } + token := common.DeriveMuteToken(muteKey(), email, scope) + if token == "" { + return "" + } + q := url.Values{ + "token": {token}, + "email": {email}, + "scope": {scope}, + } + return baseURL + "/api/notifications/unsubscribe?" + q.Encode() +} + +// unsubscribeHeaderValuesFor returns the List-Unsubscribe and +// List-Unsubscribe-Post header values (RFC 8058) for the given (email, scope) +// pair. Returns ("", "") when baseURL is empty. +func unsubscribeHeaderValuesFor(baseURL, email, scope string) (headerValue, postValue string) { + unsubURL := unsubscribeURLFor(baseURL, email, scope) + if unsubURL == "" { + return "", "" + } + return "<" + unsubURL + ">", "List-Unsubscribe=One-Click" +} diff --git a/internal/email/mute_test.go b/internal/email/mute_test.go new file mode 100644 index 000000000..3eaf9937d --- /dev/null +++ b/internal/email/mute_test.go @@ -0,0 +1,318 @@ +package email + +import ( + "context" + "errors" + "testing" + + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/aws/aws-sdk-go-v2/service/sesv2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Stub MuteChecker +// --------------------------------------------------------------------------- + +type mockMuteChecker struct { + mock.Mock +} + +func setMuteTestSecret(t *testing.T) { + t.Helper() + t.Setenv("NOTIFICATION_MUTE_SECRET", "email-mute-test-secret") +} + +func (m *mockMuteChecker) IsNotificationMuted(ctx context.Context, email, scope string) (bool, error) { + args := m.Called(ctx, email, scope) + return args.Bool(0), args.Error(1) +} + +// --------------------------------------------------------------------------- +// SendPurchaseApprovalRequest + mute check +// --------------------------------------------------------------------------- + +// newSenderWithMute builds a testable *Sender with a mock SES client and a +// mock MuteChecker, bypassing sandbox checks by having GetAccount return +// production mode. +func newSenderWithMute(ses *MockSESClient, mc *mockMuteChecker) *Sender { + // GetAccount returning ProductionAccessEnabled=true means no sandbox path. + ses.On("GetAccount", mock.Anything, mock.Anything). + Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil).Maybe() + return &Sender{ + sesClient: ses, + fromEmail: "noreply@example.com", + muteChecker: mc, + } +} + +func TestSendPurchaseApprovalRequest_MutedRecipient_NoSESCall(t *testing.T) { + ctx := context.Background() + ses := new(MockSESClient) + mc := new(mockMuteChecker) + + mc.On("IsNotificationMuted", mock.Anything, "approver@example.com", string(common.ScopePurchaseApprovals)). + Return(true, nil) + t.Cleanup(func() { + mc.AssertExpectations(t) + ses.AssertNotCalled(t, "SendEmail") + }) + + s := newSenderWithMute(ses, mc) + + data := NotificationData{ + RecipientEmail: "approver@example.com", + Recommendations: []RecommendationSummary{ + {Service: "ec2", Region: "us-east-1", Count: 1, MonthlySavings: 100}, + }, + DashboardURL: "https://dashboard.example.com", + ApprovalToken: "tok", + } + err := s.SendPurchaseApprovalRequest(ctx, data) + require.NoError(t, err) +} + +func TestSendPurchaseApprovalRequest_NotMuted_SendsEmail(t *testing.T) { + ctx := context.Background() + ses := new(MockSESClient) + mc := new(mockMuteChecker) + + mc.On("IsNotificationMuted", mock.Anything, "approver@example.com", string(common.ScopePurchaseApprovals)). + Return(false, nil) + mc.On("IsNotificationMuted", mock.Anything, mock.Anything, mock.Anything). + Return(false, nil).Maybe() // for CC filter if any + ses.On("GetAccount", mock.Anything, mock.Anything). + Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) + ses.On("SendEmail", mock.Anything, mock.Anything). + Return(&sesv2.SendEmailOutput{}, nil) + t.Cleanup(func() { + mc.AssertExpectations(t) + ses.AssertExpectations(t) + }) + + s := newSenderWithMute(ses, mc) + data := NotificationData{ + RecipientEmail: "approver@example.com", + Recommendations: []RecommendationSummary{ + {Service: "ec2", Region: "us-east-1", Count: 1, MonthlySavings: 100}, + }, + DashboardURL: "https://dashboard.example.com", + ApprovalToken: "tok", + } + err := s.SendPurchaseApprovalRequest(ctx, data) + require.NoError(t, err) + ses.AssertCalled(t, "SendEmail", mock.Anything, mock.Anything) +} + +func TestSendPurchaseApprovalRequest_MuteCheckError_FailOpen(t *testing.T) { + // When the mute store returns an error, the email is still sent (fail-open + // so a DB hiccup doesn't permanently block approval notifications). + ctx := context.Background() + ses := new(MockSESClient) + mc := new(mockMuteChecker) + + mc.On("IsNotificationMuted", mock.Anything, "approver@example.com", string(common.ScopePurchaseApprovals)). + Return(false, errors.New("db error")) + mc.On("IsNotificationMuted", mock.Anything, mock.Anything, mock.Anything). + Return(false, nil).Maybe() + ses.On("GetAccount", mock.Anything, mock.Anything). + Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) + ses.On("SendEmail", mock.Anything, mock.Anything). + Return(&sesv2.SendEmailOutput{}, nil) + t.Cleanup(func() { + ses.AssertCalled(t, "SendEmail", mock.Anything, mock.Anything) + }) + + s := newSenderWithMute(ses, mc) + data := NotificationData{ + RecipientEmail: "approver@example.com", + Recommendations: []RecommendationSummary{ + {Service: "ec2", Region: "us-east-1", Count: 1, MonthlySavings: 100}, + }, + DashboardURL: "https://dashboard.example.com", + ApprovalToken: "tok", + } + err := s.SendPurchaseApprovalRequest(ctx, data) + require.NoError(t, err) +} + +func TestSendRIExchangePendingApproval_MutedRecipient_NoSESCall(t *testing.T) { + ctx := context.Background() + ses := new(MockSESClient) + mc := new(mockMuteChecker) + mc.On("IsNotificationMuted", mock.Anything, "ri-approver@example.com", string(common.ScopeRIExchangeApprovals)). + Return(true, nil).Once() + t.Cleanup(func() { + mc.AssertExpectations(t) + ses.AssertNotCalled(t, "SendEmail") + }) + + s := newSenderWithMute(ses, mc) + err := s.SendRIExchangePendingApproval(ctx, RIExchangeNotificationData{ + RecipientEmail: "ri-approver@example.com", + DashboardURL: "https://dash.example.com", + Exchanges: []RIExchangeItem{{RecordID: "rec-1", ApprovalToken: "tok"}}, + }) + require.NoError(t, err) +} + +func TestSendRIExchangePendingApproval_EmitsScopedUnsubscribeHeaders(t *testing.T) { + setMuteTestSecret(t) + ctx := context.Background() + ses := new(MockSESClient) + mc := new(mockMuteChecker) + mc.On("IsNotificationMuted", mock.Anything, "ri-approver@example.com", string(common.ScopeRIExchangeApprovals)). + Return(false, nil).Once() + + var captured *sesv2.SendEmailInput + ses.On("SendEmail", mock.Anything, mock.MatchedBy(func(in *sesv2.SendEmailInput) bool { + captured = in + return true + })).Return(&sesv2.SendEmailOutput{}, nil).Once() + t.Cleanup(func() { + mc.AssertExpectations(t) + ses.AssertExpectations(t) + }) + + s := newSenderWithMute(ses, mc).WithUnsubscribeBaseURL("https://dash.example.com") + err := s.SendRIExchangePendingApproval(ctx, RIExchangeNotificationData{ + RecipientEmail: "ri-approver@example.com", + DashboardURL: "https://dash.example.com", + Exchanges: []RIExchangeItem{{RecordID: "rec-1", ApprovalToken: "tok"}}, + }) + require.NoError(t, err) + require.NotNil(t, captured) + require.NotNil(t, captured.Content.Simple) + require.Len(t, captured.Content.Simple.Headers, 2) + assert.Equal(t, "List-Unsubscribe", *captured.Content.Simple.Headers[0].Name) + assert.Contains(t, *captured.Content.Simple.Headers[0].Value, "scope="+string(common.ScopeRIExchangeApprovals)) + assert.NotContains(t, *captured.Content.Simple.Headers[0].Value, "scope="+string(common.ScopePurchaseApprovals)) + assert.Equal(t, "List-Unsubscribe=One-Click", *captured.Content.Simple.Headers[1].Value) +} + +// TestSendPurchaseApprovalRequest_WithCC_SuppressesListUnsubscribe verifies the +// List-Unsubscribe header (whose token is bound to the primary recipient) is NOT +// emitted when the message also goes to CC recipients. A shared-envelope CC +// recipient could otherwise one-click-mute the primary recipient. +func TestSendPurchaseApprovalRequest_WithCC_SuppressesListUnsubscribe(t *testing.T) { + ctx := context.Background() + ses := new(MockSESClient) + mc := new(mockMuteChecker) + + mc.On("IsNotificationMuted", mock.Anything, mock.Anything, mock.Anything). + Return(false, nil) + ses.On("GetAccount", mock.Anything, mock.Anything). + Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) + + var captured *sesv2.SendEmailInput + ses.On("SendEmail", mock.Anything, mock.MatchedBy(func(in *sesv2.SendEmailInput) bool { + captured = in + return true + })).Return(&sesv2.SendEmailOutput{}, nil) + t.Cleanup(func() { mc.AssertExpectations(t) }) + + s := newSenderWithMute(ses, mc).WithUnsubscribeBaseURL("https://dash.example.com") + data := NotificationData{ + RecipientEmail: "approver@example.com", + CCEmails: []string{"observer@example.com"}, + Recommendations: []RecommendationSummary{ + {Service: "ec2", Region: "us-east-1", Count: 1, MonthlySavings: 100}, + }, + DashboardURL: "https://dashboard.example.com", + ApprovalToken: "tok", + } + require.NoError(t, s.SendPurchaseApprovalRequest(ctx, data)) + require.NotNil(t, captured) + require.NotNil(t, captured.Content.Simple) + for _, h := range captured.Content.Simple.Headers { + if h.Name != nil { + assert.NotEqual(t, "List-Unsubscribe", *h.Name, + "List-Unsubscribe must be suppressed when CC recipients are present") + } + } +} + +// --------------------------------------------------------------------------- +// List-Unsubscribe header injection +// --------------------------------------------------------------------------- + +func TestBuildUnsubscribeURL_EmptyBaseURL_ReturnsEmpty(t *testing.T) { + s := &Sender{} + u := s.buildUnsubscribeURL("user@example.com", "purchase_approvals") + assert.Empty(t, u) +} + +func TestBuildUnsubscribeURL_WithBaseURL_ContainsParams(t *testing.T) { + setMuteTestSecret(t) + s := &Sender{unsubscribeBaseURL: "https://dash.example.com"} + u := s.buildUnsubscribeURL("user@example.com", "purchase_approvals") + assert.Contains(t, u, "email=user%40example.com") + assert.Contains(t, u, "scope=purchase_approvals") + assert.Contains(t, u, "token=") +} + +func TestListUnsubscribeHeaders_EmptyBase_ReturnsEmpty(t *testing.T) { + s := &Sender{} + hdr, post := s.listUnsubscribeHeaders("u@e.com", "purchase_approvals") + assert.Empty(t, hdr) + assert.Empty(t, post) +} + +func TestListUnsubscribeHeaders_WithBase(t *testing.T) { + setMuteTestSecret(t) + s := &Sender{unsubscribeBaseURL: "https://dash.example.com"} + hdr, post := s.listUnsubscribeHeaders("u@e.com", "purchase_approvals") + assert.Contains(t, hdr, "