MOLTED EMAIL

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
CLI (shorthand)
molted send simulate --to user@example.com --subject "Hello" --body "Hi Alice!"
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
MCP (dry_run)
{ "to": "user@example.com", "template_id": "campaign-march", "dedupe_key": "march-user@example.com", "payload": { "name": "Alice" } }
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" }
  }'
FieldRequiredDescription
tenantIdnoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
recipientEmailyesRecipient email address
templateIdnoTemplate to validate against (default _default, as on a real send)
dedupeKeynoDeduplication key: a key an earlier request already used comes back duplicate
mailboxIdnoMailbox to send from (CLI --mailbox; default: the mailbox a real send would use)
payloadnoTemplate variables -- the simulation renders the template with them, as the send would
threadIdnoThe 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

FieldMeaning
wouldAllowfalse 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
wouldRequireApprovaltrue 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
wouldDefertrue when the send would be scheduled for a later day to fit the domain warmup cap; scheduledFor (date) and scheduledAt (timestamp) say when
renderedThe 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, resolvedMailboxIdThe address and mailbox the send would go out from
cooldownExpiresAtWith reason: "cooldown", when the cooldown ends
suppressionInfoWith reason: "suppressed", the suppression's scope and reasonCode
leaseHolderWith reason: "lease_conflict", the agent holding the lease (agentId, agentName, expiresAt)
takenOverThreadIdWith reason: "thread_taken_over", the thread a human has taken over (a real send answers this as a 409)
policyContextThe 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
rateLimitedtrue when a send quota window is used up
warningsRendering warnings (see rendered)
simulationAlways true
Allowed
{
  "wouldAllow": true,
  "wouldRequireApproval": false,
  "wouldDefer": false,
  "rendered": { "subject": "Hello Alice", "html": "<p>Hi Alice!</p>", "text": null },
  "simulation": true
}
Held for approval (mailbox autonomy level 1 or 2)
{
  "wouldAllow": true,
  "wouldRequireApproval": true,
  "approvalReason": "autonomy_level",
  "wouldDefer": false,
  "simulation": true
}
Deferred to a warmup slot
{
  "wouldAllow": true,
  "wouldRequireApproval": false,
  "wouldDefer": true,
  "scheduledFor": "2026-03-02",
  "scheduledAt": "2026-03-02T09:00:00.000Z",
  "simulation": true
}
Blocked
{
  "wouldAllow": false,
  "reason": "cooldown",
  "blockingReasons": ["cooldown"],
  "cooldownExpiresAt": "2026-03-01T12:10:00Z",
  "simulation": true
}
Suppressed
{
  "wouldAllow": false,
  "reason": "suppressed",
  "blockingReasons": ["suppressed"],
  "suppressionInfo": {
    "scope": "tenant",
    "reasonCode": "hard_bounce"
  },
  "simulation": true
}
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 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
CLI
molted send simulate-batch --template campaign-march \
  --recipients '["user1@example.com", "user2@example.com"]'
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 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/budget
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

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.