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-sendmolted send simulate --to user@example.com --subject "Hello" --body "Hi Alice!"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{ "to": "user@example.com", "template_id": "campaign-march", "dedupe_key": "march-user@example.com", "payload": { "name": "Alice" } }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 |
{
"wouldAllow": true,
"wouldRequireApproval": false,
"wouldDefer": false,
"rendered": { "subject": "Hello Alice", "html": "<p>Hi Alice!</p>", "text": null },
"simulation": true
}{
"wouldAllow": true,
"wouldRequireApproval": true,
"approvalReason": "autonomy_level",
"wouldDefer": false,
"simulation": true
}{
"wouldAllow": true,
"wouldRequireApproval": false,
"wouldDefer": true,
"scheduledFor": "2026-03-02",
"scheduledAt": "2026-03-02T09:00:00.000Z",
"simulation": true
}{
"wouldAllow": false,
"reason": "cooldown",
"blockingReasons": ["cooldown"],
"cooldownExpiresAt": "2026-03-01T12:10:00Z",
"simulation": true
}{
"wouldAllow": false,
"reason": "suppressed",
"blockingReasons": ["suppressed"],
"suppressionInfo": {
"scope": "tenant",
"reasonCode": "hard_bounce"
},
"simulation": true
}{
"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 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-batchmolted send simulate-batch --template campaign-march \
--recipients '["user1@example.com", "user2@example.com"]'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 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.
Check remaining quotas
GET https://mailbox.molted.email/v1/agent/budgetcurl "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
{
"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"
}monthlyalso carriesinOverage,overageCount,hardCapandhardCapRemainingon a plan with overage.dailycarriesplanLimitand afreeRampobject while a new Free account is still in its daily ramp (limitis then the ramp step's cap).inboundis this month's received mail against the separate inbound allowance (limit/remainingnull= unlimited;heldcounts messages held from agents);nullwhen it can't be read.teamAddressSends(when present) counts this month's sends to verified team addresses, which only count toward the hourly window.negativeSignalsis the last 24 hours of bounces and complaints against the account's budget; at the budget, sends are blocked withnegative_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_activatedorsubscription_expired(also for a paid plan whose payment grace period has run out) if sends would be blocked. ThepolicyContextincludesisBillingBlockedandbillingPlanfields. - 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, MCPdraft_email) runs the same checks without a template and lists the ones it could not run innotChecked. See Propose before you send.