Skip to content

Commit 5721fce

Browse files
fix(worker): Wave 2 P1s — BugBash 2026-05-20
Eight cross-confirmed P1s from the BugBash 2026-05-20 master report, all worker-scoped. Each fix ships a CI-run regression test. MR-P1-16 — forwarder ledger ordering (claim-after-2xx). event_email_forwarder.go: the forwarder_sent ledger was claimed BEFORE SendEvent. A crash between claim-commit and send-completion left the ledger written with no email actually sent → next tick saw claimed= false → cursor advanced → silent permanent loss. Re-ordered to isSent() BEFORE the send (dedup), markSent() AFTER a confirmed 2xx (claim). Trades a crash-loss for an at-most-once duplicate on SIGKILL mid-POST (Brevo X-Mailin-Custom absorbs where honored) — a duplicate is strictly safer than a loss. Cross-confirmed by T4 P1-2 + T22 P1-2. Test: TestEventForwarder_CrashAfterSendBeforeClaim_NoLoss. T22 P1-1 — worker email-provider PII leak. brevo_provider.go (9 sites) + ses_provider.go (10 sites) logged evt.Recipient raw at INFO/WARN/ERROR. The L1 maskEmail fix in api 4078ca3 never touched the worker. Added worker/internal/email/ logsafe.go (mirror of api maskEmail) and applied at every site. Test: TestWorkerEmailProviders_NoRawRecipientInLogs (registry-style, 6 sub-tests across both providers + success/permanent/transient paths) + TestMaskEmail_BasicShapes. T6 P0-1 (worker half) — stack namespace teardown leak + PASS 5. ExpireStacksWorker carried nsPrefix="instant-apps-" (from cfg.KubeNamespaceApps), but real stack namespaces are "instant-stack-<id>". The safety guard refused every real stack namespace and returned nil-success → DELETE FROM stacks ran anyway → orphan namespace + pods + ingress + TLS forever with no DB pointer. Fixed by introducing ExpireStacksNamespacePrefix = "instant-stack-" and passing it at the wiring. Added PASS 5 to the orphan-sweep reconciler (mirror of the PASS 4 customer-NS sweep) to catch pre-fix orphans and guard against recurrence. Tests: TestExpireStacksNamespacePrefix_MatchesStackProviderContract, TestOrphanSweep_Pass5_ReclaimsOrphanedStackNamespace, TestOrphanSweep_Pass5_NoStackNamespaces_NoQuery. T20 P0-2 / P1-3 — Workers.Stop drain-before-cancel. Pre-fix order was `cancel(); client.Stop(ctx)` — cancelling workerCtx FIRST aborted every in-flight job, wasting the 30s graceful drain. River's Stop() is the graceful contract; the pre-fix code reimplemented StopAndCancel by accident. Reordered to drain first, cancel after. Test: TestWorkers_Stop_DrainBeforeCancel. T20 P1 — process-wide River JobTimeout. River's default JobTimeout is no timeout. A hung job (wedged provisioner gRPC / Razorpay TCP black-hole / k8s API stall) pinned its worker slot for the lifetime of the pod. Added globalJobTimeout = 20 minutes (covers the longest legitimate single-tick job — billing reconciler ~17min — without pinning runaways). Test: TestWorkers_GlobalJobTimeout_HasSensibleValue. T8 P1-1 / MR-P1-21 — entitlement reconciler uses resource.tier. shouldRegrade and both regrade paths resolved caps from teams.plan_tier — the team's CURRENT tier — instead of resources.tier (the per-row snapshot). A pro→hobby downgrade silently shrunk the paid customer's existing pro Postgres connection cap (and Redis maxmemory) to the hobby cap on the next reconciler tick, contradicting the documented "keep their tier" courtesy. Switched the source of truth to resources.tier. Test: TestEntitlementReconciler_DowngradedTeam_KeepsResourceTierCap. T8 P1-2 / MR-P1-22 — Mongo arm on the entitlement reconciler. The reconciler previously iterated Postgres + Redis only. Mongo was silently under-delivered on upgrade (hobby→pro left every Mongo resource at hobby caps until re-provision). Added sweepMongoEntitlements — iterates active Mongo resources, calls RegradeResource with the resource.tier snapshot. The provisioner currently returns skip_reason="unsupported resource type for regrade" for MONGODB so this is a hooked-up loud no-op (visible mongo_skipped counter on .completed), giving a future provisioner.regradeMongo implementation an attach point with zero worker-side changes (CLAUDE.md rule 22). Test: TestEntitlementReconciler_MongoArm_SweepsMongoResources. T21 P1-1 — remaining idle-tick INFO spam → DEBUG. Eight more high-frequency jobs still emitted INFO every 30-60s when idle: deploy_status_reconcile, deploy_notify_webhook, magic_link_reconciler, pending_deletion_expirer, deployment_expirer, provisioner_reconciler, customer_backup_runner, customer_restore_runner. Same email-noise fix pattern as 7169493 — idle path → DEBUG, INFO only on real work or failures. Cuts ~5,000+ noise lines/day. Test: TestIdleTickLogLevel_DemotedToDebug (source-level guard: enumerates all 8 files, asserts no `slog.Info(...completed..., 0,` shape reappears). T21 P1-2 — resource bearer tokens logged raw at ~20 sites. quota_infra.go (12), quota.go (6), storage.go (3), expire.go (4), provisioner_reconciler.go (4), entitlement_reconciler.go (6), quota_redis_eviction.go (4), provisioner/client.go (1). Added worker/internal/logsafe pkg with Token() (first 8 chars + length indicator + ***), applied at every site. Test: TestNoRawTokenSlogFields (walks every .go in worker/internal/, asserts no `"token", <var>` outside logsafe.Token wrap) + TestToken_NoLeakBeyondPrefix. Build / vet / test all green: `go build ./... && go vet ./... && go test ./... -count=1` passes locally (worker/internal/jobs 23.4s, 57 packages clean). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b10df7e commit 5721fce

