Skip to content

Commit b19312e

Browse files
committed
feat(notifications): per-recipient mute + List-Unsubscribe (closes #297)
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.
1 parent f0660b9 commit b19312e

13 files changed

Lines changed: 949 additions & 39 deletions
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"html/template"
7+
"os"
8+
"strings"
9+
10+
"github.com/LeanerCloud/CUDly/pkg/common"
11+
"github.com/LeanerCloud/CUDly/pkg/logging"
12+
"github.com/aws/aws-lambda-go/events"
13+
)
14+
15+
// mutePageCSP is the Content-Security-Policy for the unsubscribe confirmation
16+
// page. The page is intentionally minimal (no external scripts or styles), so
17+
// we can lock it down tightly.
18+
const mutePageCSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
19+
20+
// unsubscribeConfirmTmpl is the HTML confirmation page rendered after a
21+
// successful one-click unsubscribe. No user-supplied data is interpolated via
22+
// {{.}} without html/template escaping, so there is no XSS vector.
23+
var unsubscribeConfirmTmpl = template.Must(template.New("unsub").Parse(`<!DOCTYPE html>
24+
<html lang="en">
25+
<head>
26+
<meta charset="UTF-8">
27+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
28+
<title>Unsubscribed</title>
29+
<style>
30+
body{font-family:sans-serif;max-width:480px;margin:80px auto;padding:0 1rem;color:#222}
31+
h1{font-size:1.4rem}
32+
p{line-height:1.6}
33+
</style>
34+
</head>
35+
<body>
36+
<h1>You have been unsubscribed.</h1>
37+
<p>You will no longer receive <strong>{{.ScopeLabel}}</strong> emails at this address.</p>
38+
<p>This preference is saved. You do not need to click again.</p>
39+
</body>
40+
</html>
41+
`))
42+
43+
// scopeLabel returns a human-readable label for a notification scope.
44+
func scopeLabel(scope string) string {
45+
switch scope {
46+
case string(common.ScopePurchaseApprovals):
47+
return "purchase approval request"
48+
case string(common.ScopeRIExchangeApprovals):
49+
return "RI exchange approval request"
50+
default:
51+
return "notification"
52+
}
53+
}
54+
55+
// muteSecretKey loads the NOTIFICATION_MUTE_SECRET env var as bytes. Returns
56+
// nil when unset, which causes DeriveMuteToken to fall back to its dev key.
57+
func muteSecretKey() []byte {
58+
v := os.Getenv("NOTIFICATION_MUTE_SECRET")
59+
if v == "" {
60+
return nil
61+
}
62+
return []byte(v)
63+
}
64+
65+
// unsubscribeHandler handles GET /api/notifications/unsubscribe.
66+
// The URL carries a signed token that encodes (email, scope); the handler
67+
// verifies the HMAC, upserts the mute row, and returns a confirmation page.
68+
//
69+
// Auth: AuthPublic (token-based, no login required — mirrors approve/cancel).
70+
func (h *Handler) unsubscribeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) {
71+
token := req.QueryStringParameters["token"]
72+
email := req.QueryStringParameters["email"]
73+
scope := req.QueryStringParameters["scope"]
74+
75+
if token == "" || email == "" || scope == "" {
76+
return nil, NewClientError(400, "token, email and scope are required")
77+
}
78+
79+
// Reject unknown scopes early so we never create phantom rows.
80+
validScope := scope == string(common.ScopePurchaseApprovals) ||
81+
scope == string(common.ScopeRIExchangeApprovals)
82+
if !validScope {
83+
return nil, NewClientError(400, fmt.Sprintf("unknown notification scope: %s", scope))
84+
}
85+
86+
if !common.VerifyMuteToken(muteSecretKey(), email, scope, token) {
87+
logging.Warnf("notifications/unsubscribe: invalid token for scope=%s", scope)
88+
return nil, NewClientError(401, "invalid or expired unsubscribe token")
89+
}
90+
91+
if err := h.config.UpsertNotificationMute(ctx, email, scope, token); err != nil {
92+
logging.Errorf("notifications/unsubscribe: store error: %v", err)
93+
return nil, fmt.Errorf("could not save unsubscribe preference: %w", err)
94+
}
95+
96+
logging.Infof("notifications/unsubscribe: muted scope=%s for %s", scope, redactEmailLocal(email))
97+
98+
var buf strings.Builder
99+
if err := unsubscribeConfirmTmpl.Execute(&buf, struct{ ScopeLabel string }{
100+
ScopeLabel: scopeLabel(scope),
101+
}); err != nil {
102+
return nil, fmt.Errorf("render unsubscribe page: %w", err)
103+
}
104+
105+
return &rawResponse{
106+
contentType: "text/html; charset=utf-8",
107+
body: buf.String(),
108+
csp: mutePageCSP,
109+
}, nil
110+
}
111+
112+
// redactEmailLocal returns just the domain part with the local masked, e.g.
113+
// "us***@example.com". Reuses the same masking logic as email/sender.go but
114+
// without importing that package into api (avoids a dependency cycle).
115+
func redactEmailLocal(email string) string {
116+
at := strings.LastIndex(email, "@")
117+
if at < 0 {
118+
return "***"
119+
}
120+
local := email[:at]
121+
domain := email[at:] // includes '@'
122+
if len(local) <= 2 {
123+
return "***" + domain
124+
}
125+
return local[:2] + "***" + domain
126+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/LeanerCloud/CUDly/pkg/common"
9+
"github.com/aws/aws-lambda-go/events"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/mock"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// ---------------------------------------------------------------------------
16+
// GET /api/notifications/unsubscribe
17+
// ---------------------------------------------------------------------------
18+
19+
func validUnsubToken(email, scope string) string {
20+
return common.DeriveMuteToken(nil, email, scope)
21+
}
22+
23+
func TestUnsubscribeHandler_Success(t *testing.T) {
24+
ctx := context.Background()
25+
email := "user@example.com"
26+
scope := string(common.ScopePurchaseApprovals)
27+
token := validUnsubToken(email, scope)
28+
29+
mockStore := new(MockConfigStore)
30+
mockStore.On("UpsertNotificationMute", ctx, email, scope, token).Return(nil)
31+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
32+
33+
h := &Handler{config: mockStore}
34+
r := newTestRouter(h)
35+
36+
req := &events.LambdaFunctionURLRequest{
37+
QueryStringParameters: map[string]string{
38+
"token": token,
39+
"email": email,
40+
"scope": scope,
41+
},
42+
}
43+
result, err := r.unsubscribeHandler(ctx, req, nil)
44+
require.NoError(t, err)
45+
raw, ok := result.(*rawResponse)
46+
require.True(t, ok, "expected *rawResponse")
47+
assert.Equal(t, "text/html; charset=utf-8", raw.contentType)
48+
assert.Contains(t, raw.body, "Unsubscribed")
49+
assert.Contains(t, raw.body, "purchase approval request")
50+
}
51+
52+
func TestUnsubscribeHandler_ForgedToken_Returns401(t *testing.T) {
53+
ctx := context.Background()
54+
req := &events.LambdaFunctionURLRequest{
55+
QueryStringParameters: map[string]string{
56+
"token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
57+
"email": "attacker@example.com",
58+
"scope": string(common.ScopePurchaseApprovals),
59+
},
60+
}
61+
h := &Handler{config: new(MockConfigStore)}
62+
r := newTestRouter(h)
63+
64+
_, err := r.unsubscribeHandler(ctx, req, nil)
65+
require.Error(t, err)
66+
ce, ok := IsClientError(err)
67+
require.True(t, ok)
68+
assert.Equal(t, 401, ce.code)
69+
}
70+
71+
func TestUnsubscribeHandler_MissingParams_Returns400(t *testing.T) {
72+
ctx := context.Background()
73+
h := &Handler{config: new(MockConfigStore)}
74+
r := newTestRouter(h)
75+
76+
req := &events.LambdaFunctionURLRequest{
77+
QueryStringParameters: map[string]string{
78+
"token": "something",
79+
// email and scope missing
80+
},
81+
}
82+
_, err := r.unsubscribeHandler(ctx, req, nil)
83+
require.Error(t, err)
84+
ce, ok := IsClientError(err)
85+
require.True(t, ok)
86+
assert.Equal(t, 400, ce.code)
87+
}
88+
89+
func TestUnsubscribeHandler_UnknownScope_Returns400(t *testing.T) {
90+
ctx := context.Background()
91+
email := "user@example.com"
92+
scope := "unknown_scope"
93+
// Use the dev-key token so HMAC passes but scope guard fires first.
94+
token := common.DeriveMuteToken(nil, email, scope)
95+
96+
req := &events.LambdaFunctionURLRequest{
97+
QueryStringParameters: map[string]string{
98+
"token": token,
99+
"email": email,
100+
"scope": scope,
101+
},
102+
}
103+
h := &Handler{config: new(MockConfigStore)}
104+
r := newTestRouter(h)
105+
106+
_, err := r.unsubscribeHandler(ctx, req, nil)
107+
require.Error(t, err)
108+
ce, ok := IsClientError(err)
109+
require.True(t, ok)
110+
assert.Equal(t, 400, ce.code)
111+
}
112+
113+
func TestUnsubscribeHandler_StoreError_Returns500(t *testing.T) {
114+
ctx := context.Background()
115+
email := "user@example.com"
116+
scope := string(common.ScopePurchaseApprovals)
117+
token := validUnsubToken(email, scope)
118+
119+
mockStore := new(MockConfigStore)
120+
mockStore.On("UpsertNotificationMute", mock.Anything, email, scope, token).
121+
Return(assert.AnError)
122+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
123+
124+
h := &Handler{config: mockStore}
125+
r := newTestRouter(h)
126+
127+
req := &events.LambdaFunctionURLRequest{
128+
QueryStringParameters: map[string]string{
129+
"token": token,
130+
"email": email,
131+
"scope": scope,
132+
},
133+
}
134+
_, err := r.unsubscribeHandler(ctx, req, nil)
135+
require.Error(t, err)
136+
_, isClient := IsClientError(err)
137+
assert.False(t, isClient, "store error should be a 500, not a client error")
138+
}
139+
140+
// ---------------------------------------------------------------------------
141+
// scopeLabel helper
142+
// ---------------------------------------------------------------------------
143+
144+
func TestScopeLabel_KnownScopes(t *testing.T) {
145+
assert.Equal(t, "purchase approval request", scopeLabel(string(common.ScopePurchaseApprovals)))
146+
assert.Equal(t, "RI exchange approval request", scopeLabel(string(common.ScopeRIExchangeApprovals)))
147+
assert.Equal(t, "notification", scopeLabel("bogus"))
148+
}
149+
150+
// ---------------------------------------------------------------------------
151+
// redactEmailLocal
152+
// ---------------------------------------------------------------------------
153+
154+
func TestRedactEmailLocal(t *testing.T) {
155+
cases := []struct {
156+
in, want string
157+
}{
158+
{"user@example.com", "us***@example.com"},
159+
{"ab@x.com", "***@x.com"},
160+
{"a@x.com", "***@x.com"},
161+
{"noemail", "***"},
162+
}
163+
for _, c := range cases {
164+
assert.Equal(t, c.want, redactEmailLocal(c.in), "input: %s", c.in)
165+
}
166+
}
167+
168+
// ---------------------------------------------------------------------------
169+
// isPublicEndpoint includes /api/notifications/unsubscribe
170+
// ---------------------------------------------------------------------------
171+
172+
func TestIsPublicEndpoint_UnsubscribePath(t *testing.T) {
173+
h := &Handler{}
174+
assert.True(t, h.isPublicEndpoint("/api/notifications/unsubscribe"))
175+
assert.True(t, h.isPublicEndpoint("/api/notifications/unsubscribe?token=x&email=y&scope=z"))
176+
}
177+
178+
// ---------------------------------------------------------------------------
179+
// Route is registered (router smoke test)
180+
// ---------------------------------------------------------------------------
181+
182+
func TestRouter_UnsubscribeRoute_Registered(t *testing.T) {
183+
// Verify the route is wired: an unsigned token returns 401, which can only
184+
// happen if the router dispatched to the correct handler.
185+
ctx := context.Background()
186+
h := &Handler{config: new(MockConfigStore)}
187+
r := NewRouter(h)
188+
189+
req := &events.LambdaFunctionURLRequest{
190+
RequestContext: events.LambdaFunctionURLRequestContext{
191+
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{
192+
Method: "GET",
193+
Path: "/api/notifications/unsubscribe",
194+
},
195+
},
196+
QueryStringParameters: map[string]string{
197+
"token": strings.Repeat("a", 64),
198+
"email": "user@example.com",
199+
"scope": string(common.ScopePurchaseApprovals),
200+
},
201+
}
202+
_, err := r.Route(ctx, "GET", "/api/notifications/unsubscribe", req)
203+
require.Error(t, err)
204+
ce, ok := IsClientError(err)
205+
require.True(t, ok)
206+
assert.Equal(t, 401, ce.code)
207+
}

‎internal/api/middleware.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func (h *Handler) isPublicEndpoint(path string) bool {
3131
"/api/auth/forgot-password",
3232
"/api/auth/reset-password",
3333
"/api/register/", // GET /api/register/:token (trailing slash avoids matching /api/registrations)
34+
"/api/notifications/unsubscribe",
3435
"/docs",
3536
"/api/docs",
3637
}

‎internal/api/router.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ func (r *Router) registerRoutes() {
320320
{ExactPath: "/api/ladder/configs", Method: "GET", Handler: r.getLadderConfigsHandler, Auth: AuthUser},
321321
{ExactPath: "/api/ladder/configs", Method: "PUT", Handler: r.upsertLadderConfigHandler, Auth: AuthUser},
322322

323+
// Notification one-click unsubscribe (RFC 8058). AuthPublic: the signed
324+
// token in the query string is the credential (mirrors approve/cancel).
325+
{ExactPath: "/api/notifications/unsubscribe", Method: "GET", Handler: r.unsubscribeHandler, Auth: AuthPublic},
326+
323327
// Account self-registration (public, called by Terraform during federation IaC apply)
324328
{ExactPath: "/api/register", Method: "POST", Handler: r.submitRegistrationHandler, Auth: AuthPublic},
325329
{PathPrefix: "/api/register/", Method: "GET", Handler: r.getRegistrationStatusHandler, Auth: AuthPublic},
@@ -962,3 +966,7 @@ func (r *Router) getLadderConfigsHandler(ctx context.Context, req *events.Lambda
962966
func (r *Router) upsertLadderConfigHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) {
963967
return r.h.upsertLadderConfig(ctx, req)
964968
}
969+
970+
func (r *Router) unsubscribeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) {
971+
return r.h.unsubscribeHandler(ctx, req, params)
972+
}

‎internal/config/interfaces.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,4 +436,13 @@ type StoreInterface interface {
436436
SaveLadderTranches(ctx context.Context, tranches []LadderTrancheDB) error
437437
LatestLadderRunStartedAt(ctx context.Context, configID string) (*time.Time, error)
438438
TransitionLadderRunStatus(ctx context.Context, id string, fromStatuses []ladder.RunStatus, toStatus ladder.RunStatus) (*LadderRunDB, error)
439+
440+
// Notification mutes (issue #297 / migration 000078).
441+
// UpsertNotificationMute inserts or updates a mute row for (email, scope).
442+
// Idempotent: calling it again for an already-muted address is a no-op on
443+
// muted_at but does replace unmute_token if the token changes.
444+
UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error
445+
// IsNotificationMuted returns true when (email, scope) has a row in
446+
// muted_recipients. The email comparison is case-insensitive.
447+
IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error)
439448
}

0 commit comments

Comments
 (0)