# Policy Simulation

Source: https://molted.email/docs/policy-simulation

Dry-run sends against the policy engine without actually sending email.

Policy simulation lets you test whether a send would be allowed or blocked -- without actually sending the email. Use it to pre-check sends, validate batch campaigns, and inspect remaining quotas.

A simulation runs the real send's own checks, in the same order: template and compliance, billing, quota, domain warmup, per-domain limits, policy, the sender, a thread or contact a human has taken over, another agent's lease, and the approval gates. So it answers what the real send would answer, including whether the send would wait for a human's approval or be deferred to a later warmup slot, and shows the content the send would deliver.

## Simulate a single send

```
POST https://mailbox.molted.email/v1/agent/simulate-send
```

```bash title="CLI (shorthand)"
molted send simulate --to user@example.com --subject "Hello" --body "Hi Alice!"
```

```bash title="CLI (template)"
molted send simulate \
  --to user@example.com \
  --template campaign-march \
  --dedupe-key "march-user@example.com" \
  --payload '{"name": "Alice"}'

# or: any molted send command with --dry-run
molted send --to user@example.com --template campaign-march --payload '{"name": "Alice"}' --dry-run
```

```json title="MCP (dry_run)"
{ "to": "user@example.com", "template_id": "campaign-march", "dedupe_key": "march-user@example.com", "payload": { "name": "Alice" } }
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/simulate-send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientEmail": "user@example.com",
    "templateId": "campaign-march",
    "dedupeKey": "march-user@example.com",
    "payload": { "name": "Alice" }
  }'
```

| Field | Required | Description |
| - | - | - |
| `tenantId` | no | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `recipientEmail` | yes | Recipient email address |
| `templateId` | no | Template to validate against (default `_default`, as on a real send) |
| `dedupeKey` | no | Deduplication key: a key an earlier request already used comes back `duplicate` |
| `mailboxId` | no | Mailbox to send from (CLI `--mailbox`; default: the mailbox a real send would use) |
| `payload` | no | Template variables -- the simulation renders the template with them, as the send would |
| `threadId` | no | The thread the send would reply in (CLI `--thread-id`, MCP `thread_id`). A reply skips the cooldown when it answers the contact's newest message (they wrote since your last message to them in the thread) and is checked for thread risk and for a human take-over, as a real reply is. A key scoped to some mailboxes may only name a thread in them (403 otherwise) |

The key needs `send` on the mailbox, the same scope the real send needs.

### Response

| Field | Meaning |
| - | - |
| `wouldAllow` | `false` when the send would be blocked. `reason` is exactly what the real send would say; `blockingReasons` lists every block found, in check order, so you can fix them all in one pass |
| `wouldRequireApproval` | `true` when nothing blocks but the send would wait for a human first (a real send answers `pending_approval`); `approvalReason` is `autonomy_level`, `thread_risk_flagged` or `content_check` |
| `wouldDefer` | `true` when the send would be scheduled for a later day to fit the domain warmup cap; `scheduledFor` (date) and `scheduledAt` (timestamp) say when |
| `rendered` | The `subject`, `html` and `text` the send would deliver. An unsubscribe link in it is a placeholder that never resolves (warning `unsubscribe_link_placeholder`): a simulation mints no real link. With no `payload` at all (a policy-only check), a stored template whose variables can't be filled is not rendered: warning `content_not_rendered`, not a block. `simulate-batch` never renders content and returns policy results only |
| `resolvedFromAddress`, `resolvedMailboxId` | The address and mailbox the send would go out from |
| `cooldownExpiresAt` | With `reason: "cooldown"`, when the cooldown ends |
| `suppressionInfo` | With `reason: "suppressed"`, the suppression's `scope` and `reasonCode` |
| `leaseHolder` | With `reason: "lease_conflict"`, the agent holding the lease (`agentId`, `agentName`, `expiresAt`) |
| `takenOverThreadId` | With `reason: "thread_taken_over"`, the thread a human has taken over (a real send answers this as a `409`) |
| `policyContext` | The inputs the policy engine saw: `hasDuplicate`, `isCooldownHit`, `isSuppressed`, `isConsentMissing`, `isDisengaged` (always `false` unless the workspace has disengaged auto-suppression on, which nothing self-serve turns on today), `hasActiveOpportunity`, `isTenantPaused`, `templateApproved`, `templateLintPassed`, the daily negative-signal count and budget, `isBillingBlocked` and `billingPlan` |
| `rateLimited` | `true` when a send quota window is used up |
| `warnings` | Rendering warnings (see `rendered`) |
| `simulation` | Always `true` |

```json title="Allowed"
{
  "wouldAllow": true,
  "wouldRequireApproval": false,
  "wouldDefer": false,
  "rendered": { "subject": "Hello Alice", "html": "<p>Hi Alice!</p>", "text": null },
  "simulation": true
}
```

```json title="Held for approval (mailbox autonomy level 1 or 2)"
{
  "wouldAllow": true,
  "wouldRequireApproval": true,
  "approvalReason": "autonomy_level",
  "wouldDefer": false,
  "simulation": true
}
```

```json title="Deferred to a warmup slot"
{
  "wouldAllow": true,
  "wouldRequireApproval": false,
  "wouldDefer": true,
  "scheduledFor": "2026-03-02",
  "scheduledAt": "2026-03-02T09:00:00.000Z",
  "simulation": true
}
```

```json title="Blocked"
{
  "wouldAllow": false,
  "reason": "cooldown",
  "blockingReasons": ["cooldown"],
  "cooldownExpiresAt": "2026-03-01T12:10:00Z",
  "simulation": true
}
```

