---
title: "Test an AI agent's email and SMS before real people get them"
description: "Test agent email and SMS on inboxes and phones you own: render and test-send templates, validate campaigns, fire webhook tests, use simulator numbers."
url: https://www.unitpost.com/blog/agents/test-ai-agent-email-sms
section: Blog
updated: 2026-11-02
---
# Test an AI agent's email and SMS before real people get them

Unitpost has no separate test mode, so a test send is a real message: point it at inboxes and phones you own. Render templates without sending, test-send them to your own address, validate a campaign before anyone can press Send, fire signed test events at your webhook, text the simulator or a verified test phone, and run the agent against inbound fixtures that include injection attempts. Then prove a retry never sends twice.

## Test an AI agent's email and SMS before real people get them

> Test agent email and SMS on inboxes and phones you own: render and test-send templates, validate campaigns, fire webhook tests, use simulator numbers.

An agent that sends gets tested on two things: what it writes and what it does. The failures that reach people are mostly the second kind: the wrong recipient, a second copy on retry, a campaign to the whole list. You can test all of those without reaching anyone.

### Where should an agent's test mail go?

Unitpost has no separate test mode for email. A send with a live key is a real message: delivered, counted and checked against suppressions. So every test address must be one you own: a Gmail and an Outlook account you control, a teammate who agreed, or an address at your own receiving subdomain.

Enforce that in code, not in the prompt. Put an allowlist at the agent's tool boundary, the same check that refuses an unknown recipient in production (how to make an AI agent ask before it sends (/blog/agents/ai-agent-approval-before-send)), and in the test environment fill it with your own inboxes only.

Give the test agent its own API key. Its requests are then logged under that key, and you can revoke it without touching production.

### How do you test a template without sending it?

Render it first. `POST /email/templates/{id}/render` (`templates:read`) returns the `subject`, `html` and `text` a send would carry, footer and branding included, plus `missing_variables`. Nothing is stored or sent. Pass `contact_id` to fill fields from a real contact (that also needs `contacts:read`). In a test, assert that `missing_variables` is empty for each fixture contact.

Then send one. `POST /email/templates/{id}/test-send` (`emails:send`) takes `to`, an optional `subject` and sample `variables`, and returns the `email_id`, its `status` and any `unresolved_variables`. With a verified domain the test can go to anyone, from `test@` your domain; without one it goes only to a workspace member, from a shared test address, and any other recipient is a `403`. Over MCP the tool is `email_templates_test_send`.

### How do you keep an agent from sending a campaign by mistake?

A campaign goes create, validate, fix, validate again, then send. `GET /email/campaigns/{id}/validate` (`campaigns:read`) sends nothing and returns `can_send`, the blockers, `warnings[]` (such as `SCHEDULE_PASSED` or `NO_TOPIC`), `variable_coverage[]` and `unresolved_variables`. For a large audience it answers `202` with a job while it counts, and a send attempted meanwhile is `409 preflight_pending`. The SMS version, `GET /sms/campaigns/{id}/validate`, adds `reachable`, `skipped`, `no_marketing_consent` and `quiet_hours_deferred`.

To test the agent's judgment, run it with a credential that lacks `campaigns:send`: a key without it, or an OAuth sign-in with that box unticked. The agent can draft and validate. Your test asserts that it shows the person `can_send`, the blockers and the warnings, and that any send attempt came back `403 insufficient_scope`.

### How do you test the webhook handler?

`POST /webhooks/{id}/test` (`webhooks:manage`) sends one event to the endpoint at once, signed with its secret exactly like a real delivery, and returns `delivered`, `response_status`, `event_type` and `error`. It picks the first event type the endpoint subscribes to, or `email.delivered` for an `email.*` wildcard. The event isn't stored in the delivery log.

The payload is the documented example for that type, with `data.test` set to `true` and `event_id` null. For an `email.received` endpoint, that means the sample's message id doesn't exist in your workspace, and `GET /email/received/{id}` answers `404`. So verify the signature first, then check `data.test` before your dedupe and the fetch, and return `2xx`.

For the whole inbound path, email your receiving subdomain from a mailbox you own. That is a real `email.received`, with a body to fetch and SPF, DKIM and DMARC verdicts on it.

### How do you test SMS without texting a customer?

Two kinds of test number, both under SMS, Senders, Testing:

