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.
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.
Zero matches
When no records match the selectors, the task exits 0 with:
Inbound replay: nothing to replay (0 records matched the selectors).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.
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