mailglass_inbound Operator Guide

Copy Markdown View Source

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

CodeMeaning
0All checks pass (including warnings, unless --strict)
1At least one check failed, or any warning under --strict
2Cannot 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

FlagEffect
--strictTreat warnings as failures (exit 1 on any warning)
--format jsonMachine-readable JSON output (default: human)
--verboseShow 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

FlagEffect
--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 / -ySkip the confirmation prompt
--dry-runReport 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

FlagEffect
--yes / -ySkip typed confirmation (cron/CI)
--dry-runReport scope without deleting

Delete order

Rows are deleted child-first to respect the FK-lineage invariant:

  1. replay_runs (oldest window: 30 days default)
  2. execution_runs (90 days default)
  3. evidence (90 days default)
  4. 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 --yes

mix 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
end

Rate-limit configuration

mailglass_inbound rate-limits inbound requests post-verify in three buckets evaluated fail-fast in order:

tenant -> recipient -> sender_domain

On 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

  1. 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.
  2. Recipient bucket trips second — per envelope_recipient address, regardless of sender.
  3. 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 tenant capacity for high-volume tenants (e.g. SaaS platforms receiving forwarded helpdesk email at scale).
  • Keep sender_domain low to protect against individual senders flooding a single recipient.
  • All values are runtime-configurable — no restart required if you update config/runtime.exs and 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.signals struct under the :suppression_flagged field
  • 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.example that 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
end

Or accept and handle in application logic:

def process(message) do
  MyApp.SupportTickets.create(message)
  :accept
end

The choice of outcome is yours. The inbound package only flags; it does not decide.

References