Diagnostic Amazon SES

SES silently rejects real recipients because the account is still in the sandbox

Every test you ran worked. You sent to yourself, to a colleague, to a second address you own, and all of them arrived. Then the first real customer signed up and the send threw MessageRejected: Email address is not verified. Nothing changed in your code. The account is still in the SES sandbox, and the sandbox only lets you mail addresses you have already proved you control — which is exactly every address you tested with.

Amazon SES v2 API Python and Node.js Detect through the API
The short answer

A new SES account starts in the sandbox: 200 messages per 24 hours, one message per second, and recipients must be verified identities. Because you naturally test with addresses you own, and owning them is what verification proves, the restriction stays invisible until a stranger signs up.

GetAccount returns ProductionAccessEnabled. Check it in your deploy pipeline rather than discovering it from a customer. Leaving the sandbox is a support request, not an API call, so the script detects and reports — it cannot fix this one for you.

The problem in plain words

The error text is MessageRejected with a message like Email address is not verified. The following identities failed the check in region US-EAST-1. It names the recipient, which reads like the recipient is at fault. They are not. In the sandbox SES requires both ends of the send to be verified, and your customer obviously has not verified anything with your AWS account.

The trap is that the failure is invisible in testing. You verify your own domain, you send to yourself, everything passes. The restriction only appears the first time you mail somebody who is not you — usually in production, usually to a real user, usually on a signup or password reset.

Why it happens

The sandbox is the default, and it is quiet about it. AWS puts every new SES account there to stop the service being used to send spam from a fresh account. Nothing in the send path warns you; the console shows a banner you stop noticing after a week.

Verification proves control, and you control your test addresses. The sandbox rule is that recipients must be verified. Every address a developer naturally reaches for — their own, the team's, a second domain they own — is one they can verify. The test set and the restricted set are the same set, so the tests cannot catch it.

Region is part of the answer. Sandbox status is per region. An account with production access in us-east-1 can still be sandboxed in eu-west-1, and a deploy that changes region reintroduces the problem in a place nobody thinks to look.

How to fix it

Ask the API rather than the console

GetAccount answers definitively for the region you call it in.

aws sesv2 get-account --region us-east-1 \
  --query '{Production:ProductionAccessEnabled,Max24Hour:SendQuota.Max24HourSend,Rate:SendQuota.MaxSendRate,Enforcement:EnforcementStatus}'

Production: false with Max24Hour: 200 is the sandbox signature.

Check every region you actually send from

Production access is granted per region. If your staging stack runs in one region and production in another, or you added a region for latency, each one needs its own request. The script below sweeps a list of regions so a missed one shows up before a customer finds it.

Request production access

This part is not scriptable. Open the SES console for the region, choose Request production access, and describe how you send, how people opt in, and how you handle bounces and complaints. Requests that describe a real bounce-handling process are approved faster than ones that do not.

Make the check part of deployment

Run the detector in CI against the region you are deploying to and fail the build if production access is off. That converts a customer-facing incident into a red pipeline, which is where you want to find out.

How to check it worked

After approval, the same call flips over:

aws sesv2 get-account --region us-east-1 --query 'ProductionAccessEnabled'
# true

# and the quota is no longer the sandbox 200
aws sesv2 get-account --region us-east-1 --query 'SendQuota'

Send one real message to an address you have never verified. If it arrives, you are out.

The full code

The script checks one or more regions and exits non-zero if any of them is still sandboxed, so it can sit in a deploy pipeline. It reports the quota and the enforcement status alongside, because an account can have production access and still be paused.

ses_sandbox_check.py
"""Fail if any SES region is still in the sandbox.

Written to run in CI: exits non-zero when a region cannot mail arbitrary
recipients, so a deploy stops before a customer finds out for you.
"""
import argparse
import logging
import sys

import boto3

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ses_sandbox_check")

SANDBOX_DAILY_QUOTA = 200.0


def assess(account):
    """Pure decision function over a GetAccount response.

    Production access and enforcement are separate: an account can be out of the
    sandbox and still be SHUTDOWN or UNDER_REVIEW, which fails sends just as hard.
    """
    production = bool(account.get("ProductionAccessEnabled"))
    enforcement = (account.get("EnforcementStatus") or "HEALTHY").upper()
    quota = float(account.get("SendQuota", {}).get("Max24HourSend") or 0)

    problems = []
    if not production:
        problems.append("still in the sandbox: recipients must be verified identities")
    if enforcement != "HEALTHY":
        problems.append(f"enforcement status is {enforcement}")
    if production and quota <= SANDBOX_DAILY_QUOTA:
        problems.append(f"production access is on but the quota is only {quota:.0f}/24h")
    return problems


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--regions", nargs="+", default=["us-east-1"])
    args = ap.parse_args()

    failed = False
    for region in args.regions:
        account = boto3.client("sesv2", region_name=region).get_account()
        problems = assess(account)
        quota = account.get("SendQuota", {})
        if problems:
            failed = True
            for p in problems:
                log.error("%s: %s", region, p)
        else:
            log.info("%s: production access, %.0f/24h at %.0f/sec",
                     region, quota.get("Max24HourSend", 0), quota.get("MaxSendRate", 0))
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
ses-sandbox-check.mjs
/**
 * Fail if any SES region is still in the sandbox.
 *
 * Written to run in CI: exits non-zero when a region cannot mail arbitrary
 * recipients, so a deploy stops before a customer finds out for you.
 */
