Repository navigation
fix(email): plain-text renderers, address redaction, per-scope mute failure and SMTP no-recipient parity - #1547
Conversation
…s, per-scope mute failure, SMTP no-recipient parity - templates: RenderPurchaseScheduledDelayEmail and RenderPurchaseExecutedNotificationEmail render through text/template like every other plain-text body. html/template turned & into & in a copied revoke URL, entity-encoded requester names and addresses, and rendered the executed notice's plain-text revoke token as &LeanerCloud#43;. - sender/smtp_sender: redact the recipient in the SES verification log line and in the four SMTP SendToEmailWithCC/Multipart Debugf lines. - mute: a mute-store error fails open (send) only for the purchase_approvals and ri_exchange_approvals scopes; any other scope is informational and fails closed (don't send). - smtp_sender: SendRIExchangePendingApproval, SendPurchaseApprovalRequest, SendPurchaseScheduledNotification and SendPurchaseExecutedNotification return ErrNoRecipient on an empty data.RecipientEmail, as SES does, instead of mailing the token-bearing body to the static notify inbox. Refs LeanerCloud#111 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
govulncheck in the Docker image build now fails on these two advisories, both fixed in x/net v0.62.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rG1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
Fixes the four
internal/emailitems of the 2026-07-28 review tracker: the plain-text renderer item plus A06-005, the raw-address log item plus A06-012, the mute fail-open item, and the SES/SMTP no-recipient item plus A06-014. Each was re-checked againstmainat7b86e386and still applied.Refs #111
Root cause and fix
1. Plain-text bodies rendered through
html/template(internal/email/templates.go)RenderPurchaseScheduledDelayEmailandRenderPurchaseExecutedNotificationEmailcalledrenderTemplate, the HTML renderer whose own doc comment says never to use it for plain text. Both now callrenderTextTemplate, like every other plain-text renderer. The executed notice was already broken onmain, beyond the latent&case the issue describes: its plain-text revoke URL rendered the url-escaped token through HTML escaping (?token=tok+en...). The copy-paste fallback link therefore carried an HTML entity.executed_notification_test.gohad pinned that escaped form for the text half; it now expects the working URL (tok+en%2F...). The HTML half is unchanged and still pinned to+, which mail clients decode.2. Raw recipient addresses in logs (
internal/email/sender.go,internal/email/smtp_sender.go)createVerificationRequest(SES sandbox path, Info level) and the fourDebugflines inSMTPSender.SendToEmailWithCC/SendToEmailWithCCMultipartnow logredactEmail(toEmail), matching the other log lines on these paths. The full address is still used in the user-facing sandbox error, where it is intentional.3. Mute check failed open for every scope (
internal/email/mute.go)On a mute-store error,
isRecipientMutednow asksmuteCheckFailsOpen(scope):purchase_approvalsandri_exchange_approvalsfail open (send), so a DB blip can't strand a purchase or exchange waiting on its approval email.filterMutedRecipients(CC list) andprepareMuteAwareDeliveryfollow the same rule. TheMuteChecker == nilbehavior (not configured, so nothing is muted) is unchanged. Today only the two approval scopes exist (common.MuteNotifScope), so behavior on current senders does not change. The rule is now explicit, and a new informational scope gets the safe default.4. SMTP fell back to
NOTIFY_EMAILfor token-bearing mail (internal/email/smtp_sender.go)SMTPSender.SendRIExchangePendingApproval,SendPurchaseApprovalRequest,SendPurchaseScheduledNotificationandSendPurchaseExecutedNotificationnow returnErrNoRecipientwhendata.RecipientEmailis empty, as the SESSenderdoes. Before, they mailed the approve, revoke or exchange-approval link tos.notifyEmail, which always resolves because it defaults toFROM_EMAIL. That made theErrNoRecipientbranch ininternal/api/handler_purchases.go(approvalSendFailure) unreachable on Azure and GCP. Doc comments were updated to match.Verification
All of the following is unit, mock and fixture evidence (mock SES client and an in-process mock SMTP server on 127.0.0.1). It includes no real SES, SMTP or cloud calls and no integration tests: none of these changes touch the DB.
Environment: Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.Fail-before: I ran the new and extended tests against unmodified
main(7b86e386) with only the test files added:Pass-after, at
6d422e20:Mutation probe: making
muteCheckFailsOpenreturnfalsefor the approval scopes failsTestIsRecipientMuted_StoreErrorDirectionPerScopeand the existingTestSendPurchaseApprovalRequest_MuteCheckError_FailOpen. The opposite direction is the fail-before run above.New and changed tests:
TestPlainTextTemplates_NoHTMLEscaping(extended): scheduled-delay revoke URL with a second query parameter and</>in the window time; executed notice withO'Brien/a&b@example.comand a url-escaped token. The HTML half of the executed notice is also checked to still escape<script>.TestIsRecipientMuted_StoreErrorDirectionPerScope: each approval scope fails open;purchase_executed,new_recommendationsand the empty scope fail closed. CoversisRecipientMuted,filterMutedRecipientsandprepareMuteAwareDelivery.TestIsRecipientMuted_NoErrorFollowsStore: the store answer is passed through unchanged when there is no error; a nil checker is not muted.TestSenders_NoRecipientParity(new fileno_recipient_parity_test.go): runs all four methods against both the SESSenderand theSMTPSender, withnotifyEmailset and a live mock SMTP server. Both must returnErrNoRecipient; SESSendEmailmust not be called and the SMTP server must see no session.TestCreateVerificationRequest_RedactsAddressInLog,TestSMTPSender_SendLogsRedactRecipient(new filelog_redaction_test.go): capture the default logger throughlogging.SetOutput.TestSMTPSender_SendRIExchangePendingApproval_WithNotifyEmailwas replaced by..._NotifyEmailIsNotAFallback. The old test asserted nothing once the fallback was gone, and it dialedsmtp.example.com:587.executed_notification_test.go: the text half now expects the unescaped working token (see fix 1).Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree:
queue/platform@400d256b= origin/main7b86e386plus these heads, merged in this order:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers. The full suite ran onf7c48020, which differs from this tree only by test-only commits on fix/issue-30, fix/issue-713 and fix/issue-263 that replace placeholder account-number literals with named constants:go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1):internal/analytics,internal/api,internal/auth,internal/config,internal/database/postgres/migrations,internal/purchase,internal/schedulerandinternal/serverall pass. Migrations 000149, 000150 and 000151 apply in order on this tree.approval-details,settings-accounts,api) pass (221 tests).accountkey); fixed with named constants. Re-run on this tree: git-secrets, using the repo'sscripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 183 PASS, 0 FAIL..tftest.hclfiles were not run locally (CI runs them). Nothing was applied to any cloud.Behavior change for operators (please review)
On SMTP deployments (Azure ACS, GCP SendGrid), an RI-exchange approval run in
manualmode with no Settings > General > notification email now logsfailed to send RI exchange notification email: email: no recipient addressinstead of mailing the approval links toNOTIFY_EMAIL. AWS (SES) already behaves this way. The purchase approval, scheduled-delay and executed paths already resolve a recipient (or skip) before calling the sender, so they are unaffected in practice. This is the change the issue asks for, but it should be called out in release notes.Out of scope
.tfvarsescaping, suppression-read failure direction, HTTP scheduled-path validation,IsLambdadiscriminator, A06-013 Azure bicep script escaping, A01-016 suppression config error). Scheduler/API items 1, 2 and 7 are being handled separately. The issue stays open.SMTPSender.SendScheduledPurchaseNotification(the pre-purchase reminder, which also carries approve/pause/cancel links) still sends tos.notifyEmail, while SES requiresdata.RecipientEmail. It was not one of the four methods named in A06-014, so I left it. It may deserve its own issue.Labels
A maintainer please mirror the issue's triage labels onto this PR:
priority/p3,severity/low,urgency/eventually,impact/many,effort/l,type/bug,triaged.🤖 Generated with Claude Code