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.
Unitpost ·
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), 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.
+14254147755always delivers and+14254147167always 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 aspendingorverified.
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:nonespan in the HTML - "ignore previous instructions" inside quoted text
- a message claiming to be from your CEO with
dmarc_verdictset toFAIL - a
reply_tothat differs fromfrom - a
textpart that says something different from thehtml - 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 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