import { SESv2Client, GetAccountCommand } from '@aws-sdk/client-sesv2';

const SANDBOX_DAILY_QUOTA = 200;

/**
 * Pure decision function over a GetAccount response.
 *
 * Production access and enforcement are separate: an account can be out of the
 * sandbox and still be SHUTDOWN or UNDER_REVIEW, which fails sends just as hard.
 */
export function assess(account) {
  const production = Boolean(account.ProductionAccessEnabled);
  const enforcement = (account.EnforcementStatus ?? 'HEALTHY').toUpperCase();
  const quota = Number(account.SendQuota?.Max24HourSend ?? 0);

  const problems = [];
  if (!production) problems.push('still in the sandbox: recipients must be verified identities');
  if (enforcement !== 'HEALTHY') problems.push(`enforcement status is ${enforcement}`);
  if (production && quota <= SANDBOX_DAILY_QUOTA) {
    problems.push(`production access is on but the quota is only ${quota}/24h`);
  }
  return problems;
}

async function main() {
  const regions = process.argv.slice(2).filter((a) => !a.startsWith('--'));
  const targets = regions.length ? regions : ['us-east-1'];

  let failed = false;
  for (const region of targets) {
    const account = await new SESv2Client({ region }).send(new GetAccountCommand({}));
    const problems = assess(account);
    if (problems.length) {
      failed = true;
      for (const p of problems) console.error(`${region}: ${p}`);
    } else {
      console.log(`${region}: production access, ${account.SendQuota?.Max24HourSend}/24h`);
    }
  }
  process.exit(failed ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The rule is worth testing because two of its three cases are easy to forget: an account can be out of the sandbox and still blocked, and production access with a 200/day quota means the increase never actually applied.

test_ses_sandbox_check.py
from ses_sandbox_check import assess


def healthy():
    return {"ProductionAccessEnabled": True, "EnforcementStatus": "HEALTHY",
            "SendQuota": {"Max24HourSend": 50000.0, "MaxSendRate": 14.0}}


def test_healthy_account_has_no_problems():
    assert assess(healthy()) == []


def test_sandbox_is_reported():
    acct = healthy() | {"ProductionAccessEnabled": False}
    assert any("sandbox" in p for p in assess(acct))


def test_enforcement_is_separate_from_sandbox():
    """Out of the sandbox but SHUTDOWN still cannot send."""
    acct = healthy() | {"EnforcementStatus": "SHUTDOWN"}
    assert any("SHUTDOWN" in p for p in assess(acct))


def test_production_access_with_sandbox_quota_is_suspicious():
    acct = healthy() | {"SendQuota": {"Max24HourSend": 200.0, "MaxSendRate": 1.0}}
    assert any("quota" in p for p in assess(acct))


def test_missing_enforcement_defaults_to_healthy():
    acct = healthy()
    del acct["EnforcementStatus"]
    assert assess(acct) == []
ses-sandbox-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { assess } from './ses-sandbox-check.mjs';

const healthy = () => ({
  ProductionAccessEnabled: true,
  EnforcementStatus: 'HEALTHY',
  SendQuota: { Max24HourSend: 50000, MaxSendRate: 14 },
});

test('a healthy account has no problems', () => {
  assert.deepEqual(assess(healthy()), []);
});

test('the sandbox is reported', () => {
  const problems = assess({ ...healthy(), ProductionAccessEnabled: false });
  assert.ok(problems.some((p) => p.includes('sandbox')));
});

test('enforcement is separate from the sandbox', () => {
  const problems = assess({ ...healthy(), EnforcementStatus: 'SHUTDOWN' });
  assert.ok(problems.some((p) => p.includes('SHUTDOWN')));
});

test('production access with a sandbox quota is suspicious', () => {
  const problems = assess({ ...healthy(), SendQuota: { Max24HourSend: 200, MaxSendRate: 1 } });
  assert.ok(problems.some((p) => p.includes('quota')));
});

FAQ

Why did my tests pass if the account was sandboxed?

Because the sandbox restricts recipients to verified identities, and every address a developer naturally tests with is one they own and can verify. The test set and the restricted set are the same set, so the restriction is invisible until a stranger receives mail.

Is the sandbox per account or per region?

Per region. Production access in us-east-1 says nothing about eu-west-1. Adding a region, or a staging stack that runs somewhere else, reintroduces the sandbox in a place nobody thinks to check.

Can a script take the account out of the sandbox?

No. Production access is a support request reviewed by AWS. A script can only detect the state and fail your pipeline, which is still worth doing because it moves the discovery from a customer to a build.

The account has production access but sends still fail. Why?

Check EnforcementStatus. An account can be out of the sandbox and still be UNDER_REVIEW or SHUTDOWN because of bounce or complaint rates, which fails sends for a completely different reason. The detector reports both.

How many can I send in the sandbox?

200 messages per 24 hours at one message per second, to verified identities only. If your quota still reads 200 after production access is granted, the increase did not apply and it is worth raising.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.