Skip to content

Prospect Outreach + Amazon SES

This system runs a small (~500 emails/day, ~15K/month) outbound prospect outreach program. It is separate from the transactional Gmail sender in workers/convert-email (PDF conversion result emails). All routes live in workers/api.

Components

PathPurpose
supabase/migrations/20260419_084_prospect_management.sqlprospects, email_campaigns, email_sends, unsubscribes tables (service-role-only RLS)
workers/api/src/utils/ses.tsaws4fetch SES v2 SendEmail wrapper
workers/api/src/services/prospect-sender.tsSynchronous send loop with 200ms throttle and chunk re-entry
workers/api/src/routes/prospects.tsPOST/GET/PATCH/DELETE /api/prospects + POST /api/prospects/import
workers/api/src/routes/campaigns.tsPOST/GET/PATCH /api/campaigns, POST /api/campaigns/send
workers/api/src/routes/unsubscribe.tsPublic GET/POST /api/unsubscribe (RFC 8058 one-click)
workers/api/src/routes/ses-events.tsPOST /api/webhooks/ses-events (EventBridge β†’ Worker)
workers/api/src/routes/internal.tsAdds POST /internal/campaigns/continue for chunk re-entry
scripts/test-ses-send.mjsEnd-to-end test: send one email, watch for events

Worker secrets

Terminal window
cd workers/api
wrangler secret put AWS_ACCESS_KEY_ID --env production
wrangler secret put AWS_SECRET_ACCESS_KEY --env production
wrangler secret put SES_WEBHOOK_SECRET --env production

Plain vars (already set in wrangler.toml):

  • AWS_REGION β€” e.g. us-east-1
  • SES_FROM_EMAIL β€” verified sender
  • SES_CONFIGURATION_SET β€” name of the SES config set with EventBridge destination
  • PUBLIC_BASE_URL β€” origin used to build unsubscribe links

SES setup

1. Verify sending domain

Add these DNS records for theaccessible.org:

  • SPF β€” TXT @ β†’ v=spf1 include:amazonses.com -all
  • DKIM β€” three CNAME records SES generates under Identities β†’ theaccessible.org β†’ DKIM. They look like xxxxx._domainkey.theaccessible.org β†’ xxxxx.dkim.amazonses.com.
  • DMARC β€” TXT _dmarc.theaccessible.org β†’ v=DMARC1; p=none; rua=mailto:[email protected]; pct=100; aspf=r; adkim=r (start with p=none and ramp to quarantine/reject after a week of clean reports).

Verify in SES console: Verified identities β†’ status = Verified.

2. Request production access

By default new SES accounts are in sandbox (200/day, only verified recipients). Submit Request production access with: traffic estimate (~500/day, 15K/mo), opt-in source description, bounce/complaint handling description.

3. Create configuration set

Terminal window
aws sesv2 create-configuration-set --configuration-set-name accessible-outreach \
--reputation-options ReputationMetricsEnabled=true \
--sending-options SendingEnabled=true \
--suppression-options SuppressedReasons=BOUNCE,COMPLAINT

4. Create EventBridge event bus + rule + API destination

Terminal window
# Bus
aws events create-event-bus --name accessible-ses-events
# Add EventBridge as the destination for the configuration set's events
aws sesv2 create-configuration-set-event-destination \
--configuration-set-name accessible-outreach \
--event-destination-name eventbridge \
--event-destination 'Enabled=true,MatchingEventTypes=SEND,DELIVERY,BOUNCE,COMPLAINT,OPEN,CLICK,REJECT,RENDERING_FAILURE,DELIVERY_DELAY,EventBridgeDestination={EventBusArn=arn:aws:events:us-east-1:<ACCOUNT>:event-bus/accessible-ses-events}'
# Connection (auth header EventBridge will inject)
aws events create-connection --name accessible-worker --authorization-type API_KEY \
--auth-parameters 'ApiKeyAuthParameters={ApiKeyName=X-Webhook-Secret,ApiKeyValue=<SES_WEBHOOK_SECRET>}'
# API destination β†’ the worker route
aws events create-api-destination --name accessible-worker-ses-events \
--connection-arn arn:aws:events:us-east-1:<ACCOUNT>:connection/accessible-worker/<id> \
--invocation-endpoint https://worker.theaccessible.org/api/webhooks/ses-events \
--http-method POST --invocation-rate-limit-per-second 50
# Rule on the bus
aws events put-rule --event-bus-name accessible-ses-events --name forward-ses-events \
--event-pattern '{"source":["aws.ses"]}'
# Target
aws events put-targets --event-bus-name accessible-ses-events --rule forward-ses-events \
--targets 'Id=1,Arn=arn:aws:events:us-east-1:<ACCOUNT>:api-destination/accessible-worker-ses-events/<id>,RoleArn=arn:aws:iam::<ACCOUNT>:role/EventBridgeApiDestinationRole'

