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
| Path | Purpose |
|---|---|
supabase/migrations/20260419_084_prospect_management.sql | prospects, email_campaigns, email_sends, unsubscribes tables (service-role-only RLS) |
workers/api/src/utils/ses.ts | aws4fetch SES v2 SendEmail wrapper |
workers/api/src/services/prospect-sender.ts | Synchronous send loop with 200ms throttle and chunk re-entry |
workers/api/src/routes/prospects.ts | POST/GET/PATCH/DELETE /api/prospects + POST /api/prospects/import |
workers/api/src/routes/campaigns.ts | POST/GET/PATCH /api/campaigns, POST /api/campaigns/send |
workers/api/src/routes/unsubscribe.ts | Public GET/POST /api/unsubscribe (RFC 8058 one-click) |
workers/api/src/routes/ses-events.ts | POST /api/webhooks/ses-events (EventBridge β Worker) |
workers/api/src/routes/internal.ts | Adds POST /internal/campaigns/continue for chunk re-entry |
scripts/test-ses-send.mjs | End-to-end test: send one email, watch for events |
Worker secrets
cd workers/apiwrangler secret put AWS_ACCESS_KEY_ID --env productionwrangler secret put AWS_SECRET_ACCESS_KEY --env productionwrangler secret put SES_WEBHOOK_SECRET --env productionPlain vars (already set in wrangler.toml):
AWS_REGIONβ e.g.us-east-1SES_FROM_EMAILβ verified senderSES_CONFIGURATION_SETβ name of the SES config set with EventBridge destinationPUBLIC_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
CNAMErecords SES generates underIdentities β theaccessible.org β DKIM. They look likexxxxx._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 withp=noneand ramp toquarantine/rejectafter 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
aws sesv2 create-configuration-set --configuration-set-name accessible-outreach \ --reputation-options ReputationMetricsEnabled=true \ --sending-options SendingEnabled=true \ --suppression-options SuppressedReasons=BOUNCE,COMPLAINT4. Create EventBridge event bus + rule + API destination
# Busaws events create-event-bus --name accessible-ses-events
# Add EventBridge as the destination for the configuration set's eventsaws 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 routeaws 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 busaws events put-rule --event-bus-name accessible-ses-events --name forward-ses-events \ --event-pattern '{"source":["aws.ses"]}'
# Targetaws 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>, orX-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=50PATCH /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/campaignsGET /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 pagePOST /api/unsubscribe form or RFC 8058 one-clickPOST /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/sendreturns 202 immediately and startsrunCampaignChunkinctx.waitUntil. Each chunk pulls up to 1,000 eligible prospects viaget_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 viaPOST /internal/campaigns/continueif any prospects remain. - Idempotency. A partial unique index on
email_sends(campaign_id, prospect_id) WHERE campaign_id IS NOT NULLguarantees each (campaign, prospect) pair is sent at most once, even if/api/campaigns/sendis invoked multiple times. The RPCβsNOT EXISTSclause does the same anti-join in SQL so retries are cheap. - Bounces & complaints. When EventBridge delivers a
Bounceevent withbounceType=Permanent, the handler marks both theemail_sendsrow (status='bounced',error=diagnosticCode) and theprospectsrow (status='bounced') so future campaigns skip the address. Transient bounces are logged but not flagged.Complaintevents flip the prospect tocomplained, write anunsubscribesaudit row, and update the send row. - Out-of-order events. The
Sendhandler only upgrades rows still inqueued; laterSendevents that arrive afterDelivery/Open/Clickare 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:
# 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.mjsThe script:
- Inserts a single prospect with the given email.
- Creates a one-off campaign with a βHello {{ first_name }}β template.
- POSTs
/api/campaigns/send. - Polls Supabase for
email_sendsupdates for 90 seconds and prints each event observed (sent β delivered β opened β β¦).