Guide · Transactional Email
MCP Tools for AWS SES — IAM credentials, SESv2 SDK, sending quotas, sandbox mode, SNS bounce notifications
AWS SES (Simple Email Service) differs from other transactional email APIs in three fundamental ways that MCP tool authors must understand upfront: authentication is IAM-based, not a simple API key — the AWS SDK handles SigV4 request signing using access keys or instance roles; all new accounts start in sandbox mode, where sends are restricted to verified email addresses only until production access is manually requested via AWS Support; and bounce and complaint notifications are delivered asynchronously via SNS, not via synchronous API responses or webhooks — there is no Postmark-style bounce list API in SESv2. The SESv1 and SESv2 APIs coexist, have different SDK clients, and expose different capabilities; SESv2 is the current recommended API.
TL;DR
Install: npm install @aws-sdk/client-sesv2. Create client: new SESv2Client({ region: 'us-east-1', credentials: { accessKeyId, secretAccessKey } }). Send: SendEmailCommand with FromEmailAddress, Destination.ToAddresses[], Content.Simple.Subject.Data, Content.Simple.Body.Text.Data. Quota: GetAccountCommand → SendQuota.Max24HourSend, SendQuota.SentLast24Hours. Sandbox: GetAccountCommand → ProductionAccessEnabled: false means sandbox. Bounce/complaint: configure SNS topics on the sending identity and subscribe your HTTP endpoint. Health probe: GetAccountCommand + verify ProductionAccessEnabled + check send rate headroom.
Authentication — IAM credentials, not an API key
AWS SES does not have a dedicated API key. Authentication uses AWS IAM credentials: an accessKeyId + secretAccessKey pair, or (preferred in production) an IAM role assigned to the compute resource (EC2 instance role, ECS task role, Lambda execution role). The SDK automatically handles SigV4 request signing — you never manually construct an Authorization header.
The minimum IAM permissions for an MCP tool that only sends email: ses:SendEmail and ses:SendRawEmail (or sesv2:SendEmail for the v2 API). Add ses:GetAccount for quota checks. Add ses:GetEmailIdentity for domain/address verification checks. Never grant ses:* — it includes delete-identity and send-quota-modification actions.
import { SESv2Client, SendEmailCommand, GetAccountCommand, GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
// Option 1: explicit credentials (use for local development or cross-account)
const sesClient = new SESv2Client({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
// If using temporary credentials (STS/assumed role):
// sessionToken: process.env.AWS_SESSION_TOKEN,
},
});
// Option 2: implicit credentials (preferred in production on AWS compute)
// The SDK resolves credentials from the environment in priority order:
// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
// 2. ~/.aws/credentials file
// 3. EC2/ECS/Lambda instance metadata (IAM role)
// No credentials config needed — the SDK handles it:
const sesClientImplicit = new SESv2Client({ region: 'us-east-1' });
Sending email with SESv2
SESv2's SendEmailCommand uses a deeply nested structure compared to other email APIs. The body supports Simple (subject + text/html body), Raw (full MIME message), or Template (SES-stored template). Always specify ConfigurationSetName if you have a configuration set — this activates engagement tracking, event logging to CloudWatch, and SNS bounce/complaint notifications.
async function sendEmail(sesClient, {
from,
to,
subject,
textBody,
htmlBody,
configurationSet,
tags = [],
}) {
const command = new SendEmailCommand({
FromEmailAddress: from,
Destination: {
ToAddresses: Array.isArray(to) ? to : [to],
// CcAddresses: [], BccAddresses: [] also available
},
Content: {
Simple: {
Subject: {
Data: subject,
Charset: 'UTF-8',
},
Body: {
...(textBody && {
Text: { Data: textBody, Charset: 'UTF-8' },
}),
...(htmlBody && {
Html: { Data: htmlBody, Charset: 'UTF-8' },
}),
},
},
},
// Configuration set activates SNS notifications for bounces/complaints
ConfigurationSetName: configurationSet,
// Tags appear in CloudWatch metrics for per-tag delivery tracking
EmailTags: tags.map(({ name, value }) => ({ Name: name, Value: value })),
});
const result = await sesClient.send(command);
// result.MessageId is the SES message ID — use for CloudWatch event correlation
return { messageId: result.MessageId };
}
// Bulk send via template (SESv2 bulk operation)
async function sendBulkTemplate(sesClient, { fromEmailAddress, templateName, bulkEmailEntries }) {
const { SendBulkEmailCommand } = await import('@aws-sdk/client-sesv2');
const command = new SendBulkEmailCommand({
FromEmailAddress: fromEmailAddress,
DefaultContent: {
Template: { TemplateName: templateName, TemplateData: '{}' },
},
BulkEmailEntries: bulkEmailEntries.map(entry => ({
Destination: { ToAddresses: [entry.email] },
ReplacementEmailContent: {
ReplacementTemplate: {
ReplacementTemplateData: JSON.stringify(entry.templateData),
},
},
})),
});
const result = await sesClient.send(command);
// result.BulkEmailEntryResults — one status per entry, check MessageId vs Status
return result.BulkEmailEntryResults.map((r, i) => ({
email: bulkEmailEntries[i].email,
messageId: r.MessageId,
status: r.Status, // 'SUCCESS' | 'MESSAGE_REJECTED' | 'MAIL_FROM_DOMAIN_NOT_VERIFIED' | etc.
error: r.Error,
}));
}
Sandbox mode — the most common new-account blocker
All new AWS accounts start SES in sandbox mode. In sandbox mode, you can only send email to verified email addresses (addresses you have explicitly verified in SES) and only from verified identities. You cannot send to arbitrary external addresses. Sandbox mode also imposes a very low default sending rate (1 message/second) and a daily quota (200 emails/24 hours).
To exit sandbox mode, submit a production access request via AWS Support Center. Approval typically takes 24-72 hours and requires describing your use case and expected sending volumes. Until approved, any send to an unverified address fails with MessageRejected: Email address is not verified.
// Check account status — critical for health probes
async function getAccountStatus(sesClient) {
const command = new GetAccountCommand({});
const result = await sesClient.send(command);
const quota = result.SendQuota;
const remainingToday = quota.Max24HourSend - quota.SentLast24Hours;
const remainingRate = quota.MaxSendRate - (quota.SendingRateLastSecond ?? 0);
return {
// false = sandbox mode (sends restricted to verified addresses only)
isProduction: result.ProductionAccessEnabled,
sendingEnabled: result.SendingEnabled,
quota: {
max24h: quota.Max24HourSend,
sent24h: quota.SentLast24Hours,
remaining24h: remainingToday,
maxRate: quota.MaxSendRate, // messages per second
usedPct24h: (quota.SentLast24Hours / quota.Max24HourSend * 100).toFixed(1),
},
// Warn when <20% of daily quota remains
quotaStatus: remainingToday / quota.Max24HourSend < 0.05 ? 'critical'
: remainingToday / quota.Max24HourSend < 0.20 ? 'warning'
: 'ok',
};
}
// Check if a sending identity (domain or address) is verified
async function checkIdentity(sesClient, identity) {
const { GetEmailIdentityCommand } = await import('@aws-sdk/client-sesv2');
const command = new GetEmailIdentityCommand({ EmailIdentity: identity });
const result = await sesClient.send(command);
return {
identity,
// true if SES can send from this domain/address
verified: result.VerifiedForSendingStatus,
identityType: result.IdentityType, // 'EMAIL_ADDRESS' | 'DOMAIN'
dkim: {
signingEnabled: result.DkimAttributes?.SigningEnabled,
status: result.DkimAttributes?.Status, // 'PENDING' | 'SUCCESS' | 'FAILED' | 'TEMPORARY_FAILURE' | 'NOT_STARTED'
},
mailFromDomain: result.MailFromAttributes?.MailFromDomain,
};
}
Bounce and complaint notifications via SNS
SES does not provide a REST endpoint to list bounced addresses (unlike Mailgun or Postmark). Instead, bounce and complaint notifications are pushed asynchronously to an SNS topic that you configure on each sending identity. Your MCP tool or a separate handler must subscribe to the SNS topic (via HTTP/HTTPS endpoint or SQS queue) to receive these events and maintain its own suppression list.
SES also maintains an account-level suppression list (enabled by default in SESv2) — addresses that have hard-bounced or generated spam complaints across all your SES sends are automatically added and blocked from future sends. You can query this list via the API.
// Parse an SNS bounce/complaint notification (delivered as HTTP POST from SNS)
function parseSnsEmailNotification(body) {
// SNS wraps the SES notification in a Message field (JSON-stringified)
const snsMessage = JSON.parse(body);
if (snsMessage.Type === 'SubscriptionConfirmation') {
// Confirm subscription by fetching snsMessage.SubscribeURL
return { type: 'subscription_confirmation', url: snsMessage.SubscribeURL };
}
const sesEvent = JSON.parse(snsMessage.Message);
const notificationType = sesEvent.notificationType; // 'Bounce', 'Complaint', 'Delivery'
if (notificationType === 'Bounce') {
const bounce = sesEvent.bounce;
return {
type: 'bounce',
bounceType: bounce.bounceType, // 'Permanent' | 'Transient' | 'Undetermined'
bounceSubType: bounce.bounceSubType, // 'General', 'NoEmail', 'Suppressed', 'OnAccountSuppressionList'
recipients: bounce.bouncedRecipients.map(r => ({
email: r.emailAddress,
action: r.action, // 'failed', 'delayed'
diagnosticCode: r.diagnosticCode, // SMTP diagnostic, e.g. "smtp; 550 5.1.1 User unknown"
shouldSuppress: bounce.bounceType === 'Permanent',
})),
timestamp: sesEvent.mail.timestamp,
};
}
if (notificationType === 'Complaint') {
const complaint = sesEvent.complaint;
return {
type: 'complaint',
complaintFeedbackType: complaint.complaintFeedbackType, // 'abuse', 'fraud', 'virus', 'other'
recipients: complaint.complainedRecipients.map(r => ({ email: r.emailAddress })),
timestamp: sesEvent.mail.timestamp,
// ALL spam complaints must trigger immediate permanent suppression
shouldSuppressAll: true,
};
}
return { type: notificationType.toLowerCase(), raw: sesEvent };
}
// Query SES account-level suppression list
async function getAccountSuppressions(sesClient, reasons = ['BOUNCE', 'COMPLAINT']) {
const { ListSuppressedDestinationsCommand } = await import('@aws-sdk/client-sesv2');
const results = [];
let nextToken;
do {
const command = new ListSuppressedDestinationsCommand({
Reasons: reasons,
NextToken: nextToken,
PageSize: 100,
});
const page = await sesClient.send(command);
results.push(...(page.SuppressedDestinationSummaries || []));
nextToken = page.NextToken;
} while (nextToken);
return results.map(s => ({
email: s.EmailAddress,
reason: s.Reason, // 'BOUNCE' | 'COMPLAINT'
lastUpdateTime: s.LastUpdateTime,
}));
}
Health monitoring for AWS SES MCP tools
The critical failure modes for AWS SES are: sandbox mode blocking sends to external addresses, exhausted 24-hour sending quota, DKIM verification failure, and IAM permission errors. A production health probe should check all four.
async function probeSES(sesClient, sendingIdentity) {
const results = await Promise.allSettled([
// 1. Account mode + quota (catches sandbox mode and quota exhaustion)
(async () => {
const status = await getAccountStatus(sesClient);
if (!status.isProduction) {
throw new Error('SES account is in sandbox mode — cannot send to unverified addresses');
}
if (!status.sendingEnabled) {
throw new Error('SES sending is disabled for this account — check AWS console');
}
if (status.quotaStatus === 'critical') {
throw new Error(`Sending quota critically low: ${status.quota.remaining24h} remaining today`);
}
return { kind: 'account', ...status };
})(),
// 2. Sending identity verification
(async () => {
const identity = await checkIdentity(sesClient, sendingIdentity);
if (!identity.verified) {
throw new Error(`Sending identity ${sendingIdentity} is not verified for sending`);
}
if (identity.dkim?.status !== 'SUCCESS') {
throw new Error(`DKIM status for ${sendingIdentity} is ${identity.dkim?.status} — deliverability may be impaired`);
}
return { kind: 'identity', ...identity };
})(),
// 3. Account-level suppression list size (rapid growth = deliverability problem)
(async () => {
const suppressions = await getAccountSuppressions(sesClient);
return {
kind: 'suppressions',
total: suppressions.length,
bounces: suppressions.filter(s => s.reason === 'BOUNCE').length,
complaints: suppressions.filter(s => s.reason === 'COMPLAINT').length,
};
})(),
]);
return {
healthy: results.every(r => r.status === 'fulfilled'),
components: results.map(r =>
r.status === 'fulfilled'
? { ...r.value, ok: true }
: { ok: false, error: r.reason?.message }
),
};
}
Common integration pitfalls
- Sending to unverified addresses in sandbox mode
- In sandbox mode, every send to an unverified external address fails with
MessageRejected. This is not a transient error — retrying will never succeed until production access is enabled. CheckProductionAccessEnabledat startup and fail fast rather than queuing messages that will all be rejected. - Using SESv1 SDK with SESv2 resources
- SESv1 (
@aws-sdk/client-ses) and SESv2 (@aws-sdk/client-sesv2) are separate SDK clients with different command APIs. Configuration sets, suppression list management, and contact list features are only available in SESv2. The SESv1 SDK commandSendEmailCommandhas a different parameter shape — mixing SDK versions causes "missing parameter" errors that are hard to debug. - No bounce handling because SNS seems complex
- SES does not bounce-check at send time — it accepts the message and discovers the bounce after SMTP delivery attempt. Without SNS notifications, your MCP tool has no way to learn about bounced addresses and will keep sending to them. Each bounce incrementally harms your account-level sender reputation. Implement SNS processing on day one, not as a future improvement.
- Hardcoding the region
- SES is a regional service — an identity verified in
us-east-1cannot send from a client configured foreu-west-1. The SDK returnsMessageRejected: Email address is not verifiedwhen the region does not match the identity. Make the region a required configuration parameter, not a default. - Ignoring the account-level suppression list
- SESv2 maintains a shared suppression list across all your sends. If a user hard-bounced in an unrelated workflow (e.g., a test send), their address is suppressed for all subsequent sends — including MCP tool sends. Query
ListSuppressedDestinationsbefore bulk sends to avoid mysteriously low delivery rates.
Related guides
- MCP tools for Mailgun — Basic auth with api: prefix, regional endpoints, webhook HMAC, bounces
- MCP tools for Postmark — X-Postmark-Server-Token auth, message streams, bounce classification
- MCP tools for Resend — Bearer token auth, batch sends, Svix webhook validation
- MCP tools for SparkPost — transmission API, substitution data, engagement tracking
- MCP server health check patterns — composite endpoints and failure classification
- MCP server uptime monitoring — probes, intervals, and alert routing