A lightweight, fully serverless cold-email pipeline: Bedrock derives who to target from a product's website, Clay finds and enriches matching people, Bedrock personalizes and sends, and every AI-authored message to a real person — the first cold email's follow-ups aside — waits on a human click before it goes out.
Scaling the sending side past one shared inbox? See architecture-2.0.html — a target-state roadmap doc, not yet built.
Ten Lambda functions, four DynamoDB tables, and one HTTP API — nothing runs when nothing is happening, and nothing needs patching between deploys. It replaced an earlier, much larger design (Next.js dashboard on ECS Fargate, Postgres, multi-user roles) that was scaffolded and then deliberately set aside in favor of this smaller build; that fuller platform still exists in git history if the project outgrows this version.
Two design choices carry through the whole system and are worth stating up front: every table is pay-per-request (no capacity to size, no idle cost), and every AI-drafted message to a prospect requires a human click before it sends — the ICP a campaign targets, and every reply a lead gets, both wait in an email to the sales inbox with an approve link. Nothing about that is automatic.
| Function | Trigger | Does |
|---|---|---|
| icp-discovery | API — POST /campaigns | Fetches the target homepage, Bedrock derives an ICP, emails sales inbox an approve link |
| icp-review-response | API — GET /campaigns/{id}/approve | Marks a campaign approved; Clay sourcing can start |
| clay-webhook | API — POST /campaigns/{id}/leads | Validates Clay's shared secret, checks suppression, idempotent lead insert |
| personalize | EventBridge — every 10 min | Bedrock drafts subject + opener per new lead, grounded in the campaign's ICP |
| send-scheduler | EventBridge — every N min (env-configured) | Picks least-used active inbox, sends via SMTP, writes booking + unsubscribe links into every email |
| reply-poller | EventBridge — every 5 min | IMAP-polls every active inbox, deterministic unsubscribe-phrase check, then Bedrock classifies + drafts a reply, emails sales inbox for approval |
| reply-review-response | API — GET /leads/{id}/{email}/approve-reply | Sends the approved draft in-thread via SMTP |
| calendar-webhook | API — POST /calendar/webhook (Calendar push) | Syncs the SE's calendar, matches new-event attendees to leads |
| unsubscribe | API — GET /unsubscribe/{id}/{email} | Writes suppression directly — independent of any AI classification |
| daily-digest | EventBridge — once/day | Emails the sales inbox a summary of the last 24h (new leads, top companies, meetings booked) |
Four DynamoDB tables, all pay-per-request. leads uses a single-table pattern for two secondary lookups instead of extra GSIs — cheaper and simpler at this scale.
targetUrl, status (pending_review / approved), icpProfile, bookingLink, salesEmail, approvalToken
CAMPAIGN#id / LEAD#email for lead records; THREAD#id and EMAIL#email pk/sk pairs in the same table as lookup shortcuts for the reply and calendar webhooks. gsi1 (status → campaign#createdAt) lets scheduled Lambdas query "all leads in status X" without a table scan.
Global, not per-campaign — one unsubscribe stops every campaign from ever emailing that address again. Checked on every ingest and every send.
dailyCap, sentToday, lastResetDate, status, lastImapUid (IMAP's incremental-sync cursor for the reply poller)
| System | Used for | How |
|---|---|---|
| Bedrock (Converse API) | ICP discovery, personalization, reply classification/drafting | AWS SDK, IAM-scoped, no external credentials |
| Gmail (SMTP/IMAP) | Cold sends, reply detection (5-min poll), in-thread replies | nodemailer (send) + imapflow/mailparser (poll), a Google App Password per inbox in Secrets Manager — no OAuth, no Google Cloud project |
| Google Calendar API | Meeting capture via push notifications + incremental sync | Service account (RFC 7523 JWT bearer, raw fetch) — no OAuth consent screen, no refresh token; the target calendar just needs to be shared with the service account's email |
| Clay | Lead sourcing + enrichment | Outbound webhook from Clay — accepts either a static shared-secret header or Clay's own whsec_ webhook signature (Svix/Standard-Webhooks-shaped; unconfirmed against a real Clay request), no AWS credentials given to Clay |
| SES | ICP review requests, reply-approval requests, daily digest, meeting alerts | Transactional only — never cold outbound, so it carries no sender-reputation risk |
The ICP gate stops a bad Bedrock guess from burning Clay credits on the wrong audience. The reply gate stops AI-authored content from reaching a real person unreviewed — Bedrock classifies and drafts, but nothing sends until someone clicks approve. Both use the same pattern: a one-click link email, no inline editing (reject by handling it manually or resubmitting instead). This was a deliberate reversal from an earlier version of this build where replies auto-sent — worth knowing if the tradeoff ever needs revisiting for volume.
docs/admin.html, hosted on the same S3 site as this page, is a self-service form for proving the pipeline against a new target URL before Clay is wired up for it — no chat/CLI typing, no direct DynamoDB access. It creates a test campaign (URL + optional approver/booking-link override), submits a test lead by calling the same createLead() dedupe/suppression logic the Clay webhook uses (bypassing Clay itself), triggers Personalize/Send/Reply-poll synchronously instead of waiting on the EventBridge schedule, and checks a lead's status. The sending/reading inbox is never a form field — it's always the shared pool, same as production. Gated by a shared X-Admin-Token header (Secrets Manager, auto-generated), kept in the browser's localStorage rather than in the page itself since the docs bucket is public. It does not skip either human-review gate above — the ICP approval email still has to be clicked before a test lead can be submitted.
Live so far: sending domain (trywifiscanner.com, Route 53), SES fully verified, one Gmail inbox with an App Password added to the pool, Bedrock model access confirmed (us.anthropic.claude-haiku-4-5-20251001-v1:0), Clay webhook auth confirmed reachable end-to-end. The full pipeline has been exercised live, real cold send through real Bedrock-drafted reply through real approval — one real meeting booking even showed up along the way. The wifiscanner.com campaign is created and sitting at pending_review — approving it starts real Clay ingestion and real sends, so that's held for an explicit go-ahead. Still open: the Google Calendar service account (create it, share the calendar with it, register the watch). All documented in the repo README.