# mailglass_inbound Operator Guide

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

```bash
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:

```bash
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

```bash
# 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:

```bash
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

```bash
# 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):

```bash
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:

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:

```elixir
# 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:

```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.

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`:

```elixir
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:

```elixir
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:

```elixir
# 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`:

```elixir
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:

```elixir
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:

```elixir
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

- [inbound-install.md](inbound-install.md) — initial router config and provider
  setup, which `mix mailglass.inbound.doctor` validates
- [inbound-testing.md](inbound-testing.md) — how to write tests that verify
  mailbox behavior
- [inbound-mailgun.md](inbound-mailgun.md) — Mailgun provider setup
- [inbound-ses.md](inbound-ses.md) — SES provider setup