32 files changed

Lines changed: 1849 additions & 170 deletions

internal/email/brevo_provider.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
260260
// the row instead of looping on a programmer bug.
261261
slog.Error("email.brevo.marshal_failed",
262262
"kind", evt.Kind,
263-
"recipient", evt.Recipient,
263+
"recipient", maskEmail(evt.Recipient),
264264
"error", err,
265265
)
266266
return &SendError{Class: SendClassPermanent, Cause: err, Message: "brevo: marshal payload"}
@@ -310,7 +310,7 @@ func (p *BrevoProvider) sendRaw(ctx context.Context, evt EventEmail) error {
310310
// past the row; the caller is supposed to render a subject.
311311
slog.Error("email.brevo.raw_missing_subject",
312312
"kind", evt.Kind,
313-
"recipient", evt.Recipient,
313+
"recipient", maskEmail(evt.Recipient),
314314
)
315315
return &SendError{
316316
Class: SendClassPermanent,
@@ -327,7 +327,7 @@ func (p *BrevoProvider) sendRaw(ctx context.Context, evt EventEmail) error {
327327
if err != nil {
328328
slog.Error("email.brevo.raw_marshal_failed",
329329
"kind", evt.Kind,
330-
"recipient", evt.Recipient,
330+
"recipient", maskEmail(evt.Recipient),
331331
"error", err,
332332
)
333333
return &SendError{Class: SendClassPermanent, Cause: err, Message: "brevo: raw marshal"}
@@ -363,7 +363,7 @@ func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []by
363363
// Network error, timeout, dns failure. Transient by definition.
364364
slog.Warn("email.brevo.http_failed",
365365
"kind", evt.Kind,
366-
"recipient", evt.Recipient,
366+
"recipient", maskEmail(evt.Recipient),
367367
"path", string(path),
368368
"error", err,
369369
)
@@ -376,7 +376,7 @@ func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []by
376376
case resp.StatusCode >= 200 && resp.StatusCode < 300:
377377
slog.Info("email.brevo.event_sent",
378378
"kind", evt.Kind,
379-
"recipient", evt.Recipient,
379+
"recipient", maskEmail(evt.Recipient),
380380
"status", resp.StatusCode,
381381
"path", string(path),
382382
"template_id", tmplID,
@@ -399,7 +399,7 @@ func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []by
399399
// payload rejects, not account auth failure.
400400
slog.Error("email.brevo.auth_wall",
401401
"kind", evt.Kind,
402-
"recipient", evt.Recipient,
402+
"recipient", maskEmail(evt.Recipient),
403403
"status", resp.StatusCode,
404404
"path", string(path),
405405
"body", string(respBody),
@@ -416,7 +416,7 @@ func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []by
416416
// the cursor holds and the row retries next tick.
417417
slog.Warn("email.brevo.rate_limited",
418418
"kind", evt.Kind,
419-
"recipient", evt.Recipient,
419+
"recipient", maskEmail(evt.Recipient),
420420
"status", resp.StatusCode,
421421
"path", string(path),
422422
"body", string(respBody),
@@ -432,7 +432,7 @@ func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []by
432432
// correct: holding it pins the whole queue on one bad row.
433433
slog.Error("email.brevo.permanent_4xx",
434434
"kind", evt.Kind,
435-
"recipient", evt.Recipient,
435+
"recipient", maskEmail(evt.Recipient),
436436
"status", resp.StatusCode,
437437
"path", string(path),
438438
"body", string(respBody),
@@ -446,7 +446,7 @@ func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []by
446446
// 5xx — Brevo upstream issue. Hold cursor; retry next tick.
447447
slog.Warn("email.brevo.transient_5xx",
448448
"kind", evt.Kind,
449-
"recipient", evt.Recipient,
449+
"recipient", maskEmail(evt.Recipient),
450450
"status", resp.StatusCode,
451451
"path", string(path),
452452
"body", string(respBody),

internal/email/logsafe.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package email
2+
3+
// logsafe.go — privacy-preserving helpers for slog lines in the worker
4+
// email providers.
5+
//
6+
// MR-P1-46 / T22 P1-1 (BugBash 2026-05-20): the L1 PII-masking fix
7+
// shipped in api `4078ca3` (commit "email.go:maskEmail") masked recipient
8+
// addresses in api-side slog lines but did NOT touch the worker email
9+
// providers (brevo_provider.go, ses_provider.go) — which are the
10+
// higher-volume emitters because every audit-driven lifecycle / quota /
11+
// expiry email flows through them. Prod logs (worker pod
12+
// `instant-worker-7f6d77699d-tt7gr`, commit_id=7169493) showed full
13+
// recipient addresses in cleartext at INFO/WARN/ERROR on every send —
14+
// PII shipped to New Relic. This file closes that gap with the same
15+
// algorithm the api uses.
16+
17+
import "strings"
18+
19+
// maskEmail returns a privacy-preserving rendering of a recipient
20+
// address for slog lines. Mirrors api/internal/email/email.go:maskEmail
21+
// and api/internal/models/MaskEmail.
22+
//
23+
// "alice@example.com" → "a***@example.com"
24+
// "a@example.com" → "a@example.com" (1-char local kept)
25+
// "" / "no-at-sign" / "@only" → returned unchanged (defensive)
26+
//
27+
// CLAUDE.md feedback memory: "no PII/tokens/secrets in any log line".
28+
func maskEmail(addr string) string {
29+
at := strings.LastIndex(addr, "@")
30+
if at <= 0 {
31+
return addr
32+
}
33+
local := addr[:at]
34+
domain := addr[at:]
35+
if len(local) == 1 {
36+
return local + domain
37+
}
38+
return local[:1] + "***" + domain
39+
}

internal/email/logsafe_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package email
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
11+
"testing"
12+
13+
sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
14+
"github.com/aws/aws-sdk-go-v2/aws"
15+
)
16+
17+
// TestMaskEmail_BasicShapes pins the masking algorithm — must match the
18+
// api maskEmail behaviour exactly.
19+
func TestMaskEmail_BasicShapes(t *testing.T) {
20+
cases := []struct {
21+
in, want string
22+
}{
23+
{"alice@example.com", "a***@example.com"},
24+
{"a@example.com", "a@example.com"}, // 1-char local preserved
25+
{"bb20-t7-1779218881@instanode-test.dev", "b***@instanode-test.dev"},
26+
{"mastermanas805@gmail.com", "m***@gmail.com"},
27+
{"@onlydomain.com", "@onlydomain.com"}, // empty local — return unchanged (defensive)
28+
{"no-at-sign", "no-at-sign"},
29+
{"", ""},
30+
}
31+
for _, c := range cases {
32+
if got := maskEmail(c.in); got != c.want {
33+
t.Errorf("maskEmail(%q) = %q; want %q", c.in, got, c.want)
34+
}
35+
}
36+
}
37+
38+
// captureSlog redirects the default slog logger into the returned buffer
39+
// for the duration of fn. Returns the captured text. Used by the provider
40+
// regression tests so they don't have to plumb a logger through.
41+
func captureSlog(t *testing.T, fn func()) string {
42+
t.Helper()
43+
var buf bytes.Buffer
44+
h := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
45+
orig := slog.Default()
46+
slog.SetDefault(slog.New(h))
47+
defer slog.SetDefault(orig)
48+
fn()
49+
return buf.String()
50+
}
51+
52+
// TestWorkerEmailProviders_NoRawRecipientInLogs is the T22 P1-1 / MR-P1-46
53+
// regression test.
54+
//
55+
// BUG (pre-fix): worker brevo_provider.go and ses_provider.go logged
56+
// `"recipient", evt.Recipient` raw at INFO/WARN/ERROR on every send. The
57+
// L1 PII-masking fix in api `4078ca3` masked api-side logs but did NOT
58+
// touch the worker providers — verified live in prod
59+
// (instant-worker pod commit_id=7169493 emitted full recipient strings
60+
// into NR Logs on every send).
61+
//
62+
// FIX: maskEmail applied at every `"recipient"` slog site in both
63+
// providers. This test stamps a unique recipient address, drives each
64+
// provider through a real send path that produces a `"recipient"` slog
65+
// field, and asserts the raw local part NEVER appears in the captured
66+
// log output — only the masked form. Registry-style: covers BOTH worker
67+
// providers in one table, so a future third provider that ships without
68+
// the maskEmail wrap fails this test.
69+
func TestWorkerEmailProviders_NoRawRecipientInLogs(t *testing.T) {
70+
const (
71+
rawRecipient = "regressioncanary12345@instanode-test.dev"
72+
rawLocal = "regressioncanary12345"
73+
wantMasked = "r***@instanode-test.dev"
74+
kind = "subscription.upgraded"
75+
)
76+
77+
t.Run("brevo_success_2xx_sent", func(t *testing.T) {
78+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+
_, _ = io.ReadAll(r.Body)
80+
w.WriteHeader(http.StatusCreated)
81+
_, _ = w.Write([]byte(`{"messageId":"x"}`))
82+
}))
83+
defer srv.Close()
84+
p, err := NewBrevoProvider(BrevoConfig{APIKey: "k", TemplateIDs: map[string]int{kind: 42}})
85+
if err != nil {
86+
t.Fatalf("NewBrevoProvider: %v", err)
87+
}
88+
p.url = srv.URL
89+
out := captureSlog(t, func() {
90+
_ = p.SendEvent(context.Background(), EventEmail{Kind: kind, Recipient: rawRecipient})
91+
})
92+
assertNoRawRecipient(t, "brevo:2xx", out, rawLocal, wantMasked)
93+
})
94+
95+
t.Run("brevo_permanent_4xx_logs_recipient", func(t *testing.T) {
96+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97+
_, _ = io.ReadAll(r.Body)
98+
w.WriteHeader(http.StatusBadRequest)
99+
_, _ = w.Write([]byte(`{"code":"bad_request"}`))
100+
}))
101+
defer srv.Close()
102+
p, err := NewBrevoProvider(BrevoConfig{APIKey: "k", TemplateIDs: map[string]int{kind: 42}})
103+
if err != nil {
104+
t.Fatalf("NewBrevoProvider: %v", err)
105+
}
106+
p.url = srv.URL
107+
out := captureSlog(t, func() {
108+
_ = p.SendEvent(context.Background(), EventEmail{Kind: kind, Recipient: rawRecipient})
109+
})
110+
assertNoRawRecipient(t, "brevo:4xx", out, rawLocal, wantMasked)
111+
})
112+
113+
t.Run("brevo_auth_wall_401", func(t *testing.T) {
114+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115+
_, _ = io.ReadAll(r.Body)
116+
w.WriteHeader(http.StatusUnauthorized)
117+
_, _ = w.Write([]byte(`{"code":"unauthorized"}`))
118+
}))
119+
defer srv.Close()
120+
p, err := NewBrevoProvider(BrevoConfig{APIKey: "k", TemplateIDs: map[string]int{kind: 42}})
121+
if err != nil {
122+
t.Fatalf("NewBrevoProvider: %v", err)
123+
}
124+
p.url = srv.URL
125+
out := captureSlog(t, func() {
126+
_ = p.SendEvent(context.Background(), EventEmail{Kind: kind, Recipient: rawRecipient})
127+
})
128+
assertNoRawRecipient(t, "brevo:auth_wall", out, rawLocal, wantMasked)
129+
})
130+
131+
t.Run("ses_success_sent", func(t *testing.T) {
132+
fake := &fakeSESClient{}
133+
p := &SESProvider{
134+
client: fake,
135+
fromEmail: "noreply@example.com",
136+
templates: map[string]string{kind: "tmpl-1"},
137+
}
138+
out := captureSlog(t, func() {
139+
_ = p.SendEvent(context.Background(), EventEmail{Kind: kind, Recipient: rawRecipient})
140+
})
141+
assertNoRawRecipient(t, "ses:success", out, rawLocal, wantMasked)
142+
})
143+
144+
t.Run("ses_permanent_rejected", func(t *testing.T) {
145+
fake := &fakeSESClient{err: &sestypes.MessageRejected{Message: aws.String("rejected")}}
146+
p := &SESProvider{
147+
client: fake,
148+
fromEmail: "noreply@example.com",
149+
templates: map[string]string{kind: "tmpl-1"},
150+
}
151+
out := captureSlog(t, func() {
152+
_ = p.SendEvent(context.Background(), EventEmail{Kind: kind, Recipient: rawRecipient})
153+
})
154+
assertNoRawRecipient(t, "ses:rejected", out, rawLocal, wantMasked)
155+
})
156+
157+
t.Run("ses_transient_throttling", func(t *testing.T) {
158+
fake := &fakeSESClient{err: &sestypes.TooManyRequestsException{Message: aws.String("rate")}}
159+
p := &SESProvider{
160+
client: fake,
161+
fromEmail: "noreply@example.com",
162+
templates: map[string]string{kind: "tmpl-1"},
163+
}
164+
out := captureSlog(t, func() {
165+
_ = p.SendEvent(context.Background(), EventEmail{Kind: kind, Recipient: rawRecipient})
166+
})
167+
assertNoRawRecipient(t, "ses:transient", out, rawLocal, wantMasked)
168+
})
169+
}
170+
171+
func assertNoRawRecipient(t *testing.T, label, out, rawLocal, wantMasked string) {
172+
t.Helper()
173+
if out == "" {
174+
t.Fatalf("%s: provider emitted no log output — cannot verify masking; update the harness so the recipient slog field still fires", label)
175+
}
176+
if strings.Contains(out, rawLocal) {
177+
t.Errorf("%s: provider log contains raw recipient local-part %q — PII leak.\nFull output:\n%s", label, rawLocal, out)
178+
}
179+
if !strings.Contains(out, wantMasked) {
180+
t.Errorf("%s: provider log does not contain masked form %q — masking was not applied.\nFull output:\n%s", label, wantMasked, out)
181+
}
182+
}

