@Mail Domain Check

Confirmation email | HTTP 429 | Docker and IPv6

Confirmation Email 429 Behind Docker and IPv6

When many visitors become one proxy or bridge address, a small per-IP quota can silently reject an entire event. Repair the identity chain first, make throttling truthful, then prove one recoverable notification from accepted signup to confirmation.

Generate an incident plan
No address or log upload The planner evaluates categorical states in this browser.

Browser-local incident planner

Find the first broken confirmation boundary

Classify four observable states. All 256 combinations are evaluated locally.

No email address, IP address, forwarded header, domain, token, message, configuration, credential, request body, or log is requested or transmitted by the planner.

Recognize a shared-bucket failure

A live incident in eventPhotos showed the pattern clearly. The host accepted public IPv6, while the application path behind an IPv4-only Docker bridge saw many IPv6 visitors as the same bridge gateway. Symfony's per-client signup limiter then treated unrelated visitors as one caller. After five accepted requests in the original one-hour bucket, later requests returned 429 before a subscription row or confirmation send existed.

The failure looked intermittent because IPv4 visitors still presented distinct addresses. It also stayed out of the production log: the Monolog fingers_crossed handler activated at error, while the application threw a normal too-many-requests response without a dedicated rejection metric.

Repair identity without trusting the internet

  1. Run controlled IPv4 and IPv6 requests through the real public edge. Compare the edge peer, emitted Forwarded or X-Forwarded-For chain, web-server remote address, and framework client identity privately.
  2. Choose a topology deliberately. A native dual-stack bridge keeps network isolation; a host-network edge avoids bridge NAT and userland proxying on Linux; a separate load balancer can preserve identity through an authenticated internal hop.
  3. At the public edge, remove any forwarding header supplied by the client and write the canonical chain. At each later hop, trust only the exact preceding proxy address or controlled CIDR.
  4. Repeat the dual-stack test plus a direct spoof attempt. The approved proxy path must preserve identity, while a direct request must not choose its own limiter key.

Do not configure Symfony or nginx to trust every internet source merely to recover X-Forwarded-For. A forged header would let an attacker rotate arbitrary rate-limit identities.

Use different controls for different abuse

Edge volume
A coarse network-level control absorbs floods before the application boots. It is not the place to decide whether one recipient may receive another confirmation.
Client burst
A broad application limit can slow automated form submission, but its budget must tolerate venue Wi-Fi, office NAT, and carrier CGNAT.
Recipient resend
A normalized per-email limit is the direct mail-bomb control. Keep responses non-enumerating and never log the address.
Durable work
After validation and accepted abuse gates, commit one Pending state and one idempotent notification job before claiming success.

Symfony's Rate Limiter exposes remaining tokens, limit, and retry time. A rejected request can produce the standard Retry-After header. Use it: keep the form available and state that no confirmation was scheduled. “Check your inbox” is false when the limiter stopped execution before any send attempt.

Make notification state queryable

A subscription row alone cannot distinguish “never attempted” from “provider accepted” or “send failed.” Store a monotonic state for the current confirmation generation: queued, attempted, provider_accepted, retryable_failed, or terminal_failed. Correlate with an opaque provider identifier, not a recipient or token in logs.

Provider acceptance is not inbox placement. Keep provider events, recipient-server acceptance, mailbox visibility, and confirmation-link completion as separate evidence.

For observability, count limiter name, route, outcome, address family, retry window, and privacy-preserving bucket cardinality. Route those counters independently from an error-triggered log buffer. This reveals a sudden one-bucket collapse without retaining raw IPs or forwarding chains.

Recover only the current Pending generation

  1. Claim the current idempotent job with a lease and recheck that the subscription is still Pending.
  2. Apply bounded exponential backoff to retryable transport failures. Mark permanent provider rejection separately.
  3. Never resend to Confirmed or Unsubscribed. A repeat confirmation tap may be idempotent, but an opt-out remains terminal.
  4. Refresh a token only when starting a deliberate new pending generation. An older link must not confirm the newer state.
  5. Finish with one controlled provider event, receiver-visible message, and single confirmation transition.
Check the public sender after a real attempt exists

The free scan checks public MX, SPF, DMARC, PTR, and policy evidence. It cannot inspect Docker, proxy trust, private limiter state, an outbox, provider events, or a mailbox.

Check sending domain

Primary references