mailshot

Architecture

System overview, AWS services, data flow, caching, and cost model.

Design principles

  • Product-agnostic — The framework defines contracts (event shapes, subscriber schema, template format). You provide your own sequences, templates, and events.
  • Zero idle cost — Step Functions wait states are free. No servers running between emails.
  • AI-native operations — No admin UI. All management happens through Claude Code via MCP tools and skills.
  • Single-table DynamoDB — All subscriber state in one table, engagement events in a second. No relational database.

AWS services

ServiceRole
EventBridgeEvent ingestion and routing. Custom bus receives events from your app. Rules route to Step Functions (sequences) or Lambda (fire-and-forget).
Step FunctionsSequence orchestration. One state machine per sequence. Handles branching, delays, and step ordering. Wait states cost nothing.
LambdaFive functions: SendEmail, CheckCondition, Unsubscribe, BounceHandler, EngagementHandler.
DynamoDBTwo tables. Main table: subscriber profiles, active executions, send logs, suppression records. Events table: engagement tracking (opens, clicks, deliveries).
S3HTML email templates. Deployed via CDK BucketDeployment. Cached in Lambda memory for 10 minutes.
SESEmail delivery with open/click tracking via configuration set.
SNSRoutes SES notifications (bounces, complaints, engagement events) to Lambda handlers.
SSM Parameter StoreRuntime configuration. All .env values are written as SSM parameters at deploy time, read by Lambdas with 5-minute caching.

Data flow

┌──────────────────────────────────┐
│ Your App Backend                 │
│ publishes events to EventBridge  │
└───────────────┬──────────────────┘


┌──────────────────────────────────┐
│ EventBridge (Custom Bus)         │
│                                  │
│ Sequence rules ──→ Step Functions│
│ Event rules ────→ SendEmailFn    │
└───────┬──────────────────┬───────┘
        │                  │
        ▼                  ▼
 Step Functions        SendEmailFn (fire-and-forget)
   │                       │
   ├─ register ────────────┤
   ├─ send (per step) ─────┤
   ├─ wait (free)          │
   ├─ choice (native)      │
   ├─ condition ───→ CheckConditionFn
   ├─ complete ────────────┤
   │                       │
   └───────────────────────┘

        ┌───────┴───────┐
        ▼               ▼
   DynamoDB           S3 Templates
   (state)            (HTML + Liquid)


       SES ──→ Recipient

        ├─ Bounce/Complaint ──→ SNS ──→ BounceHandlerFn
        │                                  └─ suppress subscriber
        │                                  └─ stop executions

        ├─ Engagement ────────→ SNS ──→ EngagementHandlerFn
        │  (open/click/delivery)           └─ write to Events table

        └─ Unsubscribe link ──→ UnsubscribeFn (Function URL)
                                   └─ mark unsubscribed
                                   └─ stop executions

Lambda functions

SendEmailFn (256MB, 30s timeout)

The main workhorse. Handles four actions:

  • register — Upsert subscriber profile, guard against unsubscribed/suppressed subscribers, track execution in DynamoDB
  • send — Pre-send checks → fetch template → render with Liquid → send via SES → log
  • fire_and_forget — Upsert + send in one call (for event-triggered single emails)
  • complete — Remove execution record when sequence finishes

CheckConditionFn (128MB, 10s timeout)

Runtime condition evaluation for Step Functions. Queries DynamoDB to check:

  • has_been_sent — Has this subscriber received a specific template?
  • subscriber_field_exists — Does a profile attribute exist?
  • subscriber_field_equals — Does a profile attribute match a value?

UnsubscribeFn (128MB, 10s, Function URL)

Public endpoint (no auth). Validates HMAC-SHA256 token, marks subscriber as unsubscribed, stops all active executions, adds to SES account-level suppression list, returns HTML confirmation page.

BounceHandlerFn (128MB, 10s)

SNS trigger. Processes permanent bounces and complaints. Marks subscriber as suppressed, stops all executions, adds to SES account-level suppression list. Ignores transient bounces.

EngagementHandlerFn (128MB, 10s)

SNS trigger. Writes delivery, open, click, bounce, and complaint events to the Events table with 365-day TTL.

Caching layers

CacheTTLScope
SSM config5 minutesModule-level Map in Lambda
S3 templates10 minutesModule-level Map in Lambda
Display name mappings10 minutesModule-level Map in Lambda

Cost model

At 1,000 subscribers, total cost is under $5/month:

  • Step Functions: $0.000025 per state transition. A 10-step sequence with 5 sends costs ~$0.00025 per execution.
  • Wait states: Free. A 30-day sequence costs the same as a 1-day sequence.
  • Lambda: Negligible at low volume. 128–256MB functions running for less than 1 second per invocation.
  • DynamoDB: On-demand pricing. A few cents per month at 1,000 subscribers.
  • SES: $0.10 per 1,000 emails.
  • S3: Negligible for storing HTML templates.
  • SSM: Free for standard parameters.

Compare this to $29–$299/month for email SaaS tools that do the same thing.