fix(email): retry transient send failures with per-provider error classification - #303
fix(email): retry transient send failures with per-provider error classification#303clau1902 wants to merge 5 commits into
Conversation
Refuses to send to addresses that previously hard-bounced or filed a spam complaint, since repeatedly emailing one is exactly what gets a sending domain's reputation downgraded by receiving mail providers. - New suppressed_recipients table + SuppressionService (suppress/unsuppress/ suppressed_among), normalized (trim+lowercase) on both write and lookup. - Checked in EmailService::send after the email row is inserted but before any domain/provider work. - Checks to/cc/bcc together, not just to — a suppressed address left in cc or bcc would otherwise still receive mail. - Drops only the suppressed addresses rather than capturing the whole send — a suppressed address mixed into `to` alongside legitimate recipients must not deny delivery to everyone on the email. - If every `to` address ends up suppressed (or `to` was already empty), the email is captured (not sent) with a clear error_message. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… and rate limiting
Previously a domain had exactly one provider, and any send failure went
straight to "captured" — a transient SES throttle or a Scaleway blip
permanently dropped the email instead of trying again or falling back to
a backup provider.
- Ordered failover chain per domain: primary provider, then configured
fallbacks in priority order (new `email_domain_fallback_providers`
table + CRUD endpoints). Inactive providers are dropped from the chain,
so disabling one now actually takes it out of the send path.
- Error classification (`EmailError::SendFailed { retryable, .. }`) per
provider: HTTP status for Scaleway (5xx/429 retryable), SdkError kind
for SES (throttling/timeout/network retryable, message-level rejection
not), SMTP reply code via lettre's is_transient/is_timeout for SMTP.
- Bounded retry (2 attempts) against a provider before moving to the next
one in the chain — only for errors classified as transient.
- Per-provider circuit breaker (opens after 5 consecutive failures,
cooldown before retrying) and rate limiter (`rate_limit_per_minute`,
operator-configurable per provider, sliding window), both in-memory and
scoped to the process (control-plane code, not hot-path).
- `emails.provider_id`/`retry_count` record which provider ultimately
handled the send and how many attempts it took, across the whole chain.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dviejokfs
left a comment
There was a problem hiding this comment.
Thanks — the code quality here is genuinely good (permission guards + audit on every new endpoint, batch-fetched send chain, honest doc comments on the half-open simplification, additive migration). But I'd like to rescope before this merges.
Rescope: trim to retry + error classification
Failover is enterprise-shaped, and this is OSS core. The typical temps self-hoster configures exactly one provider — often none (Mailhog capture mode is a first-class path in this same function). An ordered per-domain failover chain with its own table, CRUD surface, and priority management is for someone who has a second SES/Scaleway account — that's a vanishingly small slice of users, and it's permanent core surface + UI to maintain. If demand shows up, it can be revisited later (possibly not in core at all). Fits the "core stays thin" philosophy.
And once failover goes, the circuit breaker and rate limiter have to go with it. Their only semantic in this PR is "skip to the next provider in the chain." With one provider and no re-send queue, "skip" means capture the email terminally without attempting the send:
- Circuit breaker: 5 consecutive failures → 60s where every email is captured with zero send attempts. Worse:
record_send_failurefires for non-retryable errors too, so 5 message-level rejections (bad recipients) open the circuit and drop healthy mail for everyone. - Rate limiter: over-cap email → captured. Real MTAs defer on throttle; this drops. A burst of password resets over the cap silently doesn't deliver.
Both are strictly worse than what's on main for single-provider setups. The primitive actually missing is a deferred-send queue (throttle = delay, not drop). Once that exists in a follow-up, rate limiting becomes useful even for one provider, and CB/failover can be reconsidered on top of it.
Keep
EmailError::SendFailed { retryable }+ per-provider classification (SES/Scaleway/SMTP) — this is the actual bug fix from the PR description (transient throttle → permanent drop)- Bounded 2-attempt retry with delay
emails.retry_count/provider_idcolumns (observability)- The inactive-provider fix — previously
is_activeonly hid a provider from selection UI while the send path kept using it. That fix shouldn't die with the chain: keep a "provider inactive → capture, don't send" check
Drop
email_domain_fallback_providerstable + the 3 fallback endpoints + audit type +get_send_chainresilience.rs(circuit breaker + rate limiter)email_providers.rate_limit_per_minute
Findings on the kept portion
- SES retryability is classified by string-matching the error message (
contains("throttl"),contains("internal"), …) — fragile against SDK message wording.aws_sdk_sesv2exposes typed variants (TooManyRequestsException,LimitExceededException,SendingPausedException) andProvideErrorMetadata::code(); match on those. - Worst-case in-request latency: retries (and today, the whole chain) run inside
POST /emails— attempts × provider timeout + sleeps. Even trimmed to one provider × 2 attempts, consider a total deadline across the send. retry_countstores total attempts (clean first-try send records 1) — rename toattempt_countor storeattempts - 1.
If any of the dropped pieces come back later: UpdateEmailProviderRequest.rate_limit_per_minute: Option<i32> + .map(Some) reintroduces the known double-Option PATCH gotcha (present null collapses to "unchanged", so the limit can never be cleared back to unlimited) — temps-ai-gateway has the deserialize_optional_field helper for this. And get_send_chain swallows DB errors on the primary lookup (if let Ok(primary)), silently degrading a domain to fallbacks-only on a transient DB blip.
Also noting: no CI checks have run on this branch, and since #296 hasn't merged the diff here is still the combined stack.
Per review: failover is enterprise-shaped for OSS core (single/no-provider
is the typical self-hosted setup), and the circuit breaker/rate limiter
only make sense in service of a failover chain — without one, "skip" means
capturing the email with zero send attempts, which is strictly worse than
main for the common case (a non-retryable rejection could trip the circuit
and drop healthy mail; a rate-limited burst silently fails instead of
deferring).
Dropped:
- email_domain_fallback_providers table, its 3 CRUD endpoints, audit type,
and ProviderService::get_send_chain
- resilience.rs (circuit breaker + rate limiter)
- email_providers.rate_limit_per_minute
Kept, and fixed per review:
- EmailError::SendFailed { retryable } + per-provider classification
- Bounded 2-attempt retry with delay, only for transient errors
- emails.provider_id / emails.attempt_count (renamed from retry_count —
a clean first-try send now unambiguously records 1, not "0 retries")
- The inactive-provider fix: a domain's provider is now checked for
is_active before the send path uses it (previously only hid it from
selection UI while sends kept going through it regardless) — covered by
a new regression test
- SES retryability now matches on SendEmailError's typed variants
(TooManyRequestsException, LimitExceededException, ...) via
ProvideErrorMetadata::code() instead of string-matching the error
message, which is fragile against SDK wording changes
- The whole attempt sequence (both tries) is now bounded by an overall
45s deadline via tokio::time::timeout, so a slow/hanging provider can't
stall the request indefinitely
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Rescoped per review — pushed in a new commit. Dropped entirely: Kept, and fixed:
Verified: 🤖 Generated with Claude Code |
…-list # Conflicts: # crates/temps-migrations/src/migration/mod.rs
…-retry # Conflicts: # crates/temps-migrations/src/migration/mod.rs
Depends on #296
Stacked on top of #296 (suppression list) — the send path reuses the same suppression-filtered
to/cc/bcc. Until #296 merges, GitHub will show a combined diff; once it merges intomain, this PR's diff will shrink to just its own commits.Summary
Previously any send failure went straight to "captured" — a transient SES throttle or a Scaleway blip permanently dropped the email instead of retrying.
EmailError::SendFailed { retryable, .. }) per provider: HTTP status for Scaleway (5xx/429 retryable), typedSendEmailErrorvariants for SES (TooManyRequestsException/LimitExceededExceptionretryable, message-level rejections likeMessageRejected/SendingPausedExceptionnot — classified via the SDK's typed variants +ProvideErrorMetadata::code(), not string-matching), SMTP reply code via lettre'sis_transient/is_timeoutfor SMTP.tokio::time::timeout) so a slow/hanging provider can't stall the request indefinitely.is_activepreviously only hid a provider from selection UI while the send path kept using it regardless — now checked explicitly before a provider is used, so disabling one actually takes it out of the send path.emails.provider_id/emails.attempt_countrecord which provider attempted the send and how many attempts it took (a clean first-try success records1).Not in this PR (dropped after review — enterprise-shaped for OSS core, and unsound without a real deferred-send queue): multi-provider failover chain, per-provider circuit breaker, per-provider rate limiter. See PR discussion for the full reasoning; may return as a follow-up once a deferred-send primitive exists.
Test plan
cargo check --workspace --lib— cleancargo test -p temps-email --lib— 173 tests pass, including a new regression test for the inactive-provider fix🤖 Generated with Claude Code