```json title="Suppressed"
{
  "wouldAllow": false,
  "reason": "suppressed",
  "blockingReasons": ["suppressed"],
  "suppressionInfo": {
    "scope": "tenant",
    "reasonCode": "hard_bounce"
  },
  "simulation": true
}
```

```json title="Billing blocked (trial not activated)"
{
  "wouldAllow": false,
  "reason": "trial_not_activated",
  "blockingReasons": ["trial_not_activated"],
  "simulation": true,
  "policyContext": {
    "isBillingBlocked": true,
    "billingPlan": "trial"
  }
}
```

The simulation checks billing status in addition to policy rules. If your account is on a trial or expired plan, the simulation correctly reports that real sends would be blocked. This prevents false positives where policy checks pass but actual sends fail due to billing.

The response uses the same `reason` values as real sends -- see [Errors & Policy Blocks](https://molted.email/docs/errors.md) for the full list.

### Content check preview

On accounts in the content-check rollout (accounts created from 10 October 2026, and every account from 9 November 2026), a simulation that has content to judge (inline `subject`/`text`/`html`, or a stored template rendered with your `payload`) also returns a `contentCheck` object. It holds the model's answers about the rendered email: whether it makes deceptive or unsupported claims, asks the recipient for credentials, codes or payment in a phishing-like way, or contains another person's data or text from another thread, plus a 1-5 `spamLikeness` and a `contentType` (`commercial`, `transactional` or `reply_in_thread`). `wouldHold` and `holdReasons` say whether those answers would hold a real send for approval; `holdEnabled` says whether holds are actually switched on (off by default). If the check times out or fails, `fallbackReason` is set and the simulation is otherwise unchanged.

`contentCheck` never changes `wouldAllow`, and `contentType` is informational: your template's type still decides unsubscribe and physical-address handling. The field is absent on accounts outside the rollout.

## Simulate a batch

Dry-run policy evaluation for up to 500 recipients of one template.

```
POST https://mailbox.molted.email/v1/agent/simulate-batch
```

```bash title="CLI"
molted send simulate-batch --template campaign-march \
  --recipients '["user1@example.com", "user2@example.com"]'
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/agent/simulate-batch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "campaign-march",
    "recipientEmails": ["user1@example.com", "user2@example.com"]
  }'
```

The body is `templateId` and `recipientEmails` (an array of addresses, at most 500; more is `400 batch_too_large`). Each recipient gets the checks the [batch send](https://molted.email/docs/batch-operations.md#batch-send) would run (no lease check), with no payload, so nothing is rendered. The response counts `wouldAllow`, `wouldBlock` and `wouldDefer`, and each of its `results` carries `recipientEmail`, `wouldAllow`, `reason`, `cooldownExpiresAt`, and, when they apply, `wouldRequireApproval`/`approvalReason` and `wouldDefer`/`scheduledFor`. The warmup projection is spread across the batch, so later recipients can land on later days. See [Batch Operations](https://molted.email/docs/batch-operations.md#simulate-batch).

## Check remaining quotas

```
GET https://mailbox.molted.email/v1/agent/budget
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/agent/budget" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

In the CLI this is `molted budget`; in MCP, `check_budget`.

### Response

```json title="Response"
{
  "tenantId": "tenant_abc123",
  "monthly": { "used": 450, "limit": 1000, "remaining": 550 },
  "daily": { "used": 23, "limit": 10000, "remaining": 9977 },
  "hourly": { "used": 5, "limit": 1000, "remaining": 995 },
  "inbound": { "used": 120, "limit": 3000, "remaining": 2880, "held": 0, "heldReason": null, "overAllowanceBehavior": "hold" },
  "negativeSignals": { "count": 2, "budget": 50, "remaining": 48 },
  "timestamp": "2026-04-12T12:00:00.000Z"
}
```

- `monthly` also carries `inOverage`, `overageCount`, `hardCap` and `hardCapRemaining` on a plan with overage.
- `daily` carries `planLimit` and a `freeRamp` object while a new Free account is still in its daily ramp (`limit` is then the ramp step's cap).
- `inbound` is this month's received mail against the separate inbound allowance (`limit`/`remaining` `null` = unlimited; `held` counts messages held from agents); `null` when it can't be read.
- `teamAddressSends` (when present) counts this month's sends to verified team addresses, which only count toward the hourly window.
- `negativeSignals` is the last 24 hours of bounces and complaints against the account's budget; at the budget, sends are blocked with `negative_signal_budget_exceeded`.

## Important notes

- **No side effects** -- simulations do not increment counters, reserve warmup slots, create send records, mint unsubscribe links, or trigger delivery. They are read-only.
- **Billing included** -- simulations check your billing status exactly as a send does and will return `trial_not_activated` or `subscription_expired` (also for a paid plan whose payment grace period has run out) if sends would be blocked. The `policyContext` includes `isBillingBlocked` and `billingPlan` fields.
- **Time-of-check vs. time-of-use** -- a simulation result reflects the policy state at the moment of the check. By the time you submit the actual send, conditions may have changed (e.g., another agent may have consumed budget). Treat simulation results as guidance, not a guarantee.
- **Before a template is chosen** -- `POST /v1/agent/propose-email` (`molted send propose`, MCP `draft_email`) runs the same checks without a template and lists the ones it could not run in `notChecked`. See [Propose before you send](https://molted.email/docs/sending-email.md#propose-before-you-send).
