Implementing Autonomous Email Campaign Management: Architecture for Multi-Platform Contact Suppression and Cadence Automation
This technical post documents the engineering decisions and implementation patterns used to build an autonomous email campaign management system that handles contact suppression, multi-platform distribution, and time-based cadence automation across a distributed event marketing operation.
The Problem: Managing Suppression Lists Across Multiple Channels
When running high-volume email campaigns across multiple platforms (GetMyBoat, WeddingWire, Viator, etc.), contact suppression becomes a critical operational concern. Bounces, unsubscribes, and spam complaints accumulate across channels, but each platform maintains its own suppression list. Without a unified approach, the same invalid contact could generate bounce errors across multiple outreach attempts.
The specific challenge: Amazon SES maintains its own suppression list (permanent and transient), but platform-specific rejection data lives in platform APIs and spreadsheets. We needed a system that:
- Exports SES suppression list data regularly
- Deduplicates contacts across platforms before sending
- Implements configurable cadence rules (initial outreach, follow-ups, final breakup message)
- Maintains audit trails for compliance
- Gracefully handles API rate limiting and failures
Technical Architecture: LaunchAgent-Based Scheduling
Rather than relying on external cron services or cloud schedulers, we chose macOS LaunchAgent configuration for campaign automation. This keeps the operational stack self-contained and auditable.
Four plist files were created in /Users/cb/Library/LaunchAgents/:
com.jada.hotel-outreach-initial.plist— Initial outreach campaigncom.jada.hotel-outreach-followup1.plist— First follow-up sequencecom.jada.hotel-outreach-followup2.plist— Second follow-up sequencecom.jada.hotel-outreach-breakup.plist— Final "breakup" message with clear CTA
Each plist specifies a StartCalendarInterval dict with specific days/hours, ensuring campaigns respect timezone considerations and avoid oversaturation. The StandardErrorPath and StandardOutPath route logs to ~/Library/Logs/jada/ for debugging and compliance audits.
Python Implementation: The send_hotel_outreach.py Pipeline
The core execution logic lives in /Users/cb/Documents/repos/tools/send_hotel_outreach.py. This script implements a four-stage pipeline:
Stage 1: SES Suppression List Export
Using boto3, the script connects to the AWS SES service and retrieves both permanent and transient suppression lists:
import boto3
ses_client = boto3.client('ses', region_name='us-west-2')
# Fetch suppression list via list_suppressed_destinations()
# Cross-reference with local contact database
The SES suppression list is authoritative; if a contact appears there, they're excluded from all outreach regardless of platform status.
Stage 2: Platform Contact Deduplication
Before sending, the script loads contact lists from each platform (GetMyBoat, WeddingWire, etc.) and performs a fuzzy-match deduplication against the SES suppression list. Email normalization (lowercase, whitespace trim) prevents false positives.
Stage 3: Template Rendering and Validation
Email templates are stored in /Users/cb/Documents/repos/sites/queenofsandiego.com/ as both HTML and plain-text variants. The script validates that:
- No personally identifiable information (PII) from the sender is embedded
- All image URLs are absolute paths (S3 CloudFront URLs preferred)
- Click-through links include proper UTM parameters for campaign tracking
This is critical for the compliance directive: never embed the campaign owner's name in marketing materials. Template validation now explicitly checks for forbidden patterns.
Stage 4: Batch Sending with jada_blast.py Integration
Rather than reinventing the wheel, send_hotel_outreach.py delegates actual sending to the existing /Users/cb/Documents/repos/tools/jada_blast.py tool, which handles:
- SES rate limiting (14 emails/second in sandbox, higher in production)
- Retry logic with exponential backoff
- Per-campaign tracking via custom email headers
- Failure aggregation and alerting
The integration point accepts a list of contacts and a template identifier, returning a batch job UUID for tracking.
Infrastructure: S3 and CloudFront Integration
Email templates and promotional images are hosted on S3 behind CloudFront CDN for performance and reliability. The architecture:
- S3 Bucket:
queenofsandiego.com(primary content bucket) - CloudFront Distribution: d-specific-id (invalidation required when templates change)
- Route53 CNAME:
cdn.queenofsandiego.com→ CloudFront domain
When email templates are updated (e.g., removing the campaign owner's name from footers), the deployment process:
- Updates the template HTML in the S3 bucket
- Invalidates the CloudFront cache for that object path
- Logs the change in the deployment audit trail
Example invalidation command (no credentials shown):
aws cloudfront create-invalidation \
--distribution-id DIST_ID_HERE \
--paths "/email-templates/*"
Key Decision: Why LaunchAgent Over Lambda/SQS?
A reasonable alternative would be AWS Lambda triggered by EventBridge (CloudWatch Events) with SQS for message ordering. We chose LaunchAgent because:
- Operational Simplicity: No additional AWS service configuration, no IAM role management beyond existing SES credentials
- Cost: No per-invocation Lambda charges; the local machine already runs 24/7
- Debugging: Logs are local and immediately accessible; no CloudWatch delay
- Compliance: All campaign history remains on a controlled machine, not distributed across cloud services
This is a deliberate trade-off: we gain simplicity and auditability at the cost of global redundancy. If the local machine goes down, campaigns pause until manual intervention. For a small-to-medium operation, this is acceptable.
Compliance and Template Validation
A critical new validation rule was added to template processing: scan for any occurrence of the campaign owner's name (or variations) and reject the template if found. This is implemented as a pre-flight check in send_hotel_outreach.py:
FORBIDDEN_PATTERNS = [
r'C\.B\.\s+Ladd',
r'CB La