Guide · Amazon Pinpoint · Transactional Email · Templates · Bounce Handling

Pinpoint Transactional Email for MCP Servers — SendMessages, Templates, and Bounce Handling

An MCP server tool that sends a downtime alert or a password-reset link needs a reliable email path that handles bounce suppression, rate limits, and per-recipient personalization. Amazon Pinpoint's email channel provides all three through a single SendMessagesCommand API, with optional pre-created templates for consistent formatting. Unlike raw SES calls, Pinpoint also tracks per-endpoint engagement (opens, clicks, unsubscribes) and automatically suppresses hard-bounced addresses. This guide covers email channel setup, inline vs template-based message construction, delivery status parsing, and bounce/complaint handling.

TL;DR

Send transactional email via Pinpoint with SendMessagesCommand using either inline SimpleEmail (subject + HTML + text inline in the request) or a named template (TemplateConfiguration.EmailTemplate.Name + per-endpoint variable substitutions via endpoint Attributes). Always read MessageResponse.Result[endpointId].DeliveryStatus — HTTP 200 does not mean the email was delivered. Hard bounces set the endpoint's OptOut to ALL automatically; your endpoint registration tool should check this before sending. Email throughput is capped by your SES sending quota, not Pinpoint's own limits — increase via SES production-access request.

Email channel prerequisites

Pinpoint email is a thin orchestration layer on top of SES. Before SendMessagesCommand can deliver email, three things must be in place:

  1. Verified SES identity: The FromAddress domain or email address must be verified in SES in the same region as the Pinpoint application. Domain verification (via DKIM DNS records) is strongly preferred over individual email verification — it allows sending from any address at that domain and improves deliverability.
  2. Email channel enabled on the Pinpoint application: Call UpdateEmailChannelCommand with Enabled: true and the SES identity ARN. Pinpoint will use this channel configuration as the default sending identity for all email sends through that application.
  3. SES out of sandbox (for production): New SES accounts are in sandbox mode and can only send to verified email addresses. Submit a production-access request before going live.
import {
  PinpointClient,
  UpdateEmailChannelCommand,
  GetEmailChannelCommand,
} from '@aws-sdk/client-pinpoint';

const client = new PinpointClient({ region: 'us-east-1' });
const APP_ID = process.env.PINPOINT_APP_ID!;

// Check current email channel status before attempting to send
async function checkEmailChannel(): Promise {
  try {
    const { EmailChannelResponse } = await client.send(
      new GetEmailChannelCommand({ ApplicationId: APP_ID })
    );
    return EmailChannelResponse?.Enabled === true;
  } catch (err: any) {
    if (err.name === 'NotFoundException') return false;
    throw err;
  }
}

// Enable the email channel (run once during app setup, not on every tool call)
async function enableEmailChannel(
  fromAddress: string,
  sesIdentityArn: string
): Promise {
  await client.send(new UpdateEmailChannelCommand({
    ApplicationId: APP_ID,
    EmailChannelRequest: {
      Enabled: true,
      FromAddress: fromAddress,
      Identity: sesIdentityArn,
      // Optional: role ARN for Pinpoint to assume when calling SES
      // If omitted, Pinpoint uses an auto-created service-linked role
      // RoleArn: 'arn:aws:iam::ACCOUNT:role/PinpointEmailRole',
    },
  }));
}

Sending inline transactional email with SimpleEmail

SimpleEmail is the fastest path for one-off tool calls that generate email content dynamically. The subject line, HTML body, and plain-text body are all included inline in the SendMessagesCommand request. No template needs to be pre-created.

import { SendMessagesCommand, MessageRequest } from '@aws-sdk/client-pinpoint';

interface AlertEmailOptions {
  endpointId: string;
  toAddress: string;
  subject: string;
  htmlBody: string;
  textBody: string;
  fromAddress?: string;  // override the channel-level default if needed
}

async function sendAlertEmail(opts: AlertEmailOptions): Promise {
  const { MessageResponse } = await client.send(new SendMessagesCommand({
    ApplicationId: APP_ID,
    MessageRequest: {
      Endpoints: {
        // Map of endpointId → per-endpoint overrides
        // Empty object means: use the channel-level defaults
        [opts.endpointId]: {},
      },
      MessageConfiguration: {
        EmailMessage: {
          // FromAddress overrides the channel default for this send only
          ...(opts.fromAddress ? { FromAddress: opts.fromAddress } : {}),
          SimpleEmail: {
            Subject: { Charset: 'UTF-8', Data: opts.subject },
            HtmlPart: { Charset: 'UTF-8', Data: opts.htmlBody },
            TextPart: { Charset: 'UTF-8', Data: opts.textBody },
          },
        },
      },
    },
  }));

  // Parse per-endpoint delivery result
  const result = MessageResponse?.Result?.[opts.endpointId];
  if (!result) throw new Error(`No delivery result returned for endpoint ${opts.endpointId}`);

  switch (result.DeliveryStatus) {
    case 'SUCCESSFUL':
      return;  // Message accepted by SES for delivery
    case 'OPT_OUT':
      // User unsubscribed — do not retry, do not re-enable without explicit opt-in
      throw new Error(`Endpoint ${opts.endpointId} has opted out of email`);
    case 'QUIET_TIME':
      // Application has quiet-time hours configured and current time is in that window
      throw new Error(`Email blocked by quiet-time window for endpoint ${opts.endpointId}`);
    case 'PERMANENT_FAILURE':
      // Hard bounce or invalid address — Pinpoint has suppressed this endpoint
      throw new Error(`Permanent delivery failure for ${opts.endpointId}: ${result.StatusMessage}`);
    case 'TEMPORARY_FAILURE':
      // Soft bounce — SES will retry for up to 72 hours
      // Log and monitor but do not re-send immediately
      console.warn(`Temporary failure for ${opts.endpointId}: ${result.StatusMessage}`);
      return;
    default:
      throw new Error(`Unexpected delivery status: ${result.DeliveryStatus} — ${result.StatusMessage}`);
  }
}

