Email delivery monitoring is crucial for any application that relies on transactional emails, marketing campaigns, or system notifications. When your business depends on reliable email communication, you need visibility into delivery success rates, bounce patterns, and complaint handling. Amazon Simple Email Service (SES) provides excellent delivery capabilities, but its event data needs to be captured and centralized for effective monitoring.
In this guide, I'll show you how to create a robust email event logging system that captures all SES events (deliveries, bounces, complaints, etc.) and stores them in Amazon CloudWatch for analysis and alerting. This serverless solution automatically processes email events in real-time without requiring manual intervention.
📂 Complete Project: You can find the complete, ready-to-deploy code for this implementation on GitHub: https://github.com/levz0r/ses-cloudwatch-logging
Email deliverability is more complex than it appears. Messages can bounce due to invalid addresses, get rejected by spam filters, or trigger complaints from recipients. Without proper monitoring, you're flying blind when email delivery issues occur, potentially missing critical business communications or damaging your sender reputation.
SES provides comprehensive event tracking, but the data is distributed across SNS notifications and configuration sets. This makes it difficult to get a unified view of your email performance across different campaigns, templates, and time periods.
Implementing centralized logging provides immediate operational benefits. You gain visibility into delivery patterns that help identify problematic email addresses before they impact your sender reputation. Bounce tracking allows you to automatically clean your contact lists, while complaint monitoring helps you identify content or frequency issues that might be annoying recipients.
Perhaps most importantly, centralized logs enable proactive alerting. Instead of discovering delivery issues from customer complaints, you can set up CloudWatch alarms that notify you immediately when bounce rates spike or complaints exceed normal thresholds.
First, we need an SNS topic to receive SES events. This topic will fan out events to our Lambda function:
1# Create SNS topic for SES events
2aws sns create-topic \
3 --name ses-events-topic-dev \
4 --region us-east-1
5
6# Get the topic ARN for later use
7aws sns get-topic-attributes \
8 --topic-arn arn:aws:sns:us-east-1:ACCOUNT:ses-events-topic-devSES needs to be configured to publish events to our SNS topic. This can be done through configuration sets:
1# Create SES configuration set
2aws ses create-configuration-set \
3 --configuration-set Name=email-tracking-dev
4
5# Create event destination for bounces and complaints
6aws ses create-configuration-set-event-destination \
7 --configuration-set-name email-tracking-dev \
8 --event-destination Name=cloudwatch-logging \
9 Enabled=true \
10 MatchingEventTypes=send,reject,bounce,complaint,delivery,renderingFailure \
11 SNSDestination={TopicARN=arn:aws:sns:us-east-1:ACCOUNT:ses-events-topic-dev}You can clone the complete project and deploy it immediately:
1git clone https://github.com/levz0r/ses-cloudwatch-logging.git
2cd ses-cloudwatch-logging
3npm install
4npm run deploy:devThe Lambda function processes SNS messages containing SES events and structures them for CloudWatch logging:
1// handler.js
2const Sentry = require("@sentry/serverless");
3const {
4 CloudWatchLogsClient,
5 CreateLogGroupCommand,
6 CreateLogStreamCommand,
7 PutLogEventsCommand,
8 DescribeLogStreamsCommand,
9} = require("@aws-sdk/client-cloudwatch-logs");
10
11// Initialize Sentry for error tracking
12Sentry.AWSLambda.init({
13 dsn: process.env.SENTRY_DSN,
14 environment: process.env.NODE_ENV,
15 tracePropagationTargets: [],
16});
17
18// CloudWatch Logs client
19const cloudwatchLogs = new CloudWatchLogsClient({
20 region: process.env.AWS_REGION,
21});
22
23const LOG_GROUP_NAME = process.env.LOG_GROUP_NAME;
24const LOG_STREAM_NAME = "ses-email-events-stream";The function processes different types of SES events and extracts relevant information:
1function processSESEvent(snsMessage) {
2 const processedEvent = {
3 timestamp: new Date().toISOString(),
4 messageId: snsMessage.mail?.messageId || "unknown",
5 eventType: snsMessage.eventType || "unknown",
6 recipient: snsMessage.mail?.destination?.[0] || "unknown",
7 source: snsMessage.mail?.source || "unknown",
8 subject: snsMessage.mail?.commonHeaders?.subject || "unknown",
9 rawEvent: snsMessage,
10 };
11
12 // Add event-specific details
13 switch (snsMessage.eventType.toLowerCase()) {
14 case "bounce":
15 const bounce = snsMessage.bounce || {};
16 processedEvent.bounceType = bounce.bounceType || "unknown";
17 processedEvent.bounceSubType = bounce.bounceSubType || "unknown";
18 processedEvent.bouncedRecipients = bounce.bouncedRecipients || [];
19 break;
20
21 case "complaint":
22 const complaint = snsMessage.complaint || {};
23 processedEvent.complaintFeedbackType =
24 complaint.complaintFeedbackType || "unknown";
25 processedEvent.complainedRecipients =
26 complaint.complainedRecipients || [];
27 break;
28
29 case "delivery":
30 const delivery = snsMessage.delivery || {};
31 processedEvent.processingTimeMillis = delivery.processingTimeMillis || 0;
32 processedEvent.smtpResponse = delivery.smtpResponse || "unknown";
33 break;
34 }
35
36 return processedEvent;
37}Writing events to CloudWatch requires handling sequence tokens and potential race conditions:
1/**
2 * Get the sequence token for the log stream
3 */
4async function getSequenceToken() {
5 try {
6 const response = await cloudwatchLogs.send(
7 new DescribeLogStreamsCommand({
8 logGroupName: LOG_GROUP_NAME,
9 logStreamNamePrefix: LOG_STREAM_NAME,
10 })
11 );
12
13 if (response.logStreams && response.logStreams.length > 0) {
14 return response.logStreams[0].uploadSequenceToken;
15 }
16 return null;
17 } catch (error) {
18 console.error(`Error getting sequence token: ${error.message}`);
19 return null;
20 }
21}
22
23async function writeToCloudWatch(message) {
24 try {
25 const sequenceToken = await getSequenceToken();
26
27 const logEvent = {
28 timestamp: Date.now(),
29 message: JSON.stringify(message),
30 };
31
32 const putLogEventsParams = {
33 logGroupName: LOG_GROUP_NAME,
34 logStreamName: LOG_STREAM_NAME,
35 logEvents: [logEvent],
36 };
37
38 if (sequenceToken) {
39 putLogEventsParams.sequenceToken = sequenceToken;
40 }
41
42 const response = await cloudwatchLogs.send(
43 new PutLogEventsCommand(putLogEventsParams)
44 );
45 console.log("Successfully wrote event to CloudWatch Logs");
46 return true;
47 } catch (error) {
48 console.error(`Error writing to CloudWatch Logs: ${error.message}`);
49
50 // Handle sequence token conflicts with retry logic
51 if (error.name === "InvalidSequenceTokenException") {
52 try {
53 const match = error.message.match(
54 /The next expected sequenceToken is: (\S+)/
55 );
56 const retryParams = { ...putLogEventsParams };
57 if (match) {
58 retryParams.sequenceToken = match[1];
59 }
60
61 await cloudwatchLogs.send(new PutLogEventsCommand(retryParams));
62 console.log("Successfully wrote event to CloudWatch Logs (retry)");
63 return true;
64 } catch (retryError) {
65 console.error(`Retry failed: ${retryError.message}`);
66 return false;
67 }
68 }
69 return false;
70 }
71}The Serverless Framework configuration defines the Lambda function with proper IAM permissions:
1# serverless.yml
2functions:
3 sesEvents:
4 handler: handler.sesEventsHandler
5 memorySize: 256
6 timeout: 30
7 iamRoleStatementsName: ses-events-cloudwatch-${sls:stage}
8 environment:
9 LOG_GROUP_NAME: "/aws/ses/email-events-${sls:stage}"
10 iamRoleStatements:
11 - Effect: "Allow"
12 Action:
13 - logs:CreateLogGroup
14 Resource:
15 - "arn:aws:logs:${self:provider.region}:ACCOUNT:log-group:/aws/ses/email-events-${sls:stage}"
16 - Effect: "Allow"
17 Action:
18 - logs:CreateLogStream
19 - logs:PutLogEvents
20 - logs:DescribeLogGroups
21 - logs:DescribeLogStreams
22 Resource:
23 - "arn:aws:logs:${self:provider.region}:ACCOUNT:log-group:/aws/ses/email-events-${sls:stage}"
24 - "arn:aws:logs:${self:provider.region}:ACCOUNT:log-group:/aws/ses/email-events-${sls:stage}:*"
25 events:
26 - sns:
27 arn: arn:aws:sns:${self:provider.region}:ACCOUNT:ses-events-topic-${sls:stage}
28 topicName: ses-events-topic-${sls:stage}The function requires specific CloudWatch Logs permissions to create and write to log groups and streams. The permissions are scoped to specific log group patterns to follow the principle of least privilege while allowing the function to manage its own logging infrastructure.
Email event processing must be reliable since missing events can impact business operations. The implementation includes comprehensive error handling:
1exports.handler = Sentry.AWSLambda.wrapHandler(async (event, context) => {
2 console.log("Received event:", JSON.stringify(event, null, 2));
3
4 // Ensure log infrastructure exists
5 await ensureLogGroupExists();
6
7 try {
8 for (const record of event.Records) {
9 if (record.EventSource === "aws:sns") {
10 let snsMessage;
11 try {
12 snsMessage = JSON.parse(record.Sns.Message);
13 } catch (parseError) {
14 console.error("Failed to parse SNS message:", parseError.message);
15 continue; // Skip malformed records
16 }
17
18 if (snsMessage.eventType || snsMessage.notificationType) {
19 const processedEvent = processSESEvent(snsMessage);
20
21 if (await writeToCloudWatch(processedEvent)) {
22 console.log(
23 `Processed ${snsMessage.eventType} event for ${processedEvent.recipient}`
24 );
25 } else {
26 console.error("Failed to write event to CloudWatch");
27 }
28 }
29 }
30 }
31 } catch (error) {
32 console.error(`Error processing event: ${error.message}`);
33 throw error;
34 }
35
36 return {
37 statusCode: 200,
38 body: JSON.stringify("Events processed successfully"),
39 };
40});The function automatically creates log groups and streams if they don't exist, handling access permission edge cases:
1async function ensureLogGroupExists() {
2 try {
3 await cloudwatchLogs.send(
4 new CreateLogGroupCommand({
5 logGroupName: LOG_GROUP_NAME,
6 })
7 );
8 console.log(`Created log group: ${LOG_GROUP_NAME}`);
9 } catch (error) {
10 if (error.name === "ResourceAlreadyExistsException") {
11 console.log(`Log group already exists: ${LOG_GROUP_NAME}`);
12 } else if (error.name === "AccessDeniedException") {
13 console.log(
14 `Cannot create log group (access denied), assuming it exists: ${LOG_GROUP_NAME}`
15 );
16 } else {
17 throw error;
18 }
19 }
20}Deploy the Lambda function and related infrastructure using the Serverless Framework:
1# Deploy to development environment
2serverless deploy --stage dev
3
4# Deploy to production environment
5serverless deploy --stage prodVerify the system works by sending test emails and monitoring CloudWatch Logs:
1# Send test email using AWS CLI
2aws ses send-email \
3 --source "sender@yourdomain.com" \
4 --destination ToAddresses="test@example.com" \
5 --message Subject={Data="Test Email"},Body={Text={Data="Testing SES logging"}} \
6 --configuration-set-name email-tracking-dev
7
8# Check CloudWatch Logs for events
9aws logs filter-log-events \
10 --log-group-name "/aws/ses/email-events-dev" \
11 --start-time $(date -d "1 hour ago" +%s)000Use CloudWatch Logs Insights to analyze email performance patterns:
1-- Find all bounce events in the last 24 hours
2fields @timestamp, eventType, recipient, bounceType, bounceSubType
3| filter eventType = "bounce"
4| sort @timestamp desc
5| limit 100
6
7-- Calculate bounce rate by hour
8fields @timestamp, eventType
9| filter eventType in ["send", "bounce"]
10| stats count(*) as total, sum(eventType = "bounce") as bounces by bin(5m)
11| sort @timestamp descCreate CloudWatch alarms for critical email metrics:
1# Alert on high bounce rates
2aws cloudwatch put-metric-alarm \
3 --alarm-name "SES-High-Bounce-Rate" \
4 --alarm-description "Alert when bounce rate exceeds 5%" \
5 --metric-name BounceRate \
6 --namespace AWS/SES \
7 --statistic Average \
8 --period 300 \
9 --threshold 5.0 \
10 --comparison-operator GreaterThanThreshold
11
12# Alert on complaint rate
13aws cloudwatch put-metric-alarm \
14 --alarm-name "SES-High-Complaint-Rate" \
15 --alarm-description "Alert when complaint rate exceeds 0.1%" \
16 --metric-name ComplaintRate \
17 --namespace AWS/SES \
18 --statistic Average \
19 --period 300 \
20 --threshold 0.1 \
21 --comparison-operator GreaterThanThresholdThe Lambda function is configured with appropriate memory and timeout settings for processing email events efficiently. The 256MB memory allocation provides sufficient resources for JSON parsing and CloudWatch API calls, while the 30-second timeout allows for potential API retries without timing out.
Batch processing multiple SNS records in a single Lambda invocation improves cost efficiency and reduces the number of CloudWatch API calls, which helps stay within service limits during high-volume email campaigns.
CloudWatch Logs charges for data ingestion and storage, so consider implementing log retention policies to manage costs. For high-volume email senders, you might want to filter events to only log bounces, complaints, and failures while excluding routine delivery confirmations.
Monitor your CloudWatch API usage to ensure you're not hitting throttling limits during peak email volumes. If necessary, implement exponential backoff and jitter in your retry logic to spread API calls across time.
The implementation follows security best practices by using IAM roles with minimal required permissions. The function only has access to its specific log group and cannot read or modify other CloudWatch resources.
Sensitive information like message content is logged selectively, ensuring that personally identifiable information (PII) is not unnecessarily stored in logs. The raw event data is included for debugging purposes but can be filtered out in production if privacy requirements are strict.
SNS message format can vary between different SES event types and AWS regions. The implementation includes error handling for JSON parsing failures, logging the problematic message content for debugging while continuing to process other events.
If you encounter consistent parsing failures, check that your SES configuration set is properly configured and that the SNS topic is receiving events from SES rather than other AWS services.
CloudWatch Logs requires sequence tokens to maintain log ordering within a stream. The implementation handles `InvalidSequenceTokenException` errors by extracting the expected token from the error message and retrying the operation.
For high-concurrency scenarios, consider using multiple log streams with time-based or hash-based partitioning to reduce contention on sequence tokens.
IAM permission errors typically manifest as `AccessDeniedException` when trying to create log groups or write log events. Verify that your Lambda execution role has the necessary CloudWatch Logs permissions and that the resource ARNs match your actual log group names.
Monitor several metrics to evaluate the effectiveness of your email logging system. Track the percentage of email events successfully captured versus the volume of emails sent through SES to ensure comprehensive coverage.
Measure the time between email events occurring and appearing in CloudWatch Logs to validate real-time processing capabilities. Monitor Lambda function execution metrics like duration, errors, and throttles to ensure reliable processing during peak volumes.
With centralized logging in place, you'll gain immediate visibility into email delivery patterns that were previously hidden. Delivery rate trends help identify reputation issues before they become critical, while bounce pattern analysis enables proactive list cleaning.
The structured log format enables automated analysis and reporting, making it easy to generate email performance dashboards and compliance reports for stakeholders.
Implementing centralized SES event logging with CloudWatch provides essential visibility into your email delivery infrastructure. This serverless solution automatically captures, processes, and stores email events without requiring ongoing maintenance or manual intervention.
The combination of real-time event processing, structured logging, and comprehensive error handling ensures that you'll have reliable data for monitoring email performance and troubleshooting delivery issues. The investment in proper email observability pays dividends through improved deliverability, proactive issue resolution, and better understanding of your email program's effectiveness.
Ready to get started? Clone the complete project from GitHub and deploy it to your AWS account in minutes. The repository includes everything you need: Lambda function code, Serverless configuration, deployment scripts, and documentation to get your SES logging infrastructure up and running quickly.
Article last update: October 2, 2025