internal/email/ses_provider.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func (p *SESProvider) SendEvent(ctx context.Context, evt EventEmail) error {
157157
// instead of looping forever on a programmer bug.
158158
slog.Error("email.ses.marshal_failed",
159159
"kind", evt.Kind,
160-
"recipient", evt.Recipient,
160+
"recipient", maskEmail(evt.Recipient),
161161
"error", err,
162162
)
163163
return &SendError{Class: SendClassPermanent, Cause: err, Message: "ses: marshal params"}
@@ -183,7 +183,7 @@ func (p *SESProvider) SendEvent(ctx context.Context, evt EventEmail) error {
183183

184184
slog.Info("email.ses.event_sent",
185185
"kind", evt.Kind,
186-
"recipient", evt.Recipient,
186+
"recipient", maskEmail(evt.Recipient),
187187
"template", tmplName,
188188
)
189189
return nil
@@ -212,7 +212,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
212212
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
213213
slog.Warn("email.ses.context_canceled",
214214
"kind", evt.Kind,
215-
"recipient", evt.Recipient,
215+
"recipient", maskEmail(evt.Recipient),
216216
"error", err,
217217
)
218218
return &SendError{Class: SendClassTransient, Cause: err, Message: "ses: context canceled"}
@@ -234,7 +234,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
234234
"SendingPausedException":
235235
slog.Warn("email.ses.transient_throttle",
236236
"kind", evt.Kind,
237-
"recipient", evt.Recipient,
237+
"recipient", maskEmail(evt.Recipient),
238238
"code", code,
239239
"message", apiErr.ErrorMessage(),
240240
)
@@ -253,7 +253,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
253253
"SignatureDoesNotMatch", "AccessDeniedException":
254254
slog.Error("email.ses.permanent",
255255
"kind", evt.Kind,
256-
"recipient", evt.Recipient,
256+
"recipient", maskEmail(evt.Recipient),
257257
"code", code,
258258
"template", tmplName,
259259
"message", apiErr.ErrorMessage(),
@@ -268,7 +268,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
268268
case "InternalServiceErrorException", "ServiceUnavailableException":
269269
slog.Warn("email.ses.transient_5xx",
270270
"kind", evt.Kind,
271-
"recipient", evt.Recipient,
271+
"recipient", maskEmail(evt.Recipient),
272272
"code", code,
273273
"message", apiErr.ErrorMessage(),
274274
)
@@ -285,7 +285,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
285285
case smithy.FaultServer:
286286
slog.Warn("email.ses.transient_server_fault",
287287
"kind", evt.Kind,
288-
"recipient", evt.Recipient,
288+
"recipient", maskEmail(evt.Recipient),
289289
"code", code,
290290
"message", apiErr.ErrorMessage(),
291291
)
@@ -299,7 +299,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
299299
// row can't pin the queue forever.
300300
slog.Error("email.ses.permanent_client_fault",
301301
"kind", evt.Kind,
302-
"recipient", evt.Recipient,
302+
"recipient", maskEmail(evt.Recipient),
303303
"code", code,
304304
"template", tmplName,
305305
"message", apiErr.ErrorMessage(),
@@ -318,7 +318,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
318318
if errors.As(err, &netErr) {
319319
slog.Warn("email.ses.network",
320320
"kind", evt.Kind,
321-
"recipient", evt.Recipient,
321+
"recipient", maskEmail(evt.Recipient),
322322
"error", err,
323323
)
324324
return &SendError{Class: SendClassTransient, Cause: err, Message: "ses: network"}
@@ -328,7 +328,7 @@ func classifySESError(err error, evt EventEmail, tmplName string) error {
328328
// cursor (mirrors the package-level ClassOf default).
329329
slog.Warn("email.ses.unknown_error",
330330
"kind", evt.Kind,
331-
"recipient", evt.Recipient,
331+
"recipient", maskEmail(evt.Recipient),
332332
"error", err,
333333
)
334334
return &SendError{Class: SendClassTransient, Cause: err, Message: "ses: unknown error"}

0 commit comments

Comments
 (0)