The IAM role needs events:InvokeApiDestination. Use SES_WEBHOOK_SECRET identical to the Worker secret β€” the route compares it in constant time.

API quick reference

All admin routes accept either:

  • Authorization: Bearer <supabase JWT for an admin user>, or
  • X-Proxy-Secret: <PROXY_SHARED_SECRET> for service-to-service calls.
POST /api/prospects { email, first_name?, last_name?, ... }
GET /api/prospects?status=active&q=acme&page=1&limit=50
PATCH /api/prospects/:id { status?: 'active'|'unsubscribed'|... }
DELETE /api/prospects/:id
POST /api/prospects/import text/csv body β€” header row must include `email`
POST /api/campaigns { name, subject, body_html, from_email, ... }
GET /api/campaigns
GET /api/campaigns/:id β†’ { campaign, stats: { sent, delivered, ... } }
PATCH /api/campaigns/:id draft fields + { status: 'draft'|'paused' }
POST /api/campaigns/send { campaignId } β†’ 202; loop runs in waitUntil
GET /api/unsubscribe?token=<uuid> public, accessible HTML page
POST /api/unsubscribe form or RFC 8058 one-click
POST /api/webhooks/ses-events EventBridge target (X-Webhook-Secret)

Body templating

Campaign body_html and body_text support {{ first_name }}, {{ last_name }}, {{ email }}, and {{ unsubscribe_url }} placeholders, replaced per recipient before send. Personal fields are HTML-escaped; the unsubscribe URL is not.

Idempotency

email_sends(campaign_id, prospect_id) has a partial unique index. Re-running /api/campaigns/send for the same campaign re-queries eligible prospects and skips already-sent ones β€” safe to retry on failures.

Chunking

The send loop processes up to 1,000 recipients per Worker invocation, then re-invokes itself via POST /internal/campaigns/continue (proxy-secret auth). With ~200ms per send the per-chunk wall clock is ~3 minutes β€” well under the Worker CPU time limit.

How it works

  • Send loop. POST /api/campaigns/send returns 202 immediately and starts runCampaignChunk in ctx.waitUntil. Each chunk pulls up to 1,000 eligible prospects via get_eligible_prospects(campaign_id, 1000) (Postgres anti-join β€” see migration 084), sends one at a time with a 200 ms delay (5/s, well under SES’s 14/s default), and re-invokes itself via POST /internal/campaigns/continue if any prospects remain.
  • Idempotency. A partial unique index on email_sends(campaign_id, prospect_id) WHERE campaign_id IS NOT NULL guarantees each (campaign, prospect) pair is sent at most once, even if /api/campaigns/send is invoked multiple times. The RPC’s NOT EXISTS clause does the same anti-join in SQL so retries are cheap.
  • Bounces & complaints. When EventBridge delivers a Bounce event with bounceType=Permanent, the handler marks both the email_sends row (status='bounced', error=diagnosticCode) and the prospects row (status='bounced') so future campaigns skip the address. Transient bounces are logged but not flagged. Complaint events flip the prospect to complained, write an unsubscribes audit row, and update the send row.
  • Out-of-order events. The Send handler only upgrades rows still in queued; later Send events that arrive after Delivery/Open/ Click are ignored so the row is not downgraded.

Rate limiting

Public endpoints (/api/unsubscribe GET and POST) should sit behind a Cloudflare Rate Limiting rule:

  • Path matches /api/unsubscribe*
  • Threshold: 30 requests / 1 minute / IP
  • Action: block 10 minutes

The webhook (/api/webhooks/ses-events) is protected by the shared secret and EventBridge’s per-target rate limit; no extra rule needed.

Testing

End-to-end smoke test:

Terminal window
# Set the env vars locally first; the script reuses Worker secrets via
# environment variables (it does not read wrangler).
SES_API_BASE=https://worker.theaccessible.org \
PROXY_SHARED_SECRET=... \
SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... \
node scripts/test-ses-send.mjs

The script:

  1. Inserts a single prospect with the given email.
  2. Creates a one-off campaign with a β€œHello {{ first_name }}” template.
  3. POSTs /api/campaigns/send.
  4. Polls Supabase for email_sends updates for 90 seconds and prints each event observed (sent β†’ delivered β†’ opened β†’ …).