diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index 8ed9b29a..57d09467 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -354,6 +354,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 +} // TestNewCollector tests the NewCollector function func TestNewCollector(t *testing.T) { diff --git a/internal/api/handler_notifications.go b/internal/api/handler_notifications.go new file mode 100644 index 00000000..ee0eca9d --- /dev/null +++ b/internal/api/handler_notifications.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "fmt" + "html/template" + "os" + "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(` + + + + +Unsubscribed + + + +

You have been unsubscribed.

+

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" + } +} + +// muteSecretKey loads the NOTIFICATION_MUTE_SECRET env var as bytes. Returns +// nil when unset, which causes DeriveMuteToken to fall back to its dev key. +func muteSecretKey() []byte { + v := os.Getenv("NOTIFICATION_MUTE_SECRET") + if v == "" { + return nil + } + return []byte(v) +} + +// unsubscribeHandler handles GET /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) { + token := req.QueryStringParameters["token"] + email := req.QueryStringParameters["email"] + scope := req.QueryStringParameters["scope"] + + if token == "" || email == "" || scope == "" { + return nil, NewClientError(400, "token, email and scope are required") + } + + // Reject unknown scopes early so we never create phantom rows. + validScope := scope == string(common.ScopePurchaseApprovals) || + scope == string(common.ScopeRIExchangeApprovals) + if !validScope { + return nil, NewClientError(400, fmt.Sprintf("unknown notification scope: %s", scope)) + } + + if !common.VerifyMuteToken(muteSecretKey(), 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 00000000..d9fef11a --- /dev/null +++ b/internal/api/handler_notifications_test.go @@ -0,0 +1,207 @@ +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(email, scope string) string { + return common.DeriveMuteToken(nil, email, scope) +} + +func TestUnsubscribeHandler_Success(t *testing.T) { + ctx := context.Background() + email := "user@example.com" + scope := string(common.ScopePurchaseApprovals) + token := validUnsubToken(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_ForgedToken_Returns401(t *testing.T) { + 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_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" + // Use the dev-key token so HMAC passes but scope guard fires first. + token := common.DeriveMuteToken(nil, email, scope) + + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{ + "token": token, + "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(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) { + // 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) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 21468c31..d9b17e0c 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -26,6 +26,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/mocks_test.go b/internal/api/mocks_test.go index 8fb7a124..08bac653 100644 --- a/internal/api/mocks_test.go +++ b/internal/api/mocks_test.go @@ -618,6 +618,26 @@ func (m *MockConfigStore) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) return fn(nil) } +// UpsertNotificationMute defaults to a no-op success so tests that do not +// exercise the mute path are unaffected. +func (m *MockConfigStore) UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error { + if !m.isExpected("UpsertNotificationMute") { + return nil + } + args := m.Called(ctx, recipientEmail, scope, unmuteToken) + return args.Error(0) +} + +// IsNotificationMuted defaults to (false, nil) so callers that don't care +// proceed without being blocked by the mute check. +func (m *MockConfigStore) IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error) { + if !m.isExpected("IsNotificationMuted") { + return false, nil + } + args := m.Called(ctx, recipientEmail, scope) + return args.Bool(0), args.Error(1) +} + // isExpected returns true when at least one .On(method, ...) expectation // has been registered on this mock. Lets us write "default no-op" stubs // above that route through m.Called only when the test explicitly cares. diff --git a/internal/api/router.go b/internal/api/router.go index 1438f78f..083087b4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -284,6 +284,10 @@ func (r *Router) registerRoutes() { {PathPrefix: "/api/ri-exchange/approve/", Method: "POST", Handler: r.approveRIExchangeHandler, Auth: AuthPublic}, {PathPrefix: "/api/ri-exchange/reject/", Method: "POST", Handler: r.rejectRIExchangeHandler, Auth: AuthPublic}, + // 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}, + // 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}, @@ -846,3 +850,7 @@ func (r *Router) listPlanAccountsHandler(ctx context.Context, req *events.Lambda func (r *Router) setPlanAccountsHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { return r.h.setPlanAccounts(ctx, req, params["id"]) } + +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 457eeef1..8882a4d1 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -227,4 +227,13 @@ type StoreInterface interface { // participate in the transaction. Nested transactions are not // supported — fn must not call WithTx recursively. WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error + + // Notification mutes (issue #297 / migration 000061). + // UpsertNotificationMute inserts or updates a mute row for (email, scope). + // Idempotent: calling it again for an already-muted address is a no-op on + // muted_at but does replace 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 6905f410..cd09f446 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -2521,3 +2521,40 @@ func nullStringFromString(s string) sql.NullString { } return sql.NullString{String: s, Valid: true} } + +// ========================================== +// 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/000061_muted_recipients.down.sql b/internal/database/postgres/migrations/000061_muted_recipients.down.sql new file mode 100644 index 00000000..2c582be6 --- /dev/null +++ b/internal/database/postgres/migrations/000061_muted_recipients.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS muted_recipients; diff --git a/internal/database/postgres/migrations/000061_muted_recipients.up.sql b/internal/database/postgres/migrations/000061_muted_recipients.up.sql new file mode 100644 index 00000000..41d398a0 --- /dev/null +++ b/internal/database/postgres/migrations/000061_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 f9a08fcc..a2404bb2 100644 --- a/internal/email/interfaces.go +++ b/internal/email/interfaces.go @@ -42,6 +42,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_test.go b/internal/email/mute_test.go new file mode 100644 index 00000000..3f7cd28e --- /dev/null +++ b/internal/email/mute_test.go @@ -0,0 +1,215 @@ +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 (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) +} + +// --------------------------------------------------------------------------- +// 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) { + 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) { + s := &Sender{unsubscribeBaseURL: "https://dash.example.com"} + hdr, post := s.listUnsubscribeHeaders("u@e.com", "purchase_approvals") + assert.Contains(t, hdr, "", "List-Unsubscribe=One-Click") + require.Len(t, hdrs, 2) + assert.Equal(t, "List-Unsubscribe", *hdrs[0].Name) + assert.Equal(t, "List-Unsubscribe-Post", *hdrs[1].Name) + assert.Equal(t, "List-Unsubscribe=One-Click", *hdrs[1].Value) +} + +// --------------------------------------------------------------------------- +// DeriveMuteToken / VerifyMuteToken +// --------------------------------------------------------------------------- + +func TestDeriveMuteToken_Stable(t *testing.T) { + key := []byte("test-secret") + t1 := common.DeriveMuteToken(key, "user@example.com", "purchase_approvals") + t2 := common.DeriveMuteToken(key, "user@example.com", "purchase_approvals") + assert.Equal(t, t1, t2) +} + +func TestDeriveMuteToken_CaseInsensitive(t *testing.T) { + key := []byte("test-secret") + lower := common.DeriveMuteToken(key, "user@example.com", "purchase_approvals") + upper := common.DeriveMuteToken(key, "USER@EXAMPLE.COM", "purchase_approvals") + assert.Equal(t, lower, upper, "token must be case-insensitive on email") +} + +func TestDeriveMuteToken_DiffersByScope(t *testing.T) { + key := []byte("test-secret") + t1 := common.DeriveMuteToken(key, "user@example.com", "purchase_approvals") + t2 := common.DeriveMuteToken(key, "user@example.com", "ri_exchange_approvals") + assert.NotEqual(t, t1, t2) +} + +func TestVerifyMuteToken_Valid(t *testing.T) { + key := []byte("test-secret") + tok := common.DeriveMuteToken(key, "user@example.com", "purchase_approvals") + assert.True(t, common.VerifyMuteToken(key, "user@example.com", "purchase_approvals", tok)) +} + +func TestVerifyMuteToken_Forged(t *testing.T) { + key := []byte("test-secret") + assert.False(t, common.VerifyMuteToken(key, "user@example.com", "purchase_approvals", "forgedtoken")) +} diff --git a/internal/email/sender.go b/internal/email/sender.go index 75b3d301..264f4a28 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -5,8 +5,11 @@ import ( "context" "errors" "fmt" + "net/url" + "os" "strings" + "github.com/LeanerCloud/CUDly/pkg/common" "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -43,6 +46,12 @@ type Sender struct { topicARN string fromEmail string emailAddress string + // muteChecker consults the muted_recipients table before each send. + // Nil disables mute checking (e.g. when no DB is wired in tests). + muteChecker MuteChecker + // unsubscribeBaseURL is the dashboard base URL used to construct the + // List-Unsubscribe header value. Empty disables the header. + unsubscribeBaseURL string } // NewSender creates a new email sender with default context @@ -100,6 +109,93 @@ func NewSenderWithClients(snsClient SNSPublisher, sesClient SESEmailSender, cfg } } +// WithMuteChecker returns a shallow copy of s with the given MuteChecker wired +// in. Callers that have a DB-backed store use this to enable per-recipient mute +// suppression on outbound SES sends. +func (s *Sender) WithMuteChecker(mc MuteChecker) *Sender { + c := *s + c.muteChecker = mc + return &c +} + +// WithUnsubscribeBaseURL returns a shallow copy of s with the given base URL +// set. When non-empty the sender appends List-Unsubscribe / List-Unsubscribe-Post +// headers (RFC 8058) to outbound SES messages for applicable scopes. +func (s *Sender) WithUnsubscribeBaseURL(u string) *Sender { + c := *s + c.unsubscribeBaseURL = u + return &c +} + +// muteKey reads NOTIFICATION_MUTE_SECRET from env for token derivation. Returns +// nil when unset so DeriveMuteToken uses the dev fallback. +func muteKey() []byte { + v := os.Getenv("NOTIFICATION_MUTE_SECRET") + if v == "" { + return nil + } + return []byte(v) +} + +// buildUnsubscribeURL constructs the one-click unsubscribe URL for the given +// (email, scope) pair. Returns ("", "") when unsubscribeBaseURL is empty. +func (s *Sender) buildUnsubscribeURL(email, scope string) (unsubURL, mailtoURL string) { + if s.unsubscribeBaseURL == "" { + return "", "" + } + token := common.DeriveMuteToken(muteKey(), email, scope) + q := url.Values{ + "token": {token}, + "email": {email}, + "scope": {scope}, + } + unsubURL = s.unsubscribeBaseURL + "/api/notifications/unsubscribe?" + q.Encode() + return unsubURL, "" +} + +// listUnsubscribeHeaders returns the List-Unsubscribe and List-Unsubscribe-Post +// header values for the given (email, scope) pair (RFC 8058). +// Returns ("", "") when no base URL is configured. +func (s *Sender) listUnsubscribeHeaders(email, scope string) (headerValue, postValue string) { + unsubURL, _ := s.buildUnsubscribeURL(email, scope) + if unsubURL == "" { + return "", "" + } + return "<" + unsubURL + ">", "List-Unsubscribe=One-Click" +} + +// isMuted returns true when the given address is muted for this scope. When the +// mute checker is nil or returns an error the address is treated as not muted so +// a transient DB outage doesn't silently block approval emails. +func (s *Sender) isMuted(ctx context.Context, email, scope string) bool { + if s.muteChecker == nil { + return false + } + muted, err := s.muteChecker.IsNotificationMuted(ctx, email, scope) + if err != nil { + logging.Warnf("email: mute check failed for scope=%s: %v", scope, err) + return false + } + return muted +} + +// filterMutedAddresses returns a copy of addrs with any muted (for scope) +// entries removed. The original slice is not modified. Errors from the mute +// store are treated as "not muted" (fail-open) so a DB hiccup does not +// silently suppress approval emails. +func (s *Sender) filterMutedAddresses(ctx context.Context, addrs []string, scope string) []string { + if s.muteChecker == nil || len(addrs) == 0 { + return addrs + } + out := make([]string, 0, len(addrs)) + for _, addr := range addrs { + if !s.isMuted(ctx, addr, scope) { + out = append(out, addr) + } + } + return out +} + // SendNotification sends a notification email via SNS func (s *Sender) SendNotification(ctx context.Context, subject, message string) error { if s.topicARN == "" { @@ -203,7 +299,7 @@ func (s *Sender) SendToEmailWithCCMultipart(ctx context.Context, toEmail string, cc := dedupeCCAgainstTo(toEmail, ccEmails) - input := buildSESSendEmailInputMultipart(s.fromEmail, toEmail, cc, subject, textBody, htmlBody) + input := buildSESSendEmailInputMultipart(s.fromEmail, toEmail, cc, subject, textBody, htmlBody, nil) _, err := s.sesClient.SendEmail(ctx, input) if err != nil { @@ -238,7 +334,7 @@ func (s *Sender) SendToEmailWithCC(ctx context.Context, toEmail string, ccEmails cc := dedupeCCAgainstTo(toEmail, ccEmails) - input := buildSESSendEmailInput(s.fromEmail, toEmail, cc, subject, body) + input := buildSESSendEmailInput(s.fromEmail, toEmail, cc, subject, body, nil) _, err := s.sesClient.SendEmail(ctx, input) if err != nil { @@ -291,67 +387,161 @@ func (s *Sender) ensureSandboxRecipientVerified(ctx context.Context, toEmail str // buildSESSendEmailInputMultipart constructs a sesv2.SendEmailInput with // both a plain-text and an HTML alternative body. SES handles the // multipart/alternative MIME assembly server-side when both Text and Html -// fields are populated on types.Body. -func buildSESSendEmailInputMultipart(fromEmail, toEmail string, cc []string, subject, textBody, htmlBody string) *sesv2.SendEmailInput { +// fields are populated on types.Body. extraHeaders are appended as-is; use +// addListUnsubscribeHeaders to build the RFC 8058 pair. +func buildSESSendEmailInputMultipart(fromEmail, toEmail string, cc []string, subject, textBody, htmlBody string, extraHeaders []types.MessageHeader) *sesv2.SendEmailInput { destination := &types.Destination{ ToAddresses: []string{toEmail}, } if len(cc) > 0 { destination.CcAddresses = cc } - return &sesv2.SendEmailInput{ - Destination: destination, - Content: &types.EmailContent{ - Simple: &types.Message{ - Subject: &types.Content{ - Charset: aws.String("UTF-8"), - Data: aws.String(subject), - }, - Body: &types.Body{ - Text: &types.Content{ - Charset: aws.String("UTF-8"), - Data: aws.String(textBody), - }, - Html: &types.Content{ - Charset: aws.String("UTF-8"), - Data: aws.String(htmlBody), - }, - }, + msg := &types.Message{ + Subject: &types.Content{ + Charset: aws.String("UTF-8"), + Data: aws.String(subject), + }, + Body: &types.Body{ + Text: &types.Content{ + Charset: aws.String("UTF-8"), + Data: aws.String(textBody), + }, + Html: &types.Content{ + Charset: aws.String("UTF-8"), + Data: aws.String(htmlBody), }, }, + } + if len(extraHeaders) > 0 { + msg.Headers = extraHeaders + } + return &sesv2.SendEmailInput{ + Destination: destination, + Content: &types.EmailContent{Simple: msg}, FromEmailAddress: aws.String(fromEmail), } } // buildSESSendEmailInput constructs a sesv2.SendEmailInput with the -// destination To + (optional) Cc list and a plain-text body. -func buildSESSendEmailInput(fromEmail, toEmail string, cc []string, subject, body string) *sesv2.SendEmailInput { +// destination To + (optional) Cc list and a plain-text body. extraHeaders are +// appended as-is; use addListUnsubscribeHeaders to build the RFC 8058 pair. +func buildSESSendEmailInput(fromEmail, toEmail string, cc []string, subject, body string, extraHeaders []types.MessageHeader) *sesv2.SendEmailInput { destination := &types.Destination{ ToAddresses: []string{toEmail}, } if len(cc) > 0 { destination.CcAddresses = cc } - return &sesv2.SendEmailInput{ - Destination: destination, - Content: &types.EmailContent{ - Simple: &types.Message{ - Subject: &types.Content{ - Charset: aws.String("UTF-8"), - Data: aws.String(subject), - }, - Body: &types.Body{ - Text: &types.Content{ - Charset: aws.String("UTF-8"), - Data: aws.String(body), - }, - }, + msg := &types.Message{ + Subject: &types.Content{ + Charset: aws.String("UTF-8"), + Data: aws.String(subject), + }, + Body: &types.Body{ + Text: &types.Content{ + Charset: aws.String("UTF-8"), + Data: aws.String(body), }, }, + } + if len(extraHeaders) > 0 { + msg.Headers = extraHeaders + } + return &sesv2.SendEmailInput{ + Destination: destination, + Content: &types.EmailContent{Simple: msg}, FromEmailAddress: aws.String(fromEmail), } } +// addListUnsubscribeHeaders returns the RFC 8058 List-Unsubscribe pair as +// sesv2 MessageHeader values. Returns nil when headerValue is empty. +func addListUnsubscribeHeaders(headerValue, postValue string) []types.MessageHeader { + if headerValue == "" { + return nil + } + hdrs := []types.MessageHeader{ + {Name: aws.String("List-Unsubscribe"), Value: aws.String(headerValue)}, + } + if postValue != "" { + hdrs = append(hdrs, types.MessageHeader{ + Name: aws.String("List-Unsubscribe-Post"), + Value: aws.String(postValue), + }) + } + return hdrs +} + +// sendToEmailWithCCMultipartHeaders is the internal variant of +// SendToEmailWithCCMultipart that also accepts custom message headers (e.g. +// List-Unsubscribe). It is used by the mute-aware send path in +// SendPurchaseApprovalRequest so we don't expose a wider public API. +func (s *Sender) sendToEmailWithCCMultipartHeaders( + ctx context.Context, + toEmail string, + ccEmails []string, + subject, textBody, htmlBody string, + extraHeaders []types.MessageHeader, +) error { + if htmlBody == "" { + // Degrade to plain text; headers still carried via the non-multipart path. + return s.sendToEmailWithCCHeaders(ctx, toEmail, ccEmails, subject, textBody, extraHeaders) + } + if s.fromEmail == "" { + logging.Debug("No from email configured, skipping direct email") + return nil + } + if s.sesClient == nil { + return fmt.Errorf("SES client not initialized") + } + if err := s.ensureSandboxRecipientVerified(ctx, toEmail); err != nil { + return err + } + cc := dedupeCCAgainstTo(toEmail, ccEmails) + input := buildSESSendEmailInputMultipart(s.fromEmail, toEmail, cc, subject, textBody, htmlBody, extraHeaders) + if _, err := s.sesClient.SendEmail(ctx, input); err != nil { + return fmt.Errorf("failed to send email via SES: %w", err) + } + if len(cc) > 0 { + logging.Debugf("Sent multipart email to %s (cc %d): %s", redactEmail(toEmail), len(cc), subject) + } else { + logging.Debugf("Sent multipart email to %s: %s", redactEmail(toEmail), subject) + } + return nil +} + +// sendToEmailWithCCHeaders is the plain-text variant of +// sendToEmailWithCCMultipartHeaders. +func (s *Sender) sendToEmailWithCCHeaders( + ctx context.Context, + toEmail string, + ccEmails []string, + subject, body string, + extraHeaders []types.MessageHeader, +) error { + if s.fromEmail == "" { + logging.Debug("No from email configured, skipping direct email") + return nil + } + if s.sesClient == nil { + return fmt.Errorf("SES client not initialized") + } + if err := s.ensureSandboxRecipientVerified(ctx, toEmail); err != nil { + return err + } + cc := dedupeCCAgainstTo(toEmail, ccEmails) + input := buildSESSendEmailInput(s.fromEmail, toEmail, cc, subject, body, extraHeaders) + if _, err := s.sesClient.SendEmail(ctx, input); err != nil { + return fmt.Errorf("failed to send email via SES: %w", err) + } + if len(cc) > 0 { + logging.Debugf("Sent email to %s (cc %d): %s", redactEmail(toEmail), len(cc), subject) + } else { + logging.Debugf("Sent email to %s: %s", redactEmail(toEmail), subject) + } + return nil +} + // dedupeCCAgainstTo returns cc with the to-address removed (case-insensitive) // and duplicate entries collapsed, preserving input order. Empty strings are // dropped so a caller can freely pass optional slots without sanitising. diff --git a/internal/email/templates.go b/internal/email/templates.go index 82421855..7327c749 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -4,7 +4,9 @@ import ( "context" "fmt" + "github.com/LeanerCloud/CUDly/pkg/common" "github.com/LeanerCloud/CUDly/pkg/logging" + "github.com/aws/aws-sdk-go-v2/service/sesv2/types" ) // Email templates @@ -702,6 +704,11 @@ func sendMultipartVia( // ErrNoRecipient when data.RecipientEmail is empty and ErrNoFromEmail when // FROM_EMAIL is unconfigured, so the caller can surface a precise reason in // the API response instead of the prior silent no-op. +// +// Mute check: if the recipient has opted out of purchase_approvals via the +// List-Unsubscribe link, the email is silently skipped and nil is returned. +// A List-Unsubscribe / List-Unsubscribe-Post header pair (RFC 8058) is added +// to the outbound SES message when an unsubscribe base URL is configured. func (s *Sender) SendPurchaseApprovalRequest(ctx context.Context, data NotificationData) error { if data.RecipientEmail == "" { return ErrNoRecipient @@ -714,8 +721,49 @@ func (s *Sender) SendPurchaseApprovalRequest(ctx context.Context, data Notificat if !isValidFromEmail(s.fromEmail) { return ErrNoFromEmail } + + scope := string(common.ScopePurchaseApprovals) + + // Per-recipient mute check: skip silently if the approver has opted out. + if s.isMuted(ctx, data.RecipientEmail, scope) { + logging.Infof("email: purchase approval skipped for muted recipient (scope=%s)", scope) + return nil + } + + // Filter CC list against mutes so no muted address receives a copy. + filteredCC := s.filterMutedAddresses(ctx, data.CCEmails, scope) + + // Build RFC 8058 List-Unsubscribe headers scoped to the primary recipient. + unsubHdr, postHdr := s.listUnsubscribeHeaders(data.RecipientEmail, scope) + extraHeaders := addListUnsubscribeHeaders(unsubHdr, postHdr) + subject := fmt.Sprintf("CUDly - Purchase Approval Required (%d commitment(s))", len(data.Recommendations)) - return sendPurchaseApprovalRequestVia(ctx, s, data.RecipientEmail, subject, data) + return sendPurchaseApprovalRequestWithCC(ctx, s, data.RecipientEmail, filteredCC, subject, data, extraHeaders) +} + +// sendPurchaseApprovalRequestWithCC is the low-level send helper that accepts +// a pre-filtered CC list and extra message headers. Extracted from +// sendPurchaseApprovalRequestVia so the mute/unsub path can inject headers +// without duplicating the render logic. +func sendPurchaseApprovalRequestWithCC( + ctx context.Context, + s *Sender, + recipient string, + ccEmails []string, + subject string, + data NotificationData, + extraHeaders []types.MessageHeader, +) error { + textBody, err := RenderPurchaseApprovalRequestEmail(data) + if err != nil { + return fmt.Errorf("failed to render purchase approval request email (text): %w", err) + } + htmlBody, htmlErr := RenderPurchaseApprovalRequestEmailHTML(data) + if htmlErr != nil { + logging.Warnf("email: HTML approval-request render failed, falling back to text-only: %v", htmlErr) + htmlBody = "" + } + return s.sendToEmailWithCCMultipartHeaders(ctx, recipient, ccEmails, subject, textBody, htmlBody, extraHeaders) } // --------------------------------------------------------------------------- diff --git a/internal/purchase/mocks_test.go b/internal/purchase/mocks_test.go index e39bf8f0..23aac450 100644 --- a/internal/purchase/mocks_test.go +++ b/internal/purchase/mocks_test.go @@ -579,6 +579,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 +} // Verify MockConfigStore implements config.StoreInterface var _ config.StoreInterface = (*MockConfigStore)(nil) diff --git a/internal/scheduler/scheduler_overrides_test.go b/internal/scheduler/scheduler_overrides_test.go index e3b2d722..2a897bd0 100644 --- a/internal/scheduler/scheduler_overrides_test.go +++ b/internal/scheduler/scheduler_overrides_test.go @@ -64,6 +64,12 @@ func (m *mockOverrideStore) GetGlobalConfig(_ context.Context) (*config.GlobalCo RecommendationsLookbackDays: config.DefaultRecommendationsLookbackDays, }, nil } +func (m *mockOverrideStore) UpsertNotificationMute(_ context.Context, _, _, _ string) error { + return nil +} +func (m *mockOverrideStore) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) { + return false, nil +} func boolPtr(b bool) *bool { return &b } diff --git a/internal/scheduler/scheduler_suppressions_test.go b/internal/scheduler/scheduler_suppressions_test.go index 694de959..a3a1e0aa 100644 --- a/internal/scheduler/scheduler_suppressions_test.go +++ b/internal/scheduler/scheduler_suppressions_test.go @@ -58,6 +58,12 @@ func (m *mockSuppressionStore) GetGlobalConfig(_ context.Context) (*config.Globa RecommendationsLookbackDays: config.DefaultRecommendationsLookbackDays, }, nil } +func (m *mockSuppressionStore) UpsertNotificationMute(_ context.Context, _, _, _ string) error { + return nil +} +func (m *mockSuppressionStore) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) { + return false, nil +} func strPtr(s string) *string { return &s } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index bb827162..ea5dd36f 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -462,6 +462,12 @@ func (m *MockConfigStore) UpsertRIUtilizationCache(ctx context.Context, region s } return m.Called(ctx, region, lookbackDays, payload, fetchedAt).Error(0) } +func (m *MockConfigStore) UpsertNotificationMute(_ context.Context, _, _, _ string) error { + return nil +} +func (m *MockConfigStore) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) { + return false, nil +} // MockEmailSender is a mock implementation of email.Sender type MockEmailSender struct { diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index 91347680..5b501165 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -279,3 +279,9 @@ func (m *mockConfigStoreForHealth) GetPendingExecutionsTx(ctx context.Context, _ func (m *mockConfigStoreForHealth) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error { return fn(nil) } +func (m *mockConfigStoreForHealth) UpsertNotificationMute(_ context.Context, _, _, _ string) error { + return nil +} +func (m *mockConfigStoreForHealth) IsNotificationMuted(_ context.Context, _, _ string) (bool, error) { + return false, nil +} diff --git a/pkg/common/tokens.go b/pkg/common/tokens.go index 51754bd8..eae779cb 100644 --- a/pkg/common/tokens.go +++ b/pkg/common/tokens.go @@ -1,6 +1,7 @@ package common import ( + "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" @@ -93,3 +94,43 @@ func ReservationOrderID(token, fallback string) string { } return fallback } + +// MuteNotifScope is the set of valid notification scopes for per-recipient +// muting (issue #297). Each scope corresponds to one category of outbound email; +// a mute row suppresses only that category for its holder. +type MuteNotifScope string + +const ( + // ScopePurchaseApprovals suppresses purchase-approval-request emails. + ScopePurchaseApprovals MuteNotifScope = "purchase_approvals" + // ScopeRIExchangeApprovals suppresses RI-exchange pending-approval emails. + ScopeRIExchangeApprovals MuteNotifScope = "ri_exchange_approvals" +) + +// DeriveMuteToken returns a 32-byte HMAC-SHA256 token (hex-encoded) that +// signs the (email, scope) tuple. The token is embedded in the +// List-Unsubscribe URL; the handler re-derives it from the query params and +// compares in constant time, so a forged URL cannot mute a different address. +// +// key must come from a deployment secret (NOTIFICATION_MUTE_SECRET env var). +// When key is empty a static fallback is used so local-dev / test environments +// still produce a deterministic token without crashing; production deployments +// MUST set the env var. +func DeriveMuteToken(key []byte, email, scope string) string { + if len(key) == 0 { + // Fallback for local dev / tests: deterministic but clearly insecure. + key = []byte("dev-mute-secret-not-for-production") + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(strings.ToLower(email))) + mac.Write([]byte("|")) + mac.Write([]byte(scope)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// VerifyMuteToken returns true when token equals the HMAC for (email, scope) +// under key. Comparison is constant-time to prevent timing attacks. +func VerifyMuteToken(key []byte, email, scope, token string) bool { + want := DeriveMuteToken(key, email, scope) + return hmac.Equal([]byte(want), []byte(token)) +}