Skip to content

Commit 07567a8

Browse files
committed
fix(notifications): wire per-recipient mute + List-Unsubscribe into production sender (refs #297)
The per-recipient mute + List-Unsubscribe feature was dead in production: the sender returned by email.NewSenderFromEnvironment was never decorated with the mute checker or the unsubscribe base URL, so the bare sender's mute checker was always nil and no List-Unsubscribe header was ever emitted. The SMTP transport bypassed the mute logic entirely. - app.go: decorate the factory-produced sender via a new decorateSenderWithMute helper that type-switches on *email.Sender / *email.SMTPSender and calls WithMuteChecker(configStore) + WithUnsubscribeBaseURL(trimmed DASHBOARD_URL). configStore (config.StoreInterface) satisfies email.MuteChecker via IsNotificationMuted. The base URL reuses the same DASHBOARD_URL the email templates already use, trimmed to match resolveOIDCIssuerURL. - email/mute.go: extract the transport-agnostic mute + List-Unsubscribe decision logic (isRecipientMuted, filterMutedRecipients, unsubscribeURLFor, unsubscribeHeaderValuesFor) into shared free functions so the SES and SMTP paths cannot diverge; *Sender delegates to them. - email/smtp_sender.go: add muteChecker + unsubscribeBaseURL fields and WithMuteChecker/WithUnsubscribeBaseURL mirroring *Sender, and apply the mute skip + CC filter + RFC 8058 List-Unsubscribe headers in SendPurchaseApprovalRequest. - Wiring tests exercise the real factory/decoration path (decorateSenderWithMute on a factory-shaped sender) and the SMTP transport: a muted recipient is suppressed and the List-Unsubscribe header is emitted with the wired base URL. Both fail against the pre-wiring behavior (nil checker, no base URL).
1 parent 958203b commit 07567a8

6 files changed

Lines changed: 458 additions & 39 deletions

File tree

‎internal/email/mute.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package email
2+
3+
import (
4+
"context"
5+
"net/url"
6+
7+
"github.com/LeanerCloud/CUDly/pkg/common"
8+
"github.com/LeanerCloud/CUDly/pkg/logging"
9+
)
10+
11+
// This file holds the transport-agnostic mute + List-Unsubscribe logic shared
12+
// by the SES (*Sender) and SMTP (*SMTPSender) paths. Both transports must apply
13+
// the same per-recipient mute suppression, CC filtering, and RFC 8058
14+
// List-Unsubscribe headers; keeping the logic here (instead of duplicating it
15+
// per transport) prevents the two paths from diverging.
16+
17+
// isRecipientMuted returns true when (email, scope) is muted. A nil checker or a
18+
// store error is treated as "not muted" (fail-open) so a transient DB outage
19+
// does not silently block approval emails.
20+
func isRecipientMuted(ctx context.Context, mc MuteChecker, email, scope string) bool {
21+
if mc == nil {
22+
return false
23+
}
24+
muted, err := mc.IsNotificationMuted(ctx, email, scope)
25+
if err != nil {
26+
logging.Warnf("email: mute check failed for scope=%s: %v", scope, err)
27+
return false
28+
}
29+
return muted
30+
}
31+
32+
// filterMutedRecipients returns a copy of addrs with any address muted for scope
33+
// removed. The original slice is not modified. Errors from the mute store are
34+
// treated as "not muted" (fail-open).
35+
func filterMutedRecipients(ctx context.Context, mc MuteChecker, addrs []string, scope string) []string {
36+
if mc == nil || len(addrs) == 0 {
37+
return addrs
38+
}
39+
out := make([]string, 0, len(addrs))
40+
for _, addr := range addrs {
41+
if !isRecipientMuted(ctx, mc, addr, scope) {
42+
out = append(out, addr)
43+
}
44+
}
45+
return out
46+
}
47+
48+
// unsubscribeURLFor constructs the one-click unsubscribe URL for the given
49+
// (email, scope) pair. Returns "" when baseURL is empty.
50+
func unsubscribeURLFor(baseURL, email, scope string) string {
51+
if baseURL == "" {
52+
return ""
53+
}
54+
token := common.DeriveMuteToken(muteKey(), email, scope)
55+
q := url.Values{
56+
"token": {token},
57+
"email": {email},
58+
"scope": {scope},
59+
}
60+
return baseURL + "/api/notifications/unsubscribe?" + q.Encode()
61+
}
62+
63+
// unsubscribeHeaderValuesFor returns the List-Unsubscribe and
64+
// List-Unsubscribe-Post header values (RFC 8058) for the given (email, scope)
65+
// pair. Returns ("", "") when baseURL is empty.
66+
func unsubscribeHeaderValuesFor(baseURL, email, scope string) (headerValue, postValue string) {
67+
unsubURL := unsubscribeURLFor(baseURL, email, scope)
68+
if unsubURL == "" {
69+
return "", ""
70+
}
71+
return "<" + unsubURL + ">", "List-Unsubscribe=One-Click"
72+
}

‎internal/email/sender.go‎

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import (
55
"context"
66
"errors"
77
"fmt"
8-
"net/url"
98
"os"
109
"strings"
1110

12-
"github.com/LeanerCloud/CUDly/pkg/common"
1311
"github.com/LeanerCloud/CUDly/pkg/logging"
1412
"github.com/aws/aws-sdk-go-v2/aws"
1513
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -140,60 +138,29 @@ func muteKey() []byte {
140138
// buildUnsubscribeURL constructs the one-click unsubscribe URL for the given
141139
// (email, scope) pair. Returns ("", "") when unsubscribeBaseURL is empty.
142140
func (s *Sender) buildUnsubscribeURL(email, scope string) (unsubURL, mailtoURL string) {
143-
if s.unsubscribeBaseURL == "" {
144-
return "", ""
145-
}
146-
token := common.DeriveMuteToken(muteKey(), email, scope)
147-
q := url.Values{
148-
"token": {token},
149-
"email": {email},
150-
"scope": {scope},
151-
}
152-
unsubURL = s.unsubscribeBaseURL + "/api/notifications/unsubscribe?" + q.Encode()
153-
return unsubURL, ""
141+
return unsubscribeURLFor(s.unsubscribeBaseURL, email, scope), ""
154142
}
155143

156144
// listUnsubscribeHeaders returns the List-Unsubscribe and List-Unsubscribe-Post
157145
// header values for the given (email, scope) pair (RFC 8058).
158146
// Returns ("", "") when no base URL is configured.
159147
func (s *Sender) listUnsubscribeHeaders(email, scope string) (headerValue, postValue string) {
160-
unsubURL, _ := s.buildUnsubscribeURL(email, scope)
161-
if unsubURL == "" {
162-
return "", ""
163-
}
164-
return "<" + unsubURL + ">", "List-Unsubscribe=One-Click"
148+
return unsubscribeHeaderValuesFor(s.unsubscribeBaseURL, email, scope)
165149
}
166150

167151
// isMuted returns true when the given address is muted for this scope. When the
168152
// mute checker is nil or returns an error the address is treated as not muted so
169153
// a transient DB outage doesn't silently block approval emails.
170154
func (s *Sender) isMuted(ctx context.Context, email, scope string) bool {
171-
if s.muteChecker == nil {
172-
return false
173-
}
174-
muted, err := s.muteChecker.IsNotificationMuted(ctx, email, scope)
175-
if err != nil {
176-
logging.Warnf("email: mute check failed for scope=%s: %v", scope, err)
177-
return false
178-
}
179-
return muted
155+
return isRecipientMuted(ctx, s.muteChecker, email, scope)
180156
}
181157

182158
// filterMutedAddresses returns a copy of addrs with any muted (for scope)
183159
// entries removed. The original slice is not modified. Errors from the mute
184160
// store are treated as "not muted" (fail-open) so a DB hiccup does not
185161
// silently suppress approval emails.
186162
func (s *Sender) filterMutedAddresses(ctx context.Context, addrs []string, scope string) []string {
187-
if s.muteChecker == nil || len(addrs) == 0 {
188-
return addrs
189-
}
190-
out := make([]string, 0, len(addrs))
191-
for _, addr := range addrs {
192-
if !s.isMuted(ctx, addr, scope) {
193-
out = append(out, addr)
194-
}
195-
}
196-
return out
163+
return filterMutedRecipients(ctx, s.muteChecker, addrs, scope)
197164
}
198165

199166
// SendNotification sends a notification email via SNS

‎internal/email/smtp_mute_test.go‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package email
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/LeanerCloud/CUDly/pkg/common"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// smtpApprovalData builds a minimal purchase-approval NotificationData.
14+
func smtpApprovalData(recipient string) NotificationData {
15+
return NotificationData{
16+
RecipientEmail: recipient,
17+
DashboardURL: "https://dash.example.com",
18+
ApprovalToken: "tok",
19+
Recommendations: []RecommendationSummary{
20+
{Service: "ec2", Region: "us-east-1", Count: 1, MonthlySavings: 100},
21+
},
22+
}
23+
}
24+
25+
// TestSMTPSender_PurchaseApproval_MutedRecipient_NoSend verifies the SMTP
26+
// transport applies the same per-recipient mute suppression as SES: a muted
27+
// recipient never reaches the wire (no DATA section is transmitted).
28+
func TestSMTPSender_PurchaseApproval_MutedRecipient_NoSend(t *testing.T) {
29+
server := newMockSMTPServer(t, false)
30+
server.start(t)
31+
defer server.stop()
32+
33+
base := &SMTPSender{
34+
host: "127.0.0.1",
35+
port: server.port,
36+
fromEmail: "sender@test.com",
37+
useTLS: false,
38+
}
39+
mc := &wiringMuteCheckerSMTP{muted: map[string]bool{"muted@test.com": true}}
40+
sender := base.WithMuteChecker(mc).WithUnsubscribeBaseURL("https://dash.example.com")
41+
42+
err := sender.SendPurchaseApprovalRequest(context.Background(), smtpApprovalData("muted@test.com"))
43+
require.NoError(t, err)
44+
45+
server.stop() // flush the connection goroutine before reading receivedMsg
46+
server.mu.Lock()
47+
got := server.receivedMsg
48+
server.mu.Unlock()
49+
assert.NotContains(t, got, "Purchase Approval Required",
50+
"muted recipient must not have a message body transmitted over SMTP")
51+
}
52+
53+
// TestSMTPSender_PurchaseApproval_EmitsListUnsubscribe verifies the SMTP
54+
// approval send attaches the RFC 8058 List-Unsubscribe header sourced from the
55+
// wired-in unsubscribe base URL.
56+
func TestSMTPSender_PurchaseApproval_EmitsListUnsubscribe(t *testing.T) {
57+
server := newMockSMTPServer(t, false)
58+
server.start(t)
59+
defer server.stop()
60+
61+
base := &SMTPSender{
62+
host: "127.0.0.1",
63+
port: server.port,
64+
fromEmail: "sender@test.com",
65+
useTLS: false,
66+
}
67+
mc := &wiringMuteCheckerSMTP{muted: map[string]bool{}}
68+
sender := base.WithMuteChecker(mc).WithUnsubscribeBaseURL("https://dash.example.com")
69+
70+
err := sender.SendPurchaseApprovalRequest(context.Background(), smtpApprovalData("approver@test.com"))
71+
require.NoError(t, err)
72+
73+
server.stop()
74+
server.mu.Lock()
75+
got := server.receivedMsg
76+
server.mu.Unlock()
77+
assert.Contains(t, got, "List-Unsubscribe:",
78+
"SMTP approval send must carry a List-Unsubscribe header")
79+
assert.Contains(t, got, "https://dash.example.com/api/notifications/unsubscribe")
80+
assert.Contains(t, got, "scope="+string(common.ScopePurchaseApprovals))
81+
assert.True(t, strings.Contains(got, "List-Unsubscribe-Post:"),
82+
"SMTP approval send must carry a List-Unsubscribe-Post header")
83+
}
84+
85+
type wiringMuteCheckerSMTP struct {
86+
muted map[string]bool
87+
}
88+
89+
func (w *wiringMuteCheckerSMTP) IsNotificationMuted(_ context.Context, recipientEmail, _ string) (bool, error) {
90+
return w.muted[recipientEmail], nil
91+
}

0 commit comments

Comments
 (0)