This guide covers the production operations surface for mailglass_inbound:
three mix tasks for config verification, replay, and data retention; the
retention and rate-limit configuration schema; and how suppression flagging
works.
Contract boundary for operators
The stable operator contract is the command-level behavior documented in this
guide: CLI flags, confirmation tiers, tenant guards, destructive behavior, and
exit/outcome semantics for mix mailglass.inbound.doctor,
mix mailglass.inbound.replay, and mix mailglass.inbound.prune.
Everything else is implementation detail and may change without deprecation:
internal MailglassInbound.Internal.* modules, worker modules, queue/retry
internals, and admin UI implementation details.
mix mailglass.inbound.doctor
mix mailglass.inbound.doctor runs DNS-free pre-deploy validation of your
inbound configuration. It is the inbound analog of mix mail.doctor — entirely
offline, exit-coded for CI, designed to catch configuration errors before you
deploy.
Usage
mix mailglass.inbound.doctor
mix mailglass.inbound.doctor --strict
mix mailglass.inbound.doctor --format json
mix mailglass.inbound.doctor --verbose
The task reads config :mailglass_inbound, router: MyApp.InboundRouter to
locate your compiled router.
What it checks
- Routes compile and do not conflict
- Referenced mailbox modules exist and implement
process/1 - Provider signing keys are present in config (presence only — it never verifies a signature, so no live HTTP call is made)
- MIME backend availability
Exit codes
| Code | Meaning |
|---|---|
0 | All checks pass (including warnings, unless --strict) |
1 | At least one check failed, or any warning under --strict |
2 | Cannot diagnose — no router configured, or router does not compile |
Exit code 2 is the "cannot even run the checks" signal. Exit code 1 is the
CI failure signal. Exit code 0 is the green path.
Flags
| Flag | Effect |
|---|---|
--strict | Treat warnings as failures (exit 1 on any warning) |
--format json | Machine-readable JSON output (default: human) |
--verbose | Show the resolved route list alongside check results |
CI usage
Add to your pre-deploy gate:
mix mailglass.inbound.doctor --strict --format json
Exit code 1 fails the gate. Exit code 2 typically indicates a missing
router config and should also fail.
Output (human format)
check: routes compile ... ok
check: no route conflicts ... ok
check: SupportMailbox implements process/1 ... ok
check: postmark signing key present ... ok
check: MIME backend available ... ok
All checks passed.mix mailglass.inbound.replay
mix mailglass.inbound.replay replays previously received inbound records
through their mailboxes. It drives the shipped replay engine, appending an
ExecutionRun with source: :replay to the append-only lineage table without
modifying any existing rows.
Usage
# Replay a single record
mix mailglass.inbound.replay --tenant acme --record-id <uuid>
# Replay all records since a timestamp for a tenant
mix mailglass.inbound.replay --tenant acme --since 2026-05-01T00:00:00Z
# Report scope without replaying
mix mailglass.inbound.replay --tenant acme --dry-run
# Skip confirmation
mix mailglass.inbound.replay --tenant acme --record-id <uuid> --yes
--tenant is required
--tenant <id> is required for every replay operation. It is the cross-tenant
replay guard: every record is loaded scoped to that tenant, so a
foreign-tenant --record-id resolves to nothing rather than replaying across
the boundary.
In single-tenant deployments, pass your resolver's tenant ID:
mix mailglass.inbound.replay --tenant default --dry-run
Omitting --tenant is a CLI error:
Inbound replay blocked: --tenant <id> is required (cross-tenant replay guard).
Use --tenant default under the SingleTenant resolver.Flags
| Flag | Effect |
|---|---|
--tenant <id> | Required. Scope all record loads to this tenant |
--record-id <uuid> | Replay a specific record (AND-combined with --since) |
--since <iso8601> | Replay all records received at or after this timestamp |
--yes / -y | Skip the confirmation prompt |
--dry-run | Report the count and scope without replaying |
--record-id and --since are AND-combinable: both must match for a record to
be replayed.
Confirmation
Replay is non-destructive — it only appends ExecutionRun rows. The
confirmation tier is a simple [y/N] defaulting to No:
Replay 12 inbound record(s)? [y/N]--yes/-y skips the prompt for automation. --dry-run reports scope without
prompting or replaying.
Replay is stored-truth recovery: it is not a fresh provider receipt, it does not silently reroute to another mailbox, and it is not a public replay runtime API contract.
Zero matches
When no records match the selectors, the task exits 0 with:
Inbound replay: nothing to replay (0 records matched the selectors).Messages received before 2.2.0 cannot be replayed
Since mailglass_inbound 2.2.0, replay resolves a record's mailbox from the
durable route binding written on the evidence row at receive time, and from
nothing else. Resolving a mailbox from a module name persisted on an old
execution run was deliberately closed: turning stored text back into a module is
an unsafe code-loading operation, and evidence rows are adopter-controlled data.
The consequence is historical and bounded: an inbound message received before you upgraded to 2.2.0 has no route binding and can never be replayed. Replay reports it and moves on; the record, its evidence, and its execution history are all intact and remain readable and auditable. Every message received since the upgrade replays normally, because the ingress path writes the binding on every evidence row it creates.
There is no backfill task. Reconstructing a binding would mean either trusting the persisted module name — the exact operation that was closed — or re-running your current routing config against old evidence, which can silently bind a historical message to a mailbox that did not receive it. If you have a concrete need to replay pre-2.2.0 messages, open an issue describing it; the safe form of that remediation depends on facts about your routing history that the library cannot infer.
mix mailglass.inbound.prune
mix mailglass.inbound.prune enforces the configured inbound retention policy.
It runs the batched sweep synchronously (with or without Oban) and deletes
over-retention rows in batches of 1000 under a pg_try_advisory_lock
single-run guard.
Usage
# Interactive typed confirmation
mix mailglass.inbound.prune
# Report scope without deleting
mix mailglass.inbound.prune --dry-run
# Skip confirmation (for cron or CI)
mix mailglass.inbound.prune --yes
Typed "yes" confirmation
Because the sweep permanently deletes rows, the confirmation tier is
stronger than replay's [y/N]. You must type the full word yes at the
prompt:
This permanently deletes over-retention inbound rows. Type 'yes' to continue:Typing anything other than yes (including y, Y, or YES) aborts without
deleting:
Inbound prune: aborted (no rows deleted).--yes/-y skips the prompt for automated use (cron, CI):
mix mailglass.inbound.prune --yes
Flags
| Flag | Effect |
|---|---|
--yes / -y | Skip typed confirmation (cron/CI) |
--dry-run | Report scope without deleting |
Delete order
Rows are deleted child-first to respect the FK-lineage invariant:
replay_runs(oldest window: 30 days default)execution_runs(90 days default)evidence(90 days default)records(90 days default)
Advisory lock
A pg_try_advisory_lock guards the sweep so only one prune can run at a time.
If another sweep is already in progress, the task exits cleanly:
Inbound prune: another sweep is already running (advisory lock held); nothing deleted.Scheduled pruning with Oban
MailglassInbound.Prune.Worker exists as an Oban cron worker but is not
auto-registered. Wire it yourself in your Oban config:
# config/config.exs
config :my_app, Oban,
plugins: [
{Oban.Plugins.Cron,
crontab: [
{MailglassInbound.Prune.Worker, "0 3 * * *"} # daily at 03:00 UTC
]}
]If you do not use Oban, run the prune task from system cron:
0 3 * * * /path/to/app mix mailglass.inbound.prune --yesmix mailglass.inbound.prune runs the sweep synchronously regardless of
whether Oban is installed — only scheduling needs Oban.
The worker module and scheduling topology above are operational examples, not a public worker/queue API contract.
Retention policy configuration
Retention windows control how long each table class is kept before the prune
sweep deletes it. Configure them in config/runtime.exs:
config :mailglass_inbound,
retention: [
records_days: 90, # how long to keep InboundRecord rows
evidence_days: 90, # how long to keep raw evidence (payloads, MIME)
execution_runs_days: 90, # how long to keep fresh ExecutionRun rows
replay_runs_days: 30 # how long to keep ReplayRun rows
]These are the defaults. You only need to configure keys you want to override.
FK-lineage invariant
The four windows must respect the foreign-key lineage so the child-first sweep never trips a constraint:
records_days >= evidence_days >= max(execution_runs_days, replay_runs_days)If you configure a shorter parent window than a child, the config accessor silently clamps the parent up to the minimum safe value rather than letting the sweep crash on a foreign key violation.
Disabling a window
Set any class to :infinity to disable pruning for that class:
config :mailglass_inbound,
retention: [
records_days: :infinity, # never prune records
evidence_days: :infinity, # never prune evidence
execution_runs_days: :infinity,
replay_runs_days: 30
]When a child class is :infinity, its parent classes are also treated as
:infinity (the FK-lineage invariant applies).
Inspecting effective windows at boot
Call MailglassInbound.Config.validate_at_boot!/0 from your application start
to validate the configured shape and raise early on invalid values:
# lib/my_app/application.ex
def start(_type, _args) do
MailglassInbound.Config.validate_at_boot!()
# ... supervisor children
endRate-limit configuration
mailglass_inbound rate-limits inbound requests post-verify in three buckets
evaluated fail-fast in order:
tenant -> recipient -> sender_domainOn bucket trip, the plug returns HTTP 429 with a Retry-After header. There
is no :transactional stream bypass.
Configure in config/runtime.exs:
config :mailglass_inbound,
rate_limit: [
tenant: [capacity: 1000, per_minute: 1000], # per tenant_id
recipient: [capacity: 500, per_minute: 500], # per envelope_recipient
sender_domain: [capacity: 200, per_minute: 200] # per sender domain
]These are the defaults. capacity is the token-bucket burst size;
per_minute is the sustained refill rate. The defaults set them equal, so
"N/min" is the sustained throughput.
Evaluation order
- Tenant bucket trips first. If a tenant exceeds 1000 inbound requests per minute across all recipients, subsequent requests from that tenant are rejected with 429 before further processing.
- Recipient bucket trips second — per
envelope_recipientaddress, regardless of sender. - Sender domain bucket trips last — per extracted sender domain.
Fail-fast means a tenant trip never reaches the recipient or sender_domain checks.
Tuning recommendations
- Raise
tenantcapacity for high-volume tenants (e.g. SaaS platforms receiving forwarded helpdesk email at scale). - Keep
sender_domainlow to protect against individual senders flooding a single recipient. - All values are runtime-configurable — no restart required if you update
config/runtime.exsand restart the application.
Suppression flag interpretation
mailglass_inbound checks the outbound suppression list when an inbound message
arrives. Suppressed senders are flagged, not auto-bounced.
What the flag means
When an inbound sender's address appears on the suppression list, the
InboundRecord is inserted with suppression_flagged: true. The message still
flows through routing and mailbox execution. The flag is visible:
- In the
InboundMessage.signalsstruct under the:suppression_flaggedfield - In the inbound admin LiveView record list and detail panel
Why flag-only, not auto-bounce
Several common inbound patterns involve suppressed addresses:
- Forwarders and aliasing services. A forwarding address may be on the
suppression list (e.g. a bounce from
alias@forwarder.examplethat suppressed that address), but the human behind it is legitimate. - Complaint replies. A recipient who marked a delivery as spam may then write in with a genuine support request. Auto-bouncing that reply discards diagnostic signal.
- False-positive recovery. Suppression lists can have false positives. Auto-bouncing closes the recovery window before a human can inspect the situation.
The flag preserves the diagnostic signal. Your mailbox callback decides what to do with it:
defmodule MyApp.Mailboxes.SupportMailbox do
@behaviour MailglassInbound.Mailbox
@impl true
def process(message) do
if message.signals.suppression_flagged do
# Route to a human review queue rather than auto-processing
{:reject, "suppressed sender — flagged for manual review"}
else
:accept
end
end
endOr accept and handle in application logic:
def process(message) do
MyApp.SupportTickets.create(message)
:accept
endThe choice of outcome is yours. The inbound package only flags; it does not decide.
References
- inbound-install.md — initial router config and provider
setup, which
mix mailglass.inbound.doctorvalidates - inbound-testing.md — how to write tests that verify mailbox behavior
- inbound-mailgun.md — Mailgun provider setup
- inbound-ses.md — SES provider setup