Skip to main content

Send through your own Amazon SES

Create one IAM role, connect it with the API, and your SES-verified domains send through your own AWS account.

Connect the Amazon SES account in your own AWS account, and mail from the domains verified there goes out through it. Amazon bills you for those emails at its own price. mails.ai still runs its rules on every send, keeps each message and its events, and puts the bounces and complaints Amazon reports on your suppression list.

Connecting is done with the API for now: there is no screen for it in the dashboard yet.

Before you start

  • A paid mails.ai plan. On Free, setup and connect answer 402 feature_not_enabled, and agents send through mails.ai.
  • An API key with the manage scope, not tied to one agent: the connection is the whole workspace’s, since every agent on its domains sends through it. A connected app can read the connection but not change it.
  • An AWS account with Amazon SES in the standard AWS partition (not AWS China or GovCloud), with the domains you send from verified as identities in one SES region.

Connect your account

  1. Get the role to create

    POST /v1/ses-connection/setup answers with the IAM role your AWS account needs, filled in for your workspace. Calling it again answers the same.

    cURL
    curl -X POST https://api.mails.ai/v1/ses-connection/setup \
      -H "Authorization: Bearer $MAILS_API_KEY" \
      -o setup.json
    JSON
    {
      "object": "ses_connection_setup",
      "principal_arn": "arn:aws:iam::…",
      "external_id": "mails-…",
      "trust_policy": {
        "Version": "2012-10-17",
        "Statement": [
          {
            "Sid": "MailsAiAssumeRole",
            "Effect": "Allow",
            "Principal": { "AWS": "arn:aws:iam::…" },
            "Action": "sts:AssumeRole",
            "Condition": {
              "StringEquals": { "sts:ExternalId": "mails-…" }
            }
          }
        ]
      },
      "permissions_policy": { "Version": "2012-10-17", "Statement": [ … ] },
      "events_endpoint": "https://api.mails.ai/api/internal/byo-ses-events",
      "steps": [ … ],
      "connection": { "object": "ses_connection", "status": "pending", … }
    }

    The trust_policy lets mails.ai’s AWS principal assume the role, and only with your workspace’s external_id. The ID is made the first time you call setup and never changes. It is not a secret: it keeps another mails.ai workspace that learns your role’s ARN from connecting it. The permissions_policy is the least that sending and the bounce reports need; each statement is set out below.

  2. Create the role in AWS

    In the IAM console, create a role with a custom trust policy and paste trust_policy into it, then add permissions_policy to the role as an inline policy. Or, with the AWS CLI and the answer you saved:

    Terminal
    jq .trust_policy setup.json > trust-policy.json
    jq .permissions_policy setup.json > permissions-policy.json
    
    aws iam create-role --role-name mails-ai-ses \
      --assume-role-policy-document file://trust-policy.json \
      --query Role.Arn --output text
    
    aws iam put-role-policy --role-name mails-ai-ses \
      --policy-name mails-ai-ses \
      --policy-document file://permissions-policy.json

    The first command prints the role’s ARN, which the next step takes.

  3. Connect it

    Send the role’s ARN and the SES region you send from:

    cURL
    curl https://api.mails.ai/v1/ses-connection \
      -H "Authorization: Bearer $MAILS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "role_arn": "arn:aws:iam::123456789012:role/mails-ai-ses",
        "region": "us-east-1"
      }'

    Before it answers, mails.ai:

    • tries to assume the role without your external ID, and refuses a role that lets it in;
    • assumes it with the ID, and reads the account: sandbox or production, sending on or paused, the quota;
    • makes a configuration set, mails-ai- and your workspace ID, and an SNS topic, mails-ai-ses-events- and the same ID, which only your account’s SES may publish to, and only for that configuration set;
    • subscribes its HTTPS endpoint to the topic, and points the configuration set’s bounce, complaint and delivery events at it.

    The answer is the connection:

    JSON
    {
      "object": "ses_connection",
      "id": "sesc_01K8…",
      "status": "connected",
      "external_id": "mails-…",
      "role_arn": "arn:aws:iam::123456789012:role/mails-ai-ses",
      "aws_account_id": "123456789012",
      "region": "us-east-1",
      "configuration_set": "mails-ai-wsk_01k8…",
      "events": {
        "sns_topic_arn": "arn:aws:sns:us-east-1:123456789012:mails-ai-ses-events-wsk_01k8…",
        "confirmed": false,
        "confirmed_at": null,
        "types": ["BOUNCE", "COMPLAINT", "DELIVERY"]
      },
      "account": {
        "production_access": true,
        "sandbox": false,
        "sending_enabled": true,
        "enforcement_status": "HEALTHY",
        "max_24_hour_send": 50000,
        "max_send_rate": 14,
        "sent_last_24_hours": 0,
        "checked_at": "2026-10-21T09:30:00.000Z"
      },
      "last_error": null,
      "connected_at": "2026-10-21T09:30:00.000Z",
      "disconnected_at": null,
      "created_at": "2026-10-21T09:28:00.000Z"
    }

    A step the role may not take answers 400 ses_permission_denied, quoting the statement that grants it. Fix it and connect again: what was made before stays, and connecting again uses it.

  4. Wait for the reports to be confirmed

    Amazon SNS sends mails.ai a request to confirm the subscription, and mails.ai confirms it. Until then, nothing is sent through your account: a send from one of its domains answers 409 ses_events_not_confirmed, since its bounces would not reach your suppression list. Check events.confirmed:

    cURL
    curl https://api.mails.ai/v1/ses-connection \
      -H "Authorization: Bearer $MAILS_API_KEY"
  5. Send

    Send from an address on a domain verified in that region, or on a subdomain of one. It goes out through your account:

    cURL
    curl https://api.mails.ai/v1/messages \
      -H "Authorization: Bearer $MAILS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "from": "hello@acme.com",
        "to": "customer@example.com",
        "subject": "Your receipt",
        "body_text": "Thanks for your order."
      }'

    An app on Resend’s SDK can do the same with no code change: see Resend compatibility.

