Per-{tenant_id, recipient_domain} ETS token bucket (SEND-02).
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/3withstream == :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. Default: 100 tokens @ 100/min.
Configuration
config :mailglass, :rate_limit,
default: [capacity: 100, per_minute: 100],
overrides: [
{{"premium-tenant", "gmail.com"}, [capacity: 500, per_minute: 500]}
]Missing :rate_limit key uses built-in defaults.
Telemetry
Single-emit [:mailglass, :outbound, :rate_limit, :stop] with:
- Measurements:
%{duration_us: integer()} - Metadata:
%{allowed: boolean(), tenant_id: String.t()}
No PII — recipient_domain is NOT emitted (domain is less sensitive than full address, but to stay inside the Phase 1 D-31 whitelist we omit it; operators who need it can add a domain-aware handler that reads context from other sources).
Summary
Functions
Returns :ok when the delivery is allowed, or {:error, %RateLimitError{}}
when the bucket is depleted. :transactional stream always returns :ok.
Functions
@spec check(String.t(), String.t(), atom()) :: :ok | {:error, Mailglass.RateLimitError.t()}
Returns :ok when the delivery is allowed, or {:error, %RateLimitError{}}
when the bucket is depleted. :transactional stream always returns :ok.
Arguments
tenant_id— binary tenant idrecipient_domain— binary domain (e.g."gmail.com")stream—:transactional | :operational | :bulk