The DeliveryStatus: 'SUCCESSFUL' status means SES has accepted the message for delivery, not that the recipient's mail server has accepted it. Actual inbox delivery confirmation requires enabling the Pinpoint event stream and watching for _email.delivered events (see the event tracking guide).

Sending to multiple endpoints: Pass up to 100 endpoint IDs in the Endpoints map. Each entry can include per-endpoint overrides such as custom substitution values. The same email body is sent to all endpoints unless you use template variable substitution (covered in the next section).

Email templates with variable substitution

Pre-created templates keep email HTML consistent across tool calls and allow per-recipient variable substitution without passing full HTML in each API call. Pinpoint uses Handlebars-style {{variable_name}} syntax in template bodies; variables are resolved from the endpoint's Attributes map at send time.

import {
  CreateEmailTemplateCommand,
  SendMessagesCommand,
  UpdateEndpointCommand,
} from '@aws-sdk/client-pinpoint';

// Step 1: create the template once (run from setup script, not on every tool call)
async function createAlertTemplate(): Promise {
  await client.send(new CreateEmailTemplateCommand({
    TemplateName: 'mcp-downtime-alert',
    EmailTemplateRequest: {
      Subject: 'Your MCP server {{ServerName}} is down',
      HtmlPart: `
        

Downtime Alert

Your MCP server {{ServerName}} at {{ServerUrl}} has been unreachable since {{DownSince}}.

Last error: {{LastError}}

View on AliveMCP dashboard

`, TextPart: `Downtime Alert Your MCP server {{ServerName}} at {{ServerUrl}} has been unreachable since {{DownSince}}. Last error: {{LastError}} Dashboard: {{DashboardUrl}}`, // Template tags for organization; not sent to recipients tags: { environment: 'production', category: 'alerts' }, }, })); } // Step 2: set template variables on the endpoint before sending // Variables must be string arrays — Pinpoint takes the first element async function setEndpointAlertContext( endpointId: string, serverName: string, serverUrl: string ): Promise { await client.send(new UpdateEndpointCommand({ ApplicationId: APP_ID, EndpointId: endpointId, EndpointRequest: { ChannelType: 'EMAIL', Address: 'user@example.com', Attributes: { // All Pinpoint endpoint attributes are string arrays ServerName: [serverName], ServerUrl: [serverUrl], DashboardUrl: [`https://alivemcp.com/status/${serverName}`], }, }, })); } // Step 3: send using the template async function sendTemplateAlert( endpointId: string, downSince: string, lastError: string ): Promise { const { MessageResponse } = await client.send(new SendMessagesCommand({ ApplicationId: APP_ID, MessageRequest: { Endpoints: { [endpointId]: { // Per-endpoint overrides for variables that change per send Attributes: { DownSince: [downSince], LastError: [lastError], }, }, }, TemplateConfiguration: { EmailTemplate: { Name: 'mcp-downtime-alert', // Version: '1', // omit to use the latest active version }, }, // MessageConfiguration is omitted when using TemplateConfiguration }, })); const status = MessageResponse?.Result?.[endpointId]?.DeliveryStatus; if (status !== 'SUCCESSFUL') { throw new Error(`Template send failed: ${status}`); } }

Variable resolution order: At send time, Pinpoint resolves template variables in this order: (1) per-endpoint overrides in the Endpoints map Attributes, (2) the endpoint's stored Attributes from UpdateEndpointCommand, (3) the endpoint's User.UserAttributes. If a variable referenced in the template has no matching value in any of these sources, Pinpoint leaves it as the literal {{VariableName}} string rather than raising an error — always verify your template output with test sends before production deployment.

Template versioning: CreateEmailTemplateCommand creates version 1. Subsequent calls to UpdateEmailTemplateCommand create new versions. The active version (used when no version is specified in TemplateConfiguration) must be explicitly set with UpdateTemplateActiveVersionCommand — Pinpoint does not automatically activate new versions.

Bounce and complaint handling

SES reports bounces and complaints back to Pinpoint via an internal feedback loop. Pinpoint takes automatic action based on bounce type:

Event type Pinpoint action Endpoint OptOut after event What your code should do
Hard bounce (5xx permanent failure — invalid address, domain not found) Sets endpoint OptOut = ALL; adds address to SES account-level suppression list ALL (auto-set) Mark user's email as invalid in your database; do not send until address is corrected
Soft bounce (4xx transient failure — mailbox full, server temporarily unavailable) Retries delivery for up to 72 hours; no OptOut change Unchanged Log the TEMPORARY_FAILURE status; do not manually retry until 72h window expires
Complaint (recipient marks as spam) Sets endpoint OptOut = ALL if complaint type is abuse or fraud ALL (auto-set) Treat same as hard bounce; do not send until user explicitly re-subscribes
Unsubscribe (click on Pinpoint-managed unsubscribe link) Sets endpoint OptOut = ALL ALL (auto-set) Respect the opt-out; remove from future notification lists

To proactively check whether an endpoint is still opted in before sending, call GetEndpointCommand and read the OptOut field. This avoids wasting a SendMessagesCommand call on a suppressed address:

import { GetEndpointCommand } from '@aws-sdk/client-pinpoint';

async function isEndpointOptedIn(endpointId: string): Promise {
  try {
    const { EndpointResponse } = await client.send(new GetEndpointCommand({
      ApplicationId: APP_ID,
      EndpointId: endpointId,
    }));
    return EndpointResponse?.OptOut === 'NONE';
  } catch (err: any) {
    if (err.name === 'NotFoundException') return false;  // endpoint not registered
    throw err;
  }
}

// In your MCP tool handler
async function sendAlertIfOptedIn(endpointId: string, subject: string, body: string) {
  const optedIn = await isEndpointOptedIn(endpointId);
  if (!optedIn) {
    return { status: 'skipped', reason: 'endpoint_opted_out' };
  }
  await sendAlertEmail({ endpointId, subject, htmlBody: body, textBody: body, toAddress: '' });
  return { status: 'sent' };
}

SES account-level suppression list: When Pinpoint processes a hard bounce, it also adds the email address to SES's account-level suppression list. Even if you create a new Pinpoint endpoint with the same address, SES will reject the send with MessageRejected: Email address is on suppression list. To remove an address from the SES suppression list (after confirming the address is now valid), call ses.deleteSuppressionDestination({ EmailAddress: '...' }) using the SES v2 SDK.

Rate limits and throughput

Pinpoint email throughput is bounded by the underlying SES sending quotas, not Pinpoint-specific limits. The defaults for a new SES account after production access is granted:

Limit Default How to increase
Max sends per 24-hour period 50,000 Service quota increase via AWS console or CLI
Max send rate (messages per second) 14 msg/sec Service quota increase; note Pinpoint SendMessagesCommand itself has a default 25 TPS limit on the API call, separate from SES throughput
SendMessagesCommand API TPS (Pinpoint) 25 requests/sec Contact AWS Support; not a standard service quota adjustable via console
Max endpoints per SendMessagesCommand 100 Hard limit — cannot be increased; use Campaigns for bulk sends

For MCP tools that fan out alerts to hundreds of users simultaneously (e.g., a team alert when a monitored MCP server goes down), use a Pinpoint Campaign targeting a saved segment rather than looping SendMessagesCommand. Alternatively, write endpoint IDs to SQS and process them with a Lambda function that calls SendMessagesCommand in batches of 100, respecting the 25 TPS limit with appropriate delays.

Common errors

Error / symptom Cause Fix
BadRequestException: Email channel is not enabled for application Email channel was not configured via UpdateEmailChannelCommand, or was configured with Enabled: false Call GetEmailChannelCommand to inspect status; run channel setup with a verified SES identity ARN
MessageRejected in SES (surfaces as PERMANENT_FAILURE in Pinpoint result) Destination address is on the SES account-level suppression list due to a prior bounce Call SES GetSuppressedDestinationCommand to confirm; remove with DeleteSuppressedDestinationCommand after verifying the address is valid
Template variables appear as literal {{VariableName}} in sent emails The endpoint's Attributes map does not contain a key matching the template variable name (case-sensitive) Inspect endpoint attributes with GetEndpointCommand; ensure attribute key names exactly match template variable names including capitalization; remember all Pinpoint attribute values are string arrays — use the first element
NotFoundException: Template not found Template name or version in TemplateConfiguration does not exist in the region Templates are regional; create the template in the same region as the Pinpoint application; list templates with ListTemplatesCommand to verify names
Email delivered but opens/clicks not tracked Open and click tracking requires Pinpoint to inject a tracking pixel and rewrite links; this is off by default for SimpleEmail sends Enable tracking by setting Substitutions in the endpoint entry and ensuring the application's email channel has tracking enabled; or switch to template-based sends which have tracking enabled by default
TooManyRequestsException on rapid sequential SendMessagesCommand calls Exceeding the 25 TPS limit on the Pinpoint API (separate from the SES throughput limit) Implement exponential backoff with jitter; batch endpoints into groups of 100 per call rather than sending one endpoint per call