Check the account

POST /v1/ses-connection/test assumes the role again, reads the account and answers the connection with what it found. It changes nothing in your account.

cURL
curl -X POST https://api.mails.ai/v1/ses-connection/test \
  -H "Authorization: Bearer $MAILS_API_KEY"
  • account.sandbox: true: the account is in Amazon’s sandbox, where SES sends only to addresses verified in the account. Production access is something you ask Amazon for, in the SES console.
  • account.sending_enabled: false: sending is paused in the account.
  • account.max_24_hour_send and account.max_send_rate: Amazon’s quota for the account, which binds every send through it.

How a send through your account works

  • Which mail goes through it. Mail from a domain that is a verified identity in the connected region, or from a subdomain of one, goes through your account, even when you verified the same domain at mails.ai too. An email address verified in SES does not count: the domain must be. mails.ai remembers SES’s answer for a domain for up to ten minutes, or one minute when the domain is not verified. Mail from your mails.ai addresses, and from domains verified only at mails.ai, goes out as before.
  • The rules still run, as on any send: prohibited content, phishing links and plainly cold or bulk mail are refused, and suppression and the agent’s own limits and reputation apply. The AI review that also reads mail sent through mails.ai is skipped for a send that goes out at once through your account, which goes out on your account’s reputation. A scheduled send is still reviewed.
  • Your plan’s email caps do not apply. Sends through your account are not counted against your plan’s monthly emails or its hourly and daily caps. They are counted on their own: up to 200,000 a month on Pro and up to 2,500,000 a month on Scale. Past that, a send answers 429 monthly_limit_exceeded. Amazon’s quota applies as well.
  • Replies come back to the agent. Unless the send names a reply_to, its Reply-To is the agent’s receiving address at mails.ai (billing.acme_com@in.mails.ai for billing@acme.com), so a reply arrives as a reply.received event.
  • Test keys never send. A send with an mk_test_ key is stored and raises its events, and reaches neither your account nor anyone’s inbox.
  • Amazon’s refusals are passed on. When SES refuses a message, the send answers 422 ses_message_rejected with Amazon’s reason. When your account is over its sending rate or its daily quota, it answers 503 upstream_unavailable with Retry-After: 60.

Bounces and complaints

Every send through your account carries mails.ai’s configuration set, so Amazon reports its bounces, complaints and deliveries to the SNS topic, and SNS delivers them to mails.ai. A permanent bounce or a complaint puts the address on your workspace’s suppression list. You get message.bounced, message.complained, message.delivered and suppression.added events, as for any send, by polling or a webhook.

Disconnect

DELETE /v1/ses-connection stops sending through your account at once. Then mails.ai deletes the configuration set and the SNS topic it made there, if the role still lets it, and says what happened to each in removed.

