Multi-bucket ETS token bucket rate limiter (RATE-01).
Hot path is :ets.update_counter/4 — no GenServer mailbox
serialization. The TableOwner GenServer exists only to own the
table (see D-22). ≈1-3μs on OTP 27 with decentralized_counters: true
plus write_concurrency: :auto.
Invariants
:transactionalbypass (D-24):check/1withstream == :transactionalreturns:okBEFORE any ETS read. Password-reset / magic-link / verify-email MUST NOT be throttled because a marketing campaign saturated the bucket. Documented as a reserved invariant indocs/api_stability.md; this is NOT a tunable.- Leaky-bucket continuous refill (D-23): capacity tokens refill
at
capacity / 60_000tokens/ms.
Configuration
config :mailglass, :rate_limit,
tenant_recipient: [
default: [capacity: 100, per_minute: 100],
overrides: [
{{"premium-tenant", "gmail.com"}, [capacity: 500, per_minute: 500]}
]
],
global_recipient: [
default: [capacity: 1000, per_minute: 1000]
],
sender_domain: [
default: [capacity: 500, per_minute: 500]
]Telemetry
Single-emit [:mailglass, :outbound, :rate_limit, :stop] with:
- Measurements:
%{duration_us: integer()} - Metadata:
%{allowed: boolean(), tenant_id: String.t()}
No PII — domains are NOT emitted in telemetry (to stay inside the Phase 1 D-31 whitelist).
Summary
Functions
Returns :ok when the delivery is allowed, or {:error, %RateLimitError{}}
when any bucket is depleted. :transactional stream always returns :ok.
Functions
@spec check(Mailglass.Message.t()) :: :ok | {:error, Mailglass.RateLimitError.t()}
Returns :ok when the delivery is allowed, or {:error, %RateLimitError{}}
when any bucket is depleted. :transactional stream always returns :ok.
@spec check(String.t(), String.t(), atom()) :: :ok | {:error, Mailglass.RateLimitError.t()}
Backward compatibility shim for check/3. Delegates to check/1 by
building a synthetic message.