Guide · AWS SES v2

MCP Server AWS SES v2 — SendEmailCommand, IAM, sandbox vs production

AWS Simple Email Service v2 (SES v2) is the standard way to send transactional email from an MCP server tool — notification emails, alert dispatches, report delivery, and AI-generated content to inboxes. The v2 SDK (@aws-sdk/client-sesv2) replaced the original v1 SES SDK and consolidates sending, identity management, suppression-list control, and configuration sets into a single client. The three things you must get right before the first email delivers are identity verification (the from-address domain or email must be verified in SES), IAM permissions (narrowed to the specific identity ARN), and sandbox vs production mode (sandbox restricts you to verified recipient addresses until you request production access). Getting any of these wrong produces a silent send failure or a MessageRejected exception that is easy to confuse with application bugs.

TL;DR

Import SESv2Client and SendEmailCommand from @aws-sdk/client-sesv2. Provide FromEmailAddress (a verified identity), Destination.ToAddresses, and Content.Simple.Subject + Content.Simple.Body.Html + Content.Simple.Body.Text. IAM policy needs ses:SendEmail and ses:SendRawEmail on arn:aws:ses:REGION:ACCOUNT:identity/DOMAIN. In sandbox mode, both from and to addresses must be verified — request production access via CreateEmailIdentityCommand or the console. The default sending quota is 200 emails/day at 1 message/second in sandbox; production accounts start at 50,000/day and can be increased.

Installing the SES v2 SDK and constructing the client

The SES v2 SDK is a separate package from the original SES SDK. Do not mix the two — their clients, command names, and input shapes are different. Always use v2 for new code.

# Install
npm install @aws-sdk/client-sesv2

# Type definitions are bundled — no separate @types package needed

Construct the client once at module initialization and reuse it. The client is thread-safe and maintains an internal HTTP connection pool.

import { SESv2Client } from '@aws-sdk/client-sesv2';

// Client picks up credentials from the standard chain:
// 1. Explicit env vars (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
// 2. ~/.aws/credentials file
// 3. ECS task role / EC2 instance profile / Lambda execution role (preferred for production)
const ses = new SESv2Client({
  region: process.env.AWS_REGION ?? 'us-east-1',
  // Optional: specify a custom endpoint for localstack in tests
  // endpoint: process.env.SES_ENDPOINT,
});

SES is a regional service. The region you send from must be the same region where your verified identities live. Creating a client in us-east-1 and trying to send from an identity verified only in eu-west-1 produces a NotFoundException.

Sending a simple email: HTML + text parts

The SendEmailCommand accepts a Content union type with three variants: Simple (structured HTML+text), Raw (MIME bytes, for attachments), and Template (pre-defined template with variable substitution). For most MCP tool use cases, Simple is correct.

import {
  SESv2Client,
  SendEmailCommand,
  type SendEmailCommandInput,
} from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });

interface SendNotificationInput {
  to: string;
  subject: string;
  htmlBody: string;
  textBody: string;
}

export async function sendNotificationEmail(input: SendNotificationInput): Promise {
  const params: SendEmailCommandInput = {
    FromEmailAddress: 'alerts@yourdomain.com', // must be a verified identity
    Destination: {
      ToAddresses: [input.to],
      // CcAddresses: [],
      // BccAddresses: [],
    },
    ReplyToAddresses: ['support@yourdomain.com'], // optional — defaults to FromEmailAddress
    Content: {
      Simple: {
        Subject: {
          Data: input.subject,
          Charset: 'UTF-8', // defaults to UTF-8 when omitted; always specify explicitly
        },
        Body: {
          Html: {
            Data: input.htmlBody,
            Charset: 'UTF-8',
          },
          Text: {
            // Always include a text part — improves deliverability; required by some spam filters
            Data: input.textBody,
            Charset: 'UTF-8',
          },
        },
        Headers: [
          // Custom headers — useful for tracking, threading, or idempotency
          { Name: 'X-MCP-Tool-Call-Id', Value: 'tc_01j8x9y2z3' },
        ],
      },
    },
    // ConfigurationSetName: 'prod-transactional', // attach a configuration set for event tracking
  };

  const response = await ses.send(new SendEmailCommand(params));
  // Returns MessageId — a unique SES identifier for the message, useful for delivery tracking
  return response.MessageId!;
}

The MessageId returned is not the same as the SMTP Message-ID header in the email. It is an SES-internal identifier you can correlate with CloudWatch delivery events if you configure a configuration set.

Sending raw email (attachments and custom MIME)

Use the Raw content type when you need to attach files, embed images with Content-ID references, or construct a fully custom MIME message. Pass the MIME bytes directly — SES does not parse or validate the MIME structure beyond checking that it is valid base64 and under 10 MB.

import { createMimeMessage } from 'mimetext'; // or build MIME manually with 'nodemailer'
import { SendEmailCommand } from '@aws-sdk/client-sesv2';

async function sendEmailWithAttachment(
  to: string,
  subject: string,
  htmlBody: string,
  attachmentBuffer: Buffer,
  attachmentFilename: string,
): Promise {
  // Build MIME manually using nodemailer's createTransport().sendMail trick
  // or use a MIME library like 'mailcomposer'
  const boundary = `boundary_${Date.now()}`;
  const mimeMessage = [
    `From: alerts@yourdomain.com`,
    `To: ${to}`,
    `Subject: ${subject}`,
    `MIME-Version: 1.0`,
    `Content-Type: multipart/mixed; boundary="${boundary}"`,
    ``,
    `--${boundary}`,
    `Content-Type: text/html; charset=UTF-8`,
    `Content-Transfer-Encoding: quoted-printable`,
    ``,
    htmlBody,
    `--${boundary}`,
    `Content-Type: application/octet-stream`,
    `Content-Disposition: attachment; filename="${attachmentFilename}"`,
    `Content-Transfer-Encoding: base64`,
    ``,
    attachmentBuffer.toString('base64'),
    `--${boundary}--`,
  ].join('\r\n');

  const response = await ses.send(new SendEmailCommand({
    Content: {
      Raw: {
        Data: Buffer.from(mimeMessage), // SES accepts Buffer or Uint8Array
      },
    },
    // Note: Destination is optional for Raw — SES reads To/Cc/Bcc from the MIME headers.
    // But providing Destination explicitly takes precedence — useful for BCC injection.
    Destination: {
      ToAddresses: [to],
    },
    FromEmailAddress: 'alerts@yourdomain.com',
  }));

  return response.MessageId!;
}