cURL
curl -X DELETE https://api.mails.ai/v1/ses-connection \
  -H "Authorization: Bearer $MAILS_API_KEY"

After it, mail from a domain verified only in your SES is refused with 422 domain_not_verified. The connection keeps your external ID, so the same role can be connected again.

You can also end access from AWS: delete the role, or take mails.ai out of its trust policy. mails.ai keeps the role’s temporary credentials until five minutes before they expire, so such a change holds from the next time it asks for new ones, within the hour. Then a send from one of the account’s domains answers 400 ses_role_not_assumable.

What the role lets mails.ai do

  • ses:GetAccount On the account: whether it is in the sandbox, whether sending is on, and its quota.
  • ses:GetEmailIdentity On your identities: whether a From domain is verified in the account.
  • ses:SendEmail, ses:SendRawEmail On your identities and mails.ai’s configuration sets: the send itself, always with mails.ai’s configuration set, so every send reports its bounces and complaints.
  • ses:CreateConfigurationSet, ses:CreateConfigurationSetEventDestination, ses:UpdateConfigurationSetEventDestination, ses:DeleteConfigurationSet On configuration sets named mails-ai-*: the configuration set and its one event destination, made when you connect and deleted when you disconnect.
  • sns:CreateTopic, sns:SetTopicAttributes, sns:DeleteTopic On topics named mails-ai-ses-events-*: the topic the reports go to.
  • sns:Subscribe On those topics, to mails.ai’s HTTPS endpoint only: subscribing mails.ai to the topic. Conditions on the protocol and the endpoint keep the role from pointing your reports anywhere else.

Nothing else: mails.ai never creates, changes or deletes an identity, never reads or writes your account’s suppression list, and touches no configuration set or topic but its own.

Routes

  • POST /v1/ses-connection/setup The role to create in your AWS account, filled in for your workspace. Needs the manage scope.
  • POST /v1/ses-connection Connects the role, from its role_arn and your SES region. Needs the manage scope.
  • GET /v1/ses-connection The connection. Needs the read scope.
  • POST /v1/ses-connection/test Looks at the account again: sandbox or production, sending on or paused, the quota. Needs the manage scope.
  • DELETE /v1/ses-connection Stops sending through the account and removes what connecting made there. Needs the manage scope.

Errors

Each code with its HTTP status. Every one comes in the API’s usual error body.

  • 402 feature_not_enabled The workspace is not on a paid plan, on setup or connect.
  • 400 invalid_param_value role_arn is not an IAM role ARN in the standard AWS partition, or region is not an AWS region.
  • 400 ses_role_not_assumable mails.ai could not assume the role. The message quotes the trust policy it needs.
  • 400 ses_role_external_id_not_required The role lets mails.ai in without your external ID. Add the sts:ExternalId condition, as quoted.
  • 400 ses_permission_denied The role may not take a step. The message quotes the statement that grants it.
  • 400 ses_setup_failed Your account refused a step for another reason, which the message gives. What was made before it stays.
  • 409 ses_events_not_confirmed SNS has not confirmed the subscription yet, so nothing is sent through the account. Connect again to subscribe again.
  • 422 ses_message_rejected Amazon SES refused the message, in its own words: the sandbox, a paused account, an identity that lost its verification.
  • 422 domain_not_verified The From domain is verified neither in your SES nor at mails.ai, for example after you disconnect.
  • 429 monthly_limit_exceeded Your plan’s monthly number of emails through your own account is used up.
  • 503 upstream_unavailable AWS did not answer, or your account is over its sending rate or daily quota. Retry-After says when to try again.
  • 404 resource_not_found No account is connected.
  • 403 insufficient_scope The key lacks the scope the route needs, or is tied to one agent.
  • 403 connected_app_not_allowed A connected app tried to change the connection: use an API key.

Common questions

Why does mails.ai use a role instead of access keys?

A role leaves no secret of yours with mails.ai: there is nothing to store, leak or rotate. mails.ai assumes it with AWS STS, only with your workspace's external ID, and connecting refuses a role that would let mails.ai in without that ID, since any mails.ai workspace that learned the role's ARN could then send through your account.

How quickly does access end when I change the role?

DELETE /v1/ses-connection stops sending through your account at once. A change on the AWS side, deleting the role or editing its trust policy, holds from the next time mails.ai asks AWS for the role's credentials: it keeps them until five minutes before they expire, so within the hour.

Was this page helpful?