MailglassInbound.RateLimiter (MailglassInbound v1.3.0)

Copy Markdown View Source

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 OWN Retry-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_inbound config via MailglassInbound.Config, never :mailglass (the design contract).
  • Builds Mailglass.RateLimitError internally (reuse the struct, never re-create it): :per_tenant for the tenant bucket, :per_domain for 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

check(tenant_id, recipient, sender_domain)

(since 1.2.0)
@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 / first to address (recipient bucket key; may be the full address, see PII discipline).
  • sender_domain — the DOMAIN of the first from address (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.