Inbound-local multi-bucket ETS token-bucket rate limiter (IOPS-04, the design contract).
Cloned from Mailglass.RateLimiter — the load-bearing :ets.update_counter/4
refill math is copied verbatim. Adapted for inbound:
- Three buckets, fail-fast via
with, in order tenant (1000/min) -> recipient (500/min) -> sender_domain (200/min). The first bucket to trip returns ITS OWNRetry-After— never a cross-bucket max (the design contract). - No stream-based bypass clause — inbound has no stream semantics, so the core limiter's auth-stream short-circuit is intentionally dropped (the design contract).
- No
%Mailglass.Message{}coupling — takes plain args. - Reads
:mailglass_inboundconfig viaMailglassInbound.Config, never:mailglass(the design contract). - Builds
Mailglass.RateLimitErrorinternally (reuse the struct, never re-create it)::per_tenantfor the tenant bucket,:per_domainfor the recipient + sender_domain buckets.
Hot path is :ets.update_counter/4 on the :mailglass_inbound_rate_limit
table owned by MailglassInbound.RateLimiter.TableOwner — no GenServer
mailbox serialization (the design contract).
PII discipline (the design contract)
The sender bucket is keyed on the sender DOMAIN only, never the full sender
address. The recipient bucket may key on the full recipient address — it is
the routing identity, already persisted in clear, lives only in node-local ETS,
and is never logged, serialized, or emitted. No hashing required. The error
context and any telemetry/HTTP body carry the bucket type
(:tenant | :recipient | :sender_domain), never the key value. This comment
exists so a future lint pass does not false-positive on the recipient-address
ETS key.
Per-node scope (the design contract)
Counters live in node-local ETS — an N-node cluster enforces N x the limit. Acceptable for the single-node-default library posture; cluster-global enforcement is out of scope until a shared-backend option ships.
Summary
Functions
Returns :ok when the inbound message is under all three bucket limits, or
{:error, %RateLimitError{}} when the first-evaluated bucket
(tenant -> recipient -> sender_domain) is depleted.
Functions
@spec check(String.t(), String.t() | nil, String.t() | nil) :: :ok | {:error, Mailglass.RateLimitError.t()}
Returns :ok when the inbound message is under all three bucket limits, or
{:error, %RateLimitError{}} when the first-evaluated bucket
(tenant -> recipient -> sender_domain) is depleted.
Arguments:
tenant_id— the resolved tenant scope (tenant bucket key).recipient— the envelope recipient / firsttoaddress (recipient bucket key; may be the full address, see PII discipline).sender_domain— the DOMAIN of the firstfromaddress (sender bucket key; never the full sender address).
The returned error's retry_after_ms is the tripped bucket's own refill
interval. The context carries the PII-free bucket :bucket type and :limit
(capacity) for the plug to classify and surface — never the recipient/sender
value.