feat(email): add Brevo backend alongside Resend with auto-detect precedence#97
Merged
Merged
Conversation
…edence internal/email refactored to a provider abstraction (resendProvider, brevoProvider, noopProvider). Resend is preserved; Brevo joins as a peer so the operator can swap via env without code change. Env-precedence rule: 1. EMAIL_PROVIDER, if "brevo" | "resend" | "noop" (explicit override). 2. BREVO_API_KEY present and non-empty → brevo. 3. RESEND_API_KEY present, non-empty, and not "CHANGE_ME" → resend. 4. Otherwise → noop (logs only). The "CHANGE_ME" sentinel exclusion is the immediate fix for the 2026-05-14 magic-link outage: live RESEND_API_KEY was set to the infra/k8s/secrets.yaml placeholder, so the Resend client booted and every send returned an opaque auth error. Treating "CHANGE_ME" as unset means the same misconfiguration now silently falls through to the Brevo key (which IS set in prod) instead of failing closed. Brevo backend uses net/http directly — no new SDK dependency. POSTs https://api.brevo.com/v3/smtp/email with "api-key" header; treats 201 and 202 as success and surfaces the response body on any other status so a 400 sender-not-verified or 401 bad-key is visible in operator logs and returned to the caller. Chosen provider is logged once at boot via slog.Info("email.client.init", provider=...) so the api pod's startup line confirms which backend it picked. cli_auth.go / magic_link.go and all other Send* call sites are unchanged — they call Client.SendMagicLink etc., and only the internal dispatch routes differently. Tests: - existing 5 noop tests preserved (renamed helper to email.NewNoop()). - +5 new: TestProvider_NoopByDefault, TestProvider_PicksBrevoWhenKeyPresent, TestProvider_PicksResendWhenBrevoMissing, TestBrevoProvider_FormatsBody (httptest.Server asserting JSON shape + headers), TestBrevoProvider_HandlesUnauthorized (401 → non-nil error). Before: 5 PASS. After: 10 PASS. 0 FAIL. `make build` clean. `go vet ./...` clean on all packages this PR touches. Resend backend is intentionally retained per the brief — this is an add, not a replace. Operator can flip back via EMAIL_PROVIDER=resend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
internal/emailto a provider abstraction. Resend is preserved; Brevo joins as a peer backend chosen via env precedence (Brevo > Resend (≠"CHANGE_ME") > noop).RESEND_API_KEY=CHANGE_MEin prod made every Resend call fail with an opaque auth error.net/httponly — no new SDK dependency. POSTshttps://api.brevo.com/v3/smtp/emailwithapi-keyheader; treats 201/202 as success.cli_auth.go/magic_link.goand every otherSend*call site are unchanged — internal dispatch only.Env precedence rule
EMAIL_PROVIDER(brevo|resend|noop) — explicit override.BREVO_API_KEYset and non-empty → brevo.RESEND_API_KEYset, non-empty, and not "CHANGE_ME" → resend.Boot logs the chosen provider via
slog.Info("email.client.init", "provider", ...).Verified-sender configuration
Brevo sender pulled from
EMAIL_FROM_NAME/EMAIL_FROM_ADDRESSenv vars, falling back toInstaNode/noreply@instanode.dev(the verified sender already registered with Brevo). 400 sender-not-verified responses surface the response body inemail.send_failedlogs and the returned error.Tests
New tests:
TestProvider_NoopByDefaultTestProvider_PicksBrevoWhenKeyPresentTestProvider_PicksResendWhenBrevoMissing(also covers "CHANGE_ME" sentinel → noop)TestBrevoProvider_FormatsBody— httptest server asserts JSON body shape +api-keyheader + pathTestBrevoProvider_HandlesUnauthorized— 401 response → non-nil error containing response bodyOperator note
Resend backend is retained per brief. Operators can flip back with
EMAIL_PROVIDER=resend(or by clearingBREVO_API_KEYand settingRESEND_API_KEYto a real key). This is an add, not a replace.Test plan
go build ./...cleango vet ./...clean (excluding pre-existing untracked sibling work ininternal/circuit/)go test ./internal/email/...— 10/10 PASSgo test ./internal/handlers/...(email-adjacent) — cleanBREVO_API_KEY(already set in prod) and either leavesEMAIL_PROVIDERunset or pins it tobrevo; verifies first magic-link delivery via Brevo dashboard.🤖 Generated with Claude Code