Repository navigation
fix(email): bound SMTP sends by the caller's context - #1438
Conversation
The SMTP sender's methods took ctx but never used it: dispatchSMTP had no context parameter, the STARTTLS path used smtp.Dial and the plain path smtp.SendMail, neither of which has a deadline. A mail server that accepted the TCP connection and then stopped answering held the caller until the OS gave up, so the 30s failure-alert budget could not cut it off and a purchase or scheduler invocation could run out its time. dispatchSMTP now takes ctx. sendMail dials with net.Dialer.DialContext and, once ctx is done (deadline or cancel), moves the connection deadline into the past so any read or write in flight, the TLS handshake included, fails; the returned error wraps ctx.Err(). Both the STARTTLS path and the plain AllowInsecure path share one conversation (smtpConverse). The plain path keeps smtp.SendMail's behavior: opportunistic STARTTLS and an early "server doesn't support AUTH". Refs LeanerCloud#1433 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t name errcheck (check-blank) flags the discarded SetDeadline error in the context.AfterFunc callback; log it at debug level instead. misspell flags "cancelled" in a subtest name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 4 billable files and costs up to $1.00. Or wait 59 minutes for your next included review. View limit details
Comment |
|
Opened at head 9762732: SMTP sends now dial with the caller's context and fail any in-flight I/O once it ends (STARTTLS and plain paths); fail-before/pass-after and local check results are in the PR body. |
Fresh CI: the previous run's Security Scanning failed on a transient Trivy database download 404 from mirror.gcr.io. 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: rC3. 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 |
Closes #1433
Root cause
The
SMTPSendersend methods tookctxbut never used it.dispatchSMTPhad no context parameter. The STARTTLS path dialed withsmtp.Dialand the plain path usedsmtp.SendMail, and neither sets a deadline. A mail server that accepted the TCP connection and then stopped answering could hold the caller until the OS gave up, past the 30sfailureNotifyTimeoutbudget for the purchase-failed alert, and past a Lambda invocation's remaining time.Fix
internal/email/smtp_sender.go:dispatchSMTP(ctx, rcpts, msg)now takes the caller's context. All three call sites pass it.sendMaildials withnet.Dialer.DialContext(ctx, ...)and registerscontext.AfterFunc(ctx, ...). When ctx ends (deadline or cancel), that moves the connection's deadline into the past, so any read or write in flight fails, including the TLS handshake and every step of the SMTP conversation. When ctx is done, the returned error wrapsctx.Err(), soerrors.Is(err, context.DeadlineExceeded)(orcontext.Canceled) holds. This covers the issue's "deadline fromctx.Deadline()" and "close on cancel" points in one mechanism. Using AfterFunc rather than also callingSetDeadline(dl)avoids a race where the I/O timeout fires beforectx.Err()is set.AllowInsecurepath share one conversation,smtpConverse:smtp.NewClienton the dialed conn, STARTTLS, AUTH, MAIL/RCPT/DATA, QUIT. WithUseTLS, STARTTLS is mandatory as before (TLS 1.2 minimum, fix(scheduler): single-flight cold-start collection through the marker #410). Without it, STARTTLS is used when the server offers it, assmtp.SendMaildid.smtpAuthenticatenow also refuses early withsmtp: server doesn't support AUTHwhen the server doesn't advertise AUTH, which keepsSendMail's behavior for the plain path.SetDeadlineerror is logged at debug level for errcheck's check-blank rule, and a test name uses "canceled".Behavior change to note: callers that pass an already-canceled or expired context now get an error instead of a send. SES sends already behaved this way, because the AWS SDK honors ctx. The purchase-failed alert uses
ctxutil.DetachedWithTimeout, which is the intended 30s budget.Verification
All local runs, Linux, Go 1.26.9 toolchain:
TestSMTPSender_SendHonorsContext(internal/email/smtp_server_test.go). A local stub accepts connections and then stalls, either with no 220 greeting or after answering EHLO, while holding the connection open until cleanup. Subtests cover the STARTTLS path (no credentials,useTLS) and the plainAllowInsecurepath with credentials, at a 200ms deadline plus one cancellation case. Each asserts thatSendToEmailreturns within 2s with an error wrappingcontext.DeadlineExceeded(orcontext.Canceled). A 5s guard fails the test if the call stays blocked.smtp_sender.goandsmtp_server_test.gofromorigin/mainplus only the new test appended, all 5 subtests fail with "SendToEmail ignored its context: still blocked 5s after a 200ms deadline" (25s total).-race.sendMailTLSbecamesendMail(ctx, ...)anddispatchSMTP(ctx, ...)in 6 call sites) and three stale comments.go build ./...,go vet ./...,go vet -tags integration ./...,(cd tests/e2e && go vet -tags e2e ./...): clean.golangci-lintv2.10.1 on./internal/email/, with and without--build-tags integration: 0 issues.gocyclo -over 10 internal/email/smtp_sender.go: nothing reported.go test -race -short -count=1 ./internal/email/andgo test -race -tags integration -count=1 ./internal/email/: ok.secrets-scan.shon the changed files: "clean; planted key flagged".scripts/test-git-secrets-allowlist.sh: 183 PASS, 0 FAIL.Not verified locally: a send through real SendGrid or Azure ACS SMTP endpoints. The happy path is covered only by the existing local stub-server tests.
🤖 Generated with Claude Code