Mailfully logo

Test mode

Send with an mf_test_ key to exercise the real send path without delivering mail, force per-recipient bounces and complaints, and read back which recipient was affected.

An mf_test_ key runs the same code path as a live send: the request is validated, persisted, queued, and handed to Amazon SES, and real SES events come back as webhooks and timeline events. The one difference is the envelope. Every recipient is rewritten to an address on the Amazon SES mailbox simulator, so no mail reaches a real inbox.

curl -X POST https://api.mailfully.com/v1/emails \
  -H "Authorization: Bearer mf_test_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "orders@mail.acme.com",
    "to": "ada@example.com",
    "subject": "Your order shipped",
    "text": "Order 4212 shipped."
  }'

Test sends are exempt from the verified-domain gate and are never metered against your monthly quota, so you can build an integration before DNS is in place. The daily send cap still applies, and test messages are only visible to mf_test_ keys. See Authentication for how the two environments separate your data.

Force an outcome

The local part of each recipient decides what SES simulates:

Send toSimulated outcome
bounce@…Permanent hard bounce
complaint@…Spam complaint
suppressionlist@…Permanent bounce, as if the address sat on the SES account-level suppression list
ooto@…Delivered, followed by an out-of-office auto-reply
success@…, or anything elseDelivered

Only the local part selects the outcome, so keep whatever fixture domain you already use: bounce@example.com, bounce@acme.test, and bounce@yourcompany.com all bounce. Matching is case-insensitive.

The whole local part has to be the keyword. bounce@example.com bounces, but bounce-1@example.com and bounce+retry@example.com are ordinary addresses and deliver.

How recipients are rewritten

Each recipient becomes a labeled simulator address, <outcome>+<label>@simulator.amazonses.com, where the label identifies the address you asked for:

ada@example.com          →  success+ada=example.com@simulator.amazonses.com
bounce@example.com       →  bounce+bounce=example.com@simulator.amazonses.com
Ada <ada@example.com>    →  success+ada=example.com@simulator.amazonses.com
ada+news@example.com     →  success+ada_news=example.com@simulator.amazonses.com

The label is the lowercased address with @ rewritten to = and every character outside [a-z0-9._=-] rewritten to _. Labels over 48 characters are shortened to their first 38 characters plus a dot and 8 hex digits of the address's SHA-256, which keeps the local part inside the 64-octet limit RFC 5321 sets.

Labels exist because SES combines the simulated bounces from one sending request into a single notification. Without a per-recipient label, a bounce on a three-recipient message names bounce@simulator.amazonses.com and tells you nothing about which recipient it belongs to. AWS supports labels on simulator addresses for this VERP-style matching, and Mailfully uses them so a test bounce points at exactly one recipient.

Read back the affected recipient

Bounce and complaint events carry a recipients array, and the labels are resolved back to the addresses you originally requested. A message sent to ada@example.com and bounce@example.com reports the bounce like this:

curl https://api.mailfully.com/v1/emails/01J1X6P9T3KQ7ZW2V5R8YBAGMD/events \
  -H "Authorization: Bearer mf_test_xxxxxxxxxxxx"
{
  "data": [
    { "type": "send", "event_at": "2026-07-08T14:02:12.000Z", "detail": null },
    {
      "type": "bounce",
      "event_at": "2026-07-08T14:02:15.000Z",
      "detail": {
        "bounce_type": "Permanent",
        "bounce_subtype": "General",
        "diagnostic_code": "smtp; 550 5.1.1 user unknown",
        "recipients": ["bounce@example.com"]
      }
    }
  ]
}

The email.bounced webhook resolves the same way, in data.bounce.recipients.

Resolution is not conditional on the environment. A live address never matches the labeled-simulator pattern and passes through exactly as SES reported it, so a test payload has the same shape as a live one and your handler needs no test-only branch.

What test mode leaves alone

  • The message keeps your recipients. Only the SES envelope is rewritten. GET /v1/emails/:id returns the to, cc, and bcc you sent, and list filters match those addresses.
  • Templates, domains, webhooks, and suppressions are shared with live mode. One webhook endpoint receives events from both environments; tag your test sends if your handler needs to tell them apart.
  • Everything before the send still runs: idempotency, scheduling, cancellation, suppression filtering, and template rendering behave as they do in live mode. Rate limits apply too, from a separate bucket at the same rate, so test traffic does not eat your live allowance.

Suppressions from test bounces

A test bounce or complaint suppresses the simulator address, for example bounce+ada=example.com@simulator.amazonses.com, never ada@example.com. Two things follow:

  • A bounce drill can never block a live send to that person, and repeating the drill works because the address you send to is not the one on the list.
  • Those entries show up in your suppression list, one per distinct test recipient. Unlike live entries they are not mirrored to the SES account-level suppression list, since AWS never suppresses simulator addresses.

Suppression entries are org-global, so simulator addresses appear when you list suppressions with a live key too. The list has no domain filter; skip anything ending in @simulator.amazonses.com in an operational view, or delete the entries once a drill is done.