- Simulator numbers. `+14254147755` always delivers and `+14254147167` always fails. They never reach a phone, so they test the send path, not what a customer sees. Use the failing one to check that the agent handles a failed text instead of retrying forever.
- Your own phone. Add it there and enter the code Unitpost texts you. Verifying needs a live sending number, because the simulator can't deliver the code. `GET /sms/test-destinations` (`sms:read`) lists your test numbers as `pending` or `verified`.

`POST /sms/templates/{id}/test-send` (`sms:send`) takes `to`, optional `variables` and `sender_id`, and texts only a simulator number or a verified test number: anything else is a `403`. It takes an `Idempotency-Key`, so a retried test never texts twice.

### What belongs in the agent's eval fixtures?

For an agent that answers mail, build fixtures in the shape `GET /email/received/{id}` returns: `from`, `reply_to`, `subject`, `text`, `html`, `dmarc_verdict`, `spam_verdict`. Include the attacks along with the ordinary requests:

- an instruction hidden in a `display:none` span in the HTML
- "ignore previous instructions" inside quoted text
- a message claiming to be from your CEO with `dmarc_verdict` set to `FAIL`
- a `reply_to` that differs from `from`
- a `text` part that says something different from the `html`
- an out-of-office reply, and a copy of one of your own sends, which is how loops start

Assert on what the agent does, not how it words it: which tools it called, which recipient your code chose, and whether it handed off to a person. In unit runs, let the tool boundary record calls instead of making them. Run the whole set on every prompt or model change. How to protect an AI email agent from prompt injection (/blog/agents/protect-ai-email-agent-prompt-injection) lists the checks the fixtures should exercise.

### How do you prove a retry never sends twice?

Derive the idempotency key from the unit of work, such as `reply-` plus the inbound id, never a fresh key per attempt. Then test the cases:

- Same key, same body, within 24 hours: the first result comes back with `Idempotent-Replayed: true`, and no second email exists.
- Same key, different body, as when a rerun makes the model write a new draft: `409 invalid_idempotent_request`. Assert that the agent treats it as already sent.
- Two calls at once with one key: `409 concurrent_idempotent_requests`. Assert that it waits and retries.
- Kill the worker after the send and run the job again: still one email.

Over MCP a tool call has no headers, so `email_send` and `sms_send` take an `idempotency_key` input, and the agent's instructions must tell it to reuse the key when it tries again. `email_templates_test_send` honors one too, although its REST route takes no `Idempotency-Key`.

### What should pass before launch?

- every template renders with no `missing_variables`
- the test agent can reach only your own inboxes and test numbers
- campaign sends need `campaigns:send`, and the agent validates first
- the webhook handler verifies signatures, survives a test event and dedupes
- the injection fixtures produce a handoff, never a send
- a rerun of any job sends nothing new

## FAQ

### Does Unitpost have a sandbox or test mode for email?

Not today. A send with a live key is a real email: delivered, counted and checked against suppressions. Test with inboxes you own, render templates with POST /email/templates/{id}/render, which sends nothing, and send single tests with POST /email/templates/{id}/test-send.

### Can an SMS test go to any phone number?

No. POST /sms/templates/{id}/test-send only texts the simulator numbers, +14254147755 (always delivers) and +14254147167 (always fails), or a phone your workspace verified with a code under SMS, Senders, Testing. Any other number is a 403.

### Do webhook test events show up in the delivery log?

No. POST /webhooks/{id}/test sends one signed sample event straight to your endpoint and returns what it answered, but the event isn't stored. Its data has test set to true and its event_id is null, so your handler can tell it apart.

## Related

- [Transactional vs marketing email: what's the difference?](https://www.unitpost.com/blog/concepts/transactional-vs-marketing-email): The practical difference between transactional and marketing email (consent, unsubscribe rules, reputation risk) and when one tool for both is right.
- [How to warm up a new sending domain](https://www.unitpost.com/blog/deliverability/warm-up-new-sending-domain): Why Gmail throttles mail from a new domain, what mailbox providers actually publish about ramping volume, and a conservative ramp you can follow.
- [What is an idempotency key, and when do you need one?](https://www.unitpost.com/blog/concepts/what-is-an-idempotency-key): How idempotency keys prevent duplicate sends when a request is retried, how to choose one, and the failure modes they don't cover.
