Skip to content

feat(email): add Brevo backend alongside Resend with auto-detect precedence#97

Merged
mastermanas805 merged 1 commit into
masterfrom
feat/api-brevo-email-backend
May 14, 2026
Merged

feat(email): add Brevo backend alongside Resend with auto-detect precedence#97
mastermanas805 merged 1 commit into
masterfrom
feat/api-brevo-email-backend

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

Summary

  • Refactors internal/email to a provider abstraction. Resend is preserved; Brevo joins as a peer backend chosen via env precedence (Brevo > Resend (≠"CHANGE_ME") > noop).
  • Direct fix for the 2026-05-14 magic-link outage where RESEND_API_KEY=CHANGE_ME in prod made every Resend call fail with an opaque auth error.
  • Brevo provider uses net/http only — no new SDK dependency. POSTs https://api.brevo.com/v3/smtp/email with api-key header; treats 201/202 as success.
  • cli_auth.go / magic_link.go and every other Send* call site are unchanged — internal dispatch only.

Env precedence rule

  1. EMAIL_PROVIDER (brevo | resend | noop) — explicit override.
  2. BREVO_API_KEY set and non-empty → brevo.
  3. RESEND_API_KEY set, non-empty, and not "CHANGE_ME" → resend.
  4. Otherwise → noop.

Boot logs the chosen provider via slog.Info("email.client.init", "provider", ...).

Verified-sender configuration

Brevo sender pulled from EMAIL_FROM_NAME / EMAIL_FROM_ADDRESS env vars, falling back to InstaNode / noreply@instanode.dev (the verified sender already registered with Brevo). 400 sender-not-verified responses surface the response body in email.send_failed logs and the returned error.

Tests

Before After
5 PASS (noop only) 10 PASS — 0 FAIL

New tests:

  • TestProvider_NoopByDefault
  • TestProvider_PicksBrevoWhenKeyPresent
  • TestProvider_PicksResendWhenBrevoMissing (also covers "CHANGE_ME" sentinel → noop)
  • TestBrevoProvider_FormatsBody — httptest server asserts JSON body shape + api-key header + path
  • TestBrevoProvider_HandlesUnauthorized — 401 response → non-nil error containing response body

Operator note

Resend backend is retained per brief. Operators can flip back with EMAIL_PROVIDER=resend (or by clearing BREVO_API_KEY and setting RESEND_API_KEY to a real key). This is an add, not a replace.

Test plan

  • go build ./... clean
  • go vet ./... clean (excluding pre-existing untracked sibling work in internal/circuit/)
  • go test ./internal/email/... — 10/10 PASS
  • go test ./internal/handlers/... (email-adjacent) — clean
  • After merge: operator sets BREVO_API_KEY (already set in prod) and either leaves EMAIL_PROVIDER unset or pins it to brevo; verifies first magic-link delivery via Brevo dashboard.

🤖 Generated with Claude Code

…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>
@mastermanas805
mastermanas805 merged commit 0bcebb5 into master May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant