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}}

View dashboard →


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 / BehaviorDetail
Max templates per region20,000 (can request increase via Service Quotas)
Max template size500 KB for HTML+text combined; subject max 998 characters
Handlebars helpers supportedOnly simple {{variable}} and {{object.key}} — no {{#if}}, {{#each}}, or custom helpers
Missing variable behaviorSilently substitutes empty string — not an error; validate required fields before sending
HTML escapingSES HTML-escapes string values by default; use triple braces {{{rawHtml}}} to inject unescaped HTML (security risk — only for trusted content)
BulkEmailEntries per call50 maximum; chunk larger batches in your calling code
TemplateData formatMust be a valid JSON string; nested objects supported; arrays are not iterable (no {{#each}})
Template region scopeTemplates 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.