mailshot

DynamoDB Schema

Single-table design, key patterns, item schemas, TTLs, and GSIs.

Two tables: a main table (single-table design) for all subscriber state, and an events table for engagement tracking.

Main table

All items are keyed by subscriber email. No GSIs — all access patterns are served by the primary key.

Key structure

PKSKItem type
SUB#user@example.comPROFILESubscriber profile
SUB#user@example.comEXEC#onboardingActive execution
SUB#user@example.comSENT#2026-03-17T10:30:00.000ZSend log entry
SUB#user@example.comSUPPRESSIONSuppression record

Key helper functions

The @mailshot/shared package exports these helpers:

subscriberPK("user@example.com");  // → "SUB#user@example.com"
executionSK("onboarding");         // → "EXEC#onboarding"
sentSK("2026-03-17T10:30:00.000Z"); // → "SENT#2026-03-17T10:30:00.000Z"

Item schemas

PROFILE

{
  PK: "SUB#user@example.com",
  SK: "PROFILE",
  email: "user@example.com",
  firstName: "Jane",
  attributes: { platform: "kajabi", country: "ZA" },
  unsubscribed: false,
  suppressed: false,
  createdAt: "2026-03-01T00:00:00.000Z",
  updatedAt: "2026-03-17T10:00:00.000Z",
}

EXEC#<sequenceId>

{
  PK: "SUB#user@example.com",
  SK: "EXEC#onboarding",
  sequenceId: "onboarding",
  executionArn: "arn:aws:states:...",
  startedAt: "2026-03-01T00:00:00.000Z",
}

SENT#<timestamp>

{
  PK: "SUB#user@example.com",
  SK: "SENT#2026-03-17T10:30:00.000Z",
  templateKey: "onboarding/welcome",
  subject: "Welcome!",
  sequenceId: "onboarding",
  sentAt: "2026-03-17T10:30:00.000Z",
  ttl: 1752710400, // 90 days from sentAt
}

SUPPRESSION

{
  PK: "SUB#user@example.com",
  SK: "SUPPRESSION",
  type: "Permanent",     // or "Complaint"
  reason: "General",
  suppressedAt: "2026-03-17T10:30:00.000Z",
}

Access patterns

PatternKey condition
Get subscriber profilePK = SUB#email, SK = PROFILE
Get active executionPK = SUB#email, SK = EXEC#sequenceId
List all executionsPK = SUB#email, SK begins_with EXEC#
Get send historyPK = SUB#email, SK begins_with SENT#
Check if sentPK = SUB#email, SK begins_with SENT# + filter on templateKey
Get suppressionPK = SUB#email, SK = SUPPRESSION
Get everything for subscriberPK = SUB#email (no SK condition)

TTLs

Item typeTTL
Send logs90 days (SEND_LOG_TTL_DAYS)
All othersNo TTL (permanent until explicitly deleted)

Events table

Stores SES engagement events (delivery, open, click, bounce, complaint).

Key structure

PKSK
SUB#user@example.comEVT#2026-03-17T10:30:00.000Z#delivery

Item schema

{
  PK: "SUB#user@example.com",
  SK: "EVT#2026-03-17T10:30:00.000Z#delivery",
  eventType: "delivery",
  templateKey: "onboarding/welcome",
  sequenceId: "onboarding",
  subject: "Welcome!",
  timestamp: "2026-03-17T10:30:00.000Z",
  ttl: 1784246400, // 365 days
}

TemplateIndex GSI

Enables cross-subscriber queries by template:

GSI PKGSI SK
templateKey (e.g., onboarding/welcome)EVT#<timestamp>#<eventType>

Query: "Show me all opens for the welcome email in the last 7 days"

PK = "onboarding/welcome", SK begins_with "EVT#2026-03-10"

TTLs

All events expire after 365 days (EVENT_TTL_DAYS).

Why single-table design?

All subscriber data in one table means:

  • A single Query on the PK retrieves everything about a subscriber (profile, executions, send history, suppression)
  • No joins, no cross-table transactions
  • Consistent performance regardless of table size
  • Lower operational overhead (one table to manage, back up, monitor)

The events table is separate because:

  • Different TTL (365 days vs permanent)
  • Different access patterns (cross-subscriber queries via GSI)
  • Higher write volume (every open/click generates a record)