Guide · AWS SES v2
MCP Server AWS SES Email Templates — bulk sending, Handlebars substitution
SES v2 email templates let you define an HTML+text email once and send it to many recipients with per-recipient variable substitution using Handlebars syntax — the right pattern for any MCP tool that sends personalized notifications, weekly digests, or alert batches. Templates separate content from sending logic: the template lives in SES as a named resource, your MCP tool supplies only the recipient list and a JSON substitution data object per recipient. The two sending modes are SendEmailCommand with Content.Template (single recipient, synchronous) and SendBulkEmailCommand (up to 50 recipients per call, each with independent substitution data). The critical thing to understand is that UpdateEmailTemplateCommand completely replaces the template — there is no patch operation — so safe template deployment requires a name-versioning strategy in production.
TL;DR
Create templates with CreateEmailTemplateCommand; use {{variableName}} Handlebars syntax in HTML/text/subject. For single sends: SendEmailCommand with Content.Template.TemplateName + Content.Template.TemplateData (JSON string). For bulk sends: SendBulkEmailCommand with BulkEmailEntries[] each containing Destination and ReplacementEmailContent.ReplacementTemplate.ReplacementTemplateData. Update templates with UpdateEmailTemplateCommand (full replace — save old version before updating). IAM: add ses:SendBulkEmail and arn:aws:ses:REGION:ACCOUNT:template/TEMPLATE_NAME as an additional resource.
Creating an email template
Templates are stored in SES under a unique TemplateName. The template body uses Handlebars-compatible {{variableName}} syntax in the subject, HTML body, and text body. SES does not support advanced Handlebars helpers like {{#if}}, {{#each}}, or partials — only simple variable substitution with {{variableName}} and {{variableName.nestedKey}} for objects.
import {
SESv2Client,
CreateEmailTemplateCommand,
type EmailTemplateContent,
} from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
const templateContent: EmailTemplateContent = {
Subject: 'MCP server {{serverName}} is {{status}} — AliveMCP alert',
Html: `
MCP Server Alert
Server Status Alert
Your MCP server {{serverName}} is currently {{status}}.
Last checked: {{lastCheckedAt}}
Downtime duration: {{downtimeDuration}}
You are receiving this because you subscribed to alerts for {{serverName}} on AliveMCP.
Unsubscribe
`,
Text: `MCP server {{serverName}} is {{status}}.
Last checked: {{lastCheckedAt}}
Downtime duration: {{downtimeDuration}}
Dashboard: {{dashboardUrl}}
Unsubscribe: {{unsubscribeUrl}}`,
};
await ses.send(new CreateEmailTemplateCommand({
TemplateName: 'mcp-server-alert-v1',
TemplateContent: templateContent,
}));
console.log('Template created: mcp-server-alert-v1');
Template names are case-sensitive and must be unique per region per account. Use a versioning suffix (-v1, -v2) rather than updating in-place — that lets you roll back by switching TemplateName in your code without touching the SES resource.
Sending a templated email to a single recipient
Use SendEmailCommand with the Template content variant when sending to one recipient. TemplateData must be a valid JSON string — not a JavaScript object. SES deserializes it server-side and substitutes variables.
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
interface ServerAlertTemplateData {
serverName: string;
status: string;
lastCheckedAt: string;
downtimeDuration: string;
dashboardUrl: string;
unsubscribeUrl: string;
}
async function sendServerAlert(
recipientEmail: string,
data: ServerAlertTemplateData,
): Promise {
const response = await ses.send(new SendEmailCommand({
FromEmailAddress: 'alerts@alivemcp.com',
Destination: {
ToAddresses: [recipientEmail],
},
Content: {
Template: {
TemplateName: 'mcp-server-alert-v1',
// TemplateData MUST be a JSON string, not an object
TemplateData: JSON.stringify(data),
},
},
// Attach a configuration set to track delivery events
ConfigurationSetName: 'transactional-alerts',
}));
return response.MessageId!;
}
// Usage from MCP tool handler:
const messageId = await sendServerAlert('user@example.com', {
serverName: 'my-weather-mcp',
status: 'DOWN',
lastCheckedAt: new Date().toISOString(),
downtimeDuration: '14 minutes',
dashboardUrl: 'https://alivemcp.com/status/my-weather-mcp',
unsubscribeUrl: 'https://alivemcp.com/unsubscribe?token=abc123',
});
If a variable referenced in the template is missing from TemplateData, SES substitutes an empty string — it does not throw an error. Use this behavior intentionally for optional fields but be careful not to silently omit required data.
Bulk sending with SendBulkEmailCommand
SendBulkEmailCommand sends a template to up to 50 recipients in a single API call, with independent variable substitution per recipient. Use this for notification batches, weekly digest sends, and alert fan-outs. Each entry in BulkEmailEntries can override the from-address, reply-to, and template data.
import {
SESv2Client,
SendBulkEmailCommand,
type BulkEmailEntry,
type BulkEmailEntryResult,
} from '@aws-sdk/client-sesv2';
interface AlertRecipient {
email: string;
data: ServerAlertTemplateData;
}
async function sendBulkServerAlerts(
recipients: AlertRecipient[],
): Promise {
if (recipients.length === 0) return [];
// SendBulkEmailCommand limit: 50 BulkEmailEntries per call
const BATCH_SIZE = 50;
const allResults: BulkEmailEntryResult[] = [];
for (let i = 0; i < recipients.length; i += BATCH_SIZE) {
const batch = recipients.slice(i, i + BATCH_SIZE);
const bulkEntries: BulkEmailEntry[] = batch.map(recipient => ({
Destination: {
ToAddresses: [recipient.email],
},
ReplacementEmailContent: {
ReplacementTemplate: {
ReplacementTemplateName: 'mcp-server-alert-v1', // can override per-recipient
ReplacementTemplateData: JSON.stringify(recipient.data),
},
},
// Optional: override from-address per recipient (must still be verified identity)
// ReplacementTags: [{ Name: 'recipient_id', Value: '...' }],
}));
const response = await ses.send(new SendBulkEmailCommand({
FromEmailAddress: 'alerts@alivemcp.com',
DefaultContent: {
// DefaultContent is used if a BulkEmailEntry doesn't specify ReplacementEmailContent
Template: {
TemplateName: 'mcp-server-alert-v1',
TemplateData: JSON.stringify({ serverName: 'unknown', status: 'unknown' }),
},
},
BulkEmailEntries: bulkEntries,
ConfigurationSetName: 'transactional-alerts',
}));
allResults.push(...(response.BulkEmailEntryResults ?? []));
}
return allResults;
}
// Check results — bulk send is best-effort per entry; some may fail
const results = await sendBulkServerAlerts(recipients);
for (const result of results) {
if (result.Status !== 'SUCCESS') {
console.error(`Failed entry: status=${result.Status} error=${result.Error}`);
}
// result.MessageId is set on success
}
Unlike SendEmailCommand, SendBulkEmailCommand does not throw on per-entry failures. It returns BulkEmailEntryResults[] where each entry has a Status field (SUCCESS or the error code) and a MessageId on success. Always check per-entry results.
Listing, reading, and updating templates
SES v2 supports up to 20,000 email templates per region per account. Use ListEmailTemplatesCommand to paginate through templates and GetEmailTemplateCommand to read a template's current content before updating it.
import {
ListEmailTemplatesCommand,
GetEmailTemplateCommand,
UpdateEmailTemplateCommand,
DeleteEmailTemplateCommand,
} from '@aws-sdk/client-sesv2';
// List all templates (paginated)
async function listAllTemplates(): Promise {
const names: string[] = [];
let nextToken: string | undefined;
do {
const response = await ses.send(new ListEmailTemplatesCommand({
PageSize: 100,
NextToken: nextToken,
}));
for (const meta of response.TemplatesMetadata ?? []) {
if (meta.TemplateName) names.push(meta.TemplateName);
}
nextToken = response.NextToken;
} while (nextToken);
return names;
}
// Read template content before updating (save as backup)
const existing = await ses.send(new GetEmailTemplateCommand({
TemplateName: 'mcp-server-alert-v1',
}));
console.log(existing.TemplateContent?.Subject);
// existing.TemplateContent.Html / .Text / .Subject
// UpdateEmailTemplateCommand FULLY REPLACES the template — it is not a patch
// Always read + backup before writing
await ses.send(new UpdateEmailTemplateCommand({
TemplateName: 'mcp-server-alert-v1',
TemplateContent: {
Subject: 'Updated subject: {{serverName}} is {{status}}',
Html: '...new HTML...',
Text: '...new text...',
},
}));
// Delete a template (irreversible without backup)
await ses.send(new DeleteEmailTemplateCommand({
TemplateName: 'mcp-server-alert-v2-deprecated',
}));
Template limits and Handlebars gotchas
| Limit / Behavior | Detail |
|---|---|
| Max templates per region | 20,000 (can request increase via Service Quotas) |
| Max template size | 500 KB for HTML+text combined; subject max 998 characters |
| Handlebars helpers supported | Only simple {{variable}} and {{object.key}} — no {{#if}}, {{#each}}, or custom helpers |
| Missing variable behavior | Silently substitutes empty string — not an error; validate required fields before sending |
| HTML escaping | SES HTML-escapes string values by default; use triple braces {{{rawHtml}}} to inject unescaped HTML (security risk — only for trusted content) |
| BulkEmailEntries per call | 50 maximum; chunk larger batches in your calling code |
| TemplateData format | Must be a valid JSON string; nested objects supported; arrays are not iterable (no {{#each}}) |
| Template region scope | Templates exist per-region; you must create/replicate templates in each region you send from |
The lack of {{#if}} and {{#each}} is the most common surprise. Workarounds: pre-render conditional sections in your application code and pass the final HTML string as a single {{{bodySection}}} variable (triple braces, trusted content only), or maintain separate templates for the conditional variants.
IAM policy for template operations
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SendFromVerifiedIdentity",
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendBulkEmail"
],
"Resource": [
"arn:aws:ses:us-east-1:123456789012:identity/alivemcp.com",
"arn:aws:ses:us-east-1:123456789012:template/mcp-server-alert-v1",
"arn:aws:ses:us-east-1:123456789012:template/mcp-server-alert-v2"
]
},
{
"Sid": "TemplateManagement",
"Effect": "Allow",
"Action": [
"ses:CreateEmailTemplate",
"ses:GetEmailTemplate",
"ses:ListEmailTemplates",
"ses:UpdateEmailTemplate",
"ses:DeleteEmailTemplate"
],
"Resource": "*"
}
]
}
Both the identity ARN and the template ARN must be listed in the Resource array for ses:SendEmail and ses:SendBulkEmail. The IAM check requires permission on both — missing either one returns AccessDenied.