Inbound-local multi-bucket ETS token-bucket rate limiter (IOPS-04, D-49-11).
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 (D-49-13). - No stream-based bypass clause — inbound has no stream semantics, so the core limiter's auth-stream short-circuit is intentionally dropped (D-49-13).
- No
%Mailglass.Message{}coupling — takes plain args. - Reads
:mailglass_inboundconfig viaMailglassInbound.Config, never:mailglass(D-49-02). - 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 (D-49-12).
PII discipline (D-49-16)
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 (D-49-18)
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.