IAM policy: minimal permissions for sending

SES uses resource-based IAM where the resource ARN identifies the verified identity (email address or domain). The sending actions require two permissions: ses:SendEmail for SendEmailCommand and ses:SendRawEmail for Raw content. Never grant ses:* — that includes identity management, suppression list modification, and configuration set deletion.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSendingFromVerifiedDomain",
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:SendRawEmail"
      ],
      "Resource": [
        "arn:aws:ses:us-east-1:123456789012:identity/yourdomain.com"
      ],
      "Condition": {
        "StringEquals": {
          "ses:FromAddress": "alerts@yourdomain.com"
        }
      }
    }
  ]
}

The ses:FromAddress condition key restricts which from-addresses the IAM principal can use even within the verified identity. Without this condition, any principal with ses:SendEmail on the domain identity could send from arbitrary anything@yourdomain.com addresses. For MCP server tools, lock down to the specific system address the tool uses.

For templates (SendBulkEmailCommand) you additionally need ses:SendBulkTemplatedEmail and arn:aws:ses:REGION:ACCOUNT:template/TEMPLATE_NAME as an additional resource.

Sandbox mode vs production: what changes

All new SES accounts start in sandbox mode. In sandbox mode:

You cannot automate a production SES exit — you must submit a request via the SES console (Service Quotas → SES → "Moving out of the Amazon SES sandbox"). AWS reviews the request and typically approves within 24 hours. After approval, you can send to any address, and the quota defaults to 50,000 emails/day at 14 messages/second.

In both modes, the from address must always be a verified identity. Sandbox additionally restricts the to addresses. Trying to send to an unverified address in sandbox produces:

MessageRejected: Email address is not verified. The following identities failed
the check in region us-east-1: recipient@external.com

During local development and CI, verify a test address in SES console and use that as the recipient. Never hardcode production email addresses in test fixtures.

Rate limiting and retry strategy

SES returns TooManyRequestsException (HTTP 429) when you exceed the per-second sending rate. The SDK's default retry configuration retries 429s with exponential backoff, but it retries 3 times by default and may not be enough for burst scenarios. Implement a token-bucket or leaky-bucket rate limiter in your MCP tool to stay within quota proactively rather than relying on retries.

import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import { SESv2ServiceException } from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({
  region: process.env.AWS_REGION ?? 'us-east-1',
  maxAttempts: 5, // override default retry count
});

async function sendWithBackoff(
  params: Parameters[0],
  attempt = 1,
): Promise {
  try {
    const response = await ses.send(new SendEmailCommand(params));
    return response.MessageId!;
  } catch (err) {
    if (err instanceof SESv2ServiceException) {
      const isThrottle =
        err.name === 'TooManyRequestsException' ||
        err.$retryable?.throttling === true;

      if (isThrottle && attempt <= 5) {
        const delayMs = Math.min(1000 * Math.pow(2, attempt), 30_000);
        await new Promise(resolve => setTimeout(resolve, delayMs + Math.random() * 500));
        return sendWithBackoff(params, attempt + 1);
      }
    }
    throw err;
  }
}

// Key error types to handle differently:
// MessageRejected        — from/to address not verified (sandbox) or blacklisted
// TooManyRequestsException — rate limit exceeded; retry with backoff
// LimitExceededException — daily sending quota exhausted; do not retry until next day
// AccountSuspendedException — account suspended for policy violations; contact AWS support
// MailFromDomainNotVerifiedException — custom MAIL FROM domain DNS not propagated yet

Check your current quota with GetAccountCommand before attempting high-volume sends:

import { GetAccountCommand } from '@aws-sdk/client-sesv2';

const account = await ses.send(new GetAccountCommand({}));
console.log({
  sendingEnabled: account.SendingEnabled,
  dailyQuota: account.SendQuota?.Max24HourSend,
  sentLast24h: account.SendQuota?.SentLast24Hours,
  maxSendRate: account.SendQuota?.MaxSendRate, // messages per second
  productionAccess: account.ProductionAccessEnabled,
});

Failure modes reference

ErrorCauseFix
MessageRejectedFrom-address domain not verified in SES for this regionVerify domain via CreateEmailIdentityCommand or SES console; check region matches client
MessageRejected (recipient)Sandbox mode — recipient not verifiedVerify recipient in SES console or request production access
NotFoundExceptionIdentity ARN in IAM policy references wrong regionRegion in ARN must match SES client region and where identity was verified
TooManyRequestsExceptionExceeded per-second send rateAdd token-bucket rate limiting in caller; increase maxAttempts with jittered backoff
LimitExceededExceptionDaily sending quota exhaustedDo not retry; request quota increase via Service Quotas console
AccountSuspendedExceptionHigh bounce/complaint rate triggered suspensionFix suppression list (see ses-suppression-bounce guide); contact AWS to reinstate
InvalidParameterValueSubject or body contains characters unsupported by the encodingAlways specify Charset: 'UTF-8'; sanitize inputs; avoid NULL bytes