System Architecture · Target State — Not Yet Built

WiFiScanner AI SDR 2.0 — scaling the send side

This describes where the sending infrastructure goes as volume grows past one shared inbox — more Workspace inboxes, more sending domains, real deliverability monitoring, branded links. The pipeline itself (Lambda/DynamoDB/EventBridge, Bedrock, the two human-review gates) doesn't change; see architecture.html for that, still the as-built system. This is a roadmap, not a deploy.

Partially deployed — see status per section Baseline: AWS + Google Workspace only Option B: Smartlead — not pursued Option C: MailReach — live 2026-07-19

Update — three things this doc originally scoped are now real, via a different path than planned

MailReach (Option C) is connected — wifiscanner@trywifiscanner.com has warmup running, and its score is pulled live into admin-status.ts/admin.html. Smartlead (Option B) was evaluated and not pursued.

Clay sourcing no longer needs the manual "build a table, wire an HTTP API action" loop this doc assumed — clay-source.ts calls Clay's Search + Routines API directly, scheduled per approved campaign. See the repo README, "Clay lead sourcing."

Domain/inbox provisioning — the "manual-ish loop" described below now has a real automated version, just via a bigger mechanism than this doc originally scoped: a parallel ops platform (separate CDK app, Cognito-authenticated Fargate dashboard, Step Functions workflow) that registers DNS, adds a domain to Workspace, creates the inbox, and marks it ready — all with zero code change to send-scheduler.ts/reply-poller.ts. Real domain purchasing is deliberately not implemented there either (needs real WHOIS contact data); it always assumes the domain is already registered by hand.

What changes from 1.0

1.0 proved the pipeline end to end on one shared Workspace inbox. 2.0 is entirely about what feeds that pipeline's inbox-pool table and gmail-oauth secret — more sending identities, spread across more domains, with real visibility into whether any of them are burning reputation. Nothing about ICP discovery, Clay sourcing, personalization, or the approval gates changes; send-scheduler.ts already round-robins across whatever's in the inbox pool, so this is additive capacity, not a rebuild.

Scale target & the math

30 inboxes × 10 sends/day = 300/day baseline. That's a useful checkpoint to be explicit about: this is well below the 1,000–5,000/day range where dedicated cold-email infra operations live, and most of what makes their tooling complex (bulk inbox marketplaces, per-alias throttling consoles, managed warmup-as-a-service) is solving a problem this scale doesn't have yet. The operating principles those operations use — don't run inboxes at the edge of their limit, isolate domains so one bad one doesn't take the rest down, watch signal and rotate out underperformers rather than trying to "fix" a domain — hold at any scale, and are what 2.0 actually borrows.

Quantity1.0 (today)2.0 target
Sending inboxes1 (wifiscanner@trywifiscanner.com)~30, added via the admin form's bulk-paste
Sending domains1 (trywifiscanner.com)Several — see domain strategy below
Per-inbox daily cap1010 (unchanged — cap stays conservative regardless of pool size)
Effective daily volume≤10≤300

Domain & subdomain strategy

Two ways to add sending capacity, with different isolation properties:

Subdomains of one root

e.g. notify.trywifiscanner.com, sender.trywifiscanner.com

Each gets its own SPF and DKIM selector, so reputation is mostly independent. Cheapest to set up — one Route 53 zone, no new domain purchases. Risk: if the apex domain itself gets blocklisted by a provider, subdomains under it can get caught in the blast radius, and DMARC policy inherits from the root unless a subdomain sets its own.

Separate root domains

e.g. wifihub.com, technologydepot.com (per your earlier suggestion)

Full isolation — a burned domain only takes its own inboxes down. This is the actual firewall real cold-email operations rely on, treating each domain as a disposable unit rather than something to nurse back to health. Costs more (domain registration per unit) and more DNS setup, but at 30 inboxes split across, say, 5–6 domains (5-6 inboxes each), the blast radius of losing one domain is small.

Recommendation for this scale: a handful of separate root domains (not 30 — one per 5-6 inboxes), each registered through Route 53 like trywifiscanner.com was, each with its own SES-independent SPF/DKIM/DMARC (cold sends don't go through SES at all, so this is just Workspace + Route 53 DNS, no SES identity needed per sending domain). Reserve the actual product domain (wifiscanner.com) for the real site only — never for sending, same as today.

Provisioning inboxes — still Google-native

No third-party inbox marketplace (Hypertide-style vendors sell pre-warmed inboxes in bulk) — staying AWS + Google Workspace only means this stays a manual-ish loop per domain:

  1. Register the domain (Route 53), point MX/SPF/DKIM at Google Workspace per Google's setup docs.
  2. Verify the domain in Google Workspace, create user accounts (one per sending identity — Workspace's per-domain user limits depend on the plan tier).
  3. Enable 2-Step Verification per user, generate an App Password per inbox.
  4. Bulk-paste email,app-password,dailyCap into the admin form's Sender Inboxes section — it writes each into the shared gmail-oauth secret and creates the inbox-pool row, same mechanism as today's single inbox.
  5. Let new inboxes age before adding real send volume — see warm-up note below.

Honest gap vs. vendor tooling

This is the one place a pure AWS+Google stack doesn't have a clean equivalent: dedicated cold-email platforms sell bulk inbox/domain provisioning and warmup-as-a-service specifically to remove this manual loop. At 30 inboxes it's tractable by hand (or scriptable against the Workspace Admin SDK later if it becomes tedious); it would not be at 1,000+. Worth naming now rather than discovering it as a surprise later if volume targets grow past what this doc sizes for.

Alternative: Smartlead as an API-driven sending layer

Researched as an alternative to the manual provisioning loop above — API-only, no one operating Smartlead's own dashboard. Smartlead doesn't replace the pipeline's brains (Bedrock, the two approval gates, DynamoDB state stay exactly where they are); it would replace the mechanical send/receive layer mailer.ts currently owns, in exchange for managed inbox warmup and a reputation dashboard — the two things flagged as an honest gap in the sections above.

Current pieceSmartlead API equivalentConfidence
gmail-oauth secret + inbox-pool tablePOST /email-accounts/save — takes the same raw App Password + smtp.gmail.com/imap.gmail.com already generated. Admin form's bulk-paste could push here instead of (or in addition to) Secrets Manager.Confirmed
send-scheduler.ts (nodemailer SMTP send)Bedrock's subject/opener pushed as a lead custom_field via POST /campaigns/{id}/leads; the campaign's one-step sequence template is just {{custom_field}} — fully custom per-lead content, indirected through a "sequence" wrapperConfirmed
reply-poller.ts (IMAP poll, 5 min)EMAIL_REPLY webhook — push-based, real-time instead of polledConfirmed
reply-review-response.ts send, after human approvalPOST /campaigns/{id}/reply-email-threadNeeds a lookup step — see below
unsubscribe.ts / suppression tableKept as-is — check our own suppression table before ever adding a lead to a Smartlead campaign, rather than relying on Smartlead's own block-listNo change needed

What Smartlead does that we could build ourselves

Already built, 1.0
  • Multi-inbox rotation and per-inbox daily caps — inbox-pool table + send-scheduler.ts
  • Per-lead custom AI-personalized content — Bedrock in personalize.ts, no merge-tag/sequence wrapper needed on our side
  • Bounce/complaint/unsubscribe suppression — suppression table, checked before every send
  • Reply ingestion + AI classification/drafting — reply-poller.ts + Bedrock (polled, not pushed — see below)
Straightforward to add
  • A "unified inbox" view — a filtered read over the leads table (status = pending_reply_review / replied) rendered in admin.html; less polished, same data
  • Sent/replied/bounced counts per inbox or domain — already computable from DynamoDB status fields, just not queried yet
  • Faster reply detection — shorten the EventBridge poll rate below 5 min (still polling, not true push, without reintroducing Gmail-API-OAuth)

What only Smartlead offers

Genuine moat — not practical to self-build
  • Reciprocal warmup network. Thousands of other Smartlead-connected accounts send/open/reply to your new inboxes and vice versa, building engagement history before real prospects ever see them. Replicating this ourselves would mean operating a second fleet of fake-recipient accounts — impractical, and arguably worse practice than not warming up at all.
  • Aggregate reputation signal across their whole customer base. Their domain/inbox health scoring is informed by sending patterns across many customers, not just this one — a vantage point a single sender can't replicate.
  • Real-time reply webhooks. True push, not poll — meaningfully lower latency than any interval-based IMAP poll can offer without Gmail API push (Pub/Sub + OAuth), which this build deliberately moved away from.
  • Likely blocklist/spam-trap monitoring — dedicated platforms typically subscribe to RBL/spam-trap feeds most individual senders don't have access to (not independently confirmed in this research, but consistent with the category).

Cons

  • Deviates from the AWS+Google-only baseline stated at the top of this doc — a real fork, not a detail within it.
  • $174–379/mo (Unlimited Smart tier or above — Base/Pro don't include API/webhook access at all), scaling with plan tier rather than actual usage past the included quota.
  • Credential custody — Google App Passwords for every inbox would live in a third-party SaaS, not just Secrets Manager. SOC 2/security posture wasn't confirmed in this research; ask Smartlead directly before handing over real credentials.
  • Less precise send-timing control — once a lead is queued, Smartlead's own scheduler likely takes over the exact send moment, on top of our own pacing logic.
  • External dependency — an outage or breaking API change on their end stops cold sends; today's system has no dependency of that kind outside this AWS account.
  • Reply-threading gap — the EMAIL_REPLY webhook payload (from_email, to_email, campaign_id, reply_body) doesn't cleanly include a lead_id or message_id, so calling reply-email-thread afterward likely needs an extra lookup call this research didn't pin down exactly.
  • Pacing overlap unconfirmed — whether adding a lead to a campaign sends immediately or queues into Smartlead's own pacing engine wasn't confirmed; could double up against our own dailyCap logic rather than replacing it cleanly.
  • Rate limit — 60 API requests/min per key. Not a real constraint at 300 sends/day, but a hard ceiling worth remembering if volume targets grow well past what this doc sizes for.

Framing: because it's strictly API-driven with no manual dashboard operation, it fits the "everything through the Lambda pipeline / admin form" philosophy even though it isn't AWS-only — think of it as AWS + Google Workspace + Smartlead-as-a-managed-API, not a platform switch. Worth a real trial-account spike (14-day free trial, 2,500 email credits) to confirm the two unconfirmed items above before deciding between this and the manual-provisioning path.

Option C: MailReach — warmup-only bolt-on

The smallest-footprint option of the three. Unlike Smartlead, MailReach doesn't want to own sending or replies — it's a reputation-building layer that rides alongside inboxes already in the inbox-pool table. Nothing in the existing pipeline changes: mailer.ts, send-scheduler.ts, reply-poller.ts, personalization, both approval gates, and the DynamoDB schema all stay exactly as built. The only additions are (1) registering each inbox with MailReach via API using the same App Password credentials already generated, and (2) pulling its per-inbox reputation score ("Heat Score") into admin-status.ts — directly filling the Postmaster-Tools-shaped gap flagged above, without OAuth.

WhatDetail
Connects viaIMAP/SMTP with the same App Password credentials, or OAuth — API access included on every plan, no premium gate (unlike Warmy, see below)
Network size30,000+ reciprocal inboxes
Reputation data"Heat Score" per inbox, daily time-series, pullable via API; Slack/webhook alerts on drops
Pricing$25/inbox/mo (1-5 inboxes), $19.50/inbox/mo (6-20 inboxes); pricing above 20 inboxes isn't published

What's missing, compared to the other two options

  • No real-prospect reply automation — the biggest structural difference. MailReach only ever sees its own warmup-network traffic; it has zero visibility into actual cold-email replies. reply-poller.ts's 5-minute IMAP poll stays exactly as it is today. Smartlead's EMAIL_REPLY webhook (Option B) is a genuinely different capability this doesn't replicate — MailReach only makes it more likely a real reply happens, it doesn't change how we detect one.
  • No campaign/lead/sequence management — by design, since we keep our own. Not a loss given Bedrock + DynamoDB already do this; noted so it isn't mistaken for a shortcoming.
  • Smaller reciprocal network than Instantly's claimed 200,000+ (30,000+ vs. 200,000+) — untested whether that gap matters at 300/day scale.
  • No flat "unlimited accounts" pricing. Smartlead and Instantly both bundle unlimited email accounts into one flat monthly fee; MailReach charges per inbox. At 30 inboxes, extrapolating the published tiers (unconfirmed above 20 — needs a real quote) lands somewhere in the $500-700+/mo range, which could exceed Smartlead's flat $174-379/mo Unlimited-Smart-or-above tier. Get an actual quote at 30 inboxes before assuming this is the cheaper path.
  • No confirmed AI-adaptive send-pattern warmup or seed-list inbox-placement testing — Warmy.io advertises both as differentiators; not confirmed present in MailReach's feature set from this research. Warmy's API is gated behind its $279/mo Expert tier though, so it's a real trade either way, not a clean win for Warmy.
  • API rate limits and SOC 2/security compliance weren't found in this research — same open-item caution as Smartlead; confirm directly before handing over real credentials.

Recommendation: lowest-risk way to close the reputation-monitoring gap without touching a single line of the working send/reply pipeline — but it solves a narrower problem than Smartlead (reputation only, not real-time prospect replies), and needs a real 30-inbox quote before assuming it's the cheaper of the two paid options.

Deliverability monitoring

Clarifying which system sees what, since it's easy to check the wrong dashboard:

SignalWhere it livesCovers
SES reputation / bounce / complaint rateSES console, admin-status.ts could surface itTransactional mail only (ICP review, digest, meeting alerts) — not the cold sends, those go over Gmail SMTP
Spam rate, domain/IP reputation, auth pass ratesGoogle Postmaster Tools (per verified domain)The actual cold-send signal. Requires a TXT-record domain verification per sending domain.
Reply rate, per-inbox / per-domainAlready computable from the leads table (status counts by sentFromInbox)The most direct in-house signal — no new integration needed, just a query the status dashboard doesn't do yet
IMAP login healthadmin-inboxes.ts test-connection (built, live today)Catches a revoked App Password before it silently breaks sends

Postmaster Tools has a JSON API, but it's OAuth-based — the same complexity tradeoff already declined once when this build moved from Gmail-API-OAuth to SMTP+App-Password. Recommendation: treat Postmaster Tools as a manual check-in per domain for now, and only build the API integration if reply-rate data (which is easy to automate) starts suggesting a specific domain has a problem worth digging into. (Smartlead's own reputation dashboard, or MailReach's Heat Score API, would each cover most of this table without a separate Postmaster integration — see Options B and C above.)

Observability & reliability — AWS-native gaps

An audit of the current stack against what's actually deployed: Lambda, DynamoDB, API Gateway, EventBridge Scheduler, Secrets Manager, SES, Bedrock, S3, Route 53. Grouped by what each addition actually fixes, not just "nice to have."

Alerting — was zero coverage, now built

Worth naming plainly: the Bedrock "use case details" access gate that silently broke sends earlier in this build's history went undetected until manual testing caught it. A CloudWatch Alarm on Lambda Errors would have caught it the moment it started failing.

Built — every core pipeline Lambda (all except the admin-* test-harness functions, whose errors already surface immediately in admin.html) now has a CloudWatch Alarm on its Errors metric (≥1 in a 5-minute window), feeding one SNS topic subscribed to SALES_EMAIL. Requires confirming the SNS subscription email once — see README.

ResourceFixesStatus
CloudWatch Alarms + SNSSilent Lambda failures (the Bedrock-access incident, specifically)Done
CloudWatch DashboardFree aggregate view — invocations/errors/duration per Lambda, DynamoDB consumed capacity/PutItem throttles, API Gateway count/4xx/5xx/latency — no new Lambda code, complements admin-status.ts rather than replacing itDone
API Gateway access loggingZero request-level logging at the API layer today — only whatever each Lambda logs individuallyDone
X-Ray tracingActive tracing on every core Lambda — each function's own execution segments (including its outbound AWS SDK/fetch calls) are now visible per-invocation. Doesn't by itself stitch one lead's full ICP→approve→Clay→send→reply chain into a single trace (that needs explicit trace-ID propagation through DynamoDB records — a bigger lift, not taken here), but is real value for "why was this one invocation slow," which is how the thread-drift/repetition/Bedrock-access bugs were actually diagnosedDone (per-Lambda, not cross-chain)

Reliability & data durability

ResourceFixesStatus
DLQ (SQS) on the EventBridge Scheduler targetsAll five CfnSchedule resources now set deadLetterConfig against one shared queue — if a scheduled invocation fails repeatedly, it lands there instead of just disappearing with no recordDone
DynamoDB Point-in-Time RecoveryEnabled on all four core tables (campaigns/leads/suppression/inbox-pool) — a bad write/delete can now be restored to any point in the last 35 daysDone
DynamoDB TTLEnabled on leads only — a weekly leads-ttl-sweep.ts marks terminal-status rows (sent/replied/meeting_booked/suppressed/bounced/exhausted) older than 2 years for expiry. Deliberately not added to suppression — an unsubscribe/bounce record must never auto-expire, that risks re-emailing someone who opted outDone

Security & delivery

ResourceFixesStatus
CloudFront + ACM in front of the docs S3 bucketThe site — including admin.html, which has the admin-token entry field — used to be served over plain HTTP with a publicly-readable S3 website endpoint. Now: a private (Origin Access Control-only) bucket behind CloudFront, HTTPS-only, custom domain (docs-development.trywifiscanner.com)Done
WAF on the API GatewayTurned out to need a real architecture change to work at all — WAFv2's WebACLAssociation doesn't support API Gateway HTTP API (v2), only REST API/ALB/AppSync/Cognito/etc (caught live: the direct attempt failed with an invalid-ARN error and rolled back cleanly). Fixed by putting CloudFront in front of the API instead — same move as the docs bucket and the ops platform's own dashboard — which doubles as the branded-URL goal below and gives WAF a resource type it actually supportsDone
Systems Manager Parameter StoreA /wifiscanner-sdr/<env>/sending-paused string parameter — flip to "true" and send-scheduler.ts skips every invocation on its next run, no redeploy neededDone

Branded links

Every approval/unsubscribe/booking-adjacent link this pipeline emails now reads as go-development.trywifiscanner.com instead of a bare execute-api.amazonaws.com host — via the same CloudFront-in-front-of-the-API fix that made WAF possible above, not a direct API Gateway custom domain (which can't take WAF).

Architecture-level — bigger lift, deliberately not taken

Step Functions — explicitly declined

The pipeline's state machine stays implicit — DynamoDB status fields plus polling schedules. Asked directly and confirmed: this pipeline currently runs two real, approved campaigns with real sends in flight, and migrating every Lambda's trigger mechanism carries real risk to what's already live. The ops platform's own provisioning workflow already demonstrates Step Functions works well in this codebase when the blast radius is a new, isolated subsystem rather than the live send path itself.

SQS or DynamoDB Streams for personalize→send

Not taken — the existing DynamoDB Streams on leads/inbox-pool feed the ops platform's rollup sync, a different purpose than cutting personalize→send latency. Lower priority at 1.0's single-inbox scale; revisit once real volume approaches the 300/day target.

Test domains — a fictitious provisioning preview

The ops platform's /provision/test page lets you pick a domain (or have Bedrock suggest a few) and click through a simulated provisioning run — registering → DNS → Workspace → inbox → ready — without any real side effect. Deliberately not a variant of the real Step Functions workflow: it never calls Route 53 or the Google Admin SDK, since a domain suggested or typed here was never verified to exist, let alone be owned. Everything it writes stays in the ops platform's own ops-domains/ops-audit-log tables, tagged source: "test-suggested" and shown with a (test) badge — never the pipeline's real inbox-pool table, which is the only one send-scheduler.ts/reply-poller.ts ever read. wifiscanner@trywifiscanner.com stays the only real sender regardless of what's provisioned here.

Operating principles at this scale

Branded links

Unsubscribe and approval links currently point at the raw execute-api.us-east-1.amazonaws.com URL. API Gateway HTTP APIs support a custom domain name directly — an ACM certificate plus a Route 53 alias record pointed at the API's regional domain, no CloudFront required unless caching or a WAF is wanted in front of it later. A subdomain like links.trywifiscanner.com would cover unsubscribe, ICP-approval, and reply-approval links in one change to apiBaseUrl().

Clay & sourcing capacity

Sending capacity (300/day) and Clay's lead-sourcing plan tier are separate constraints — a bigger inbox pool doesn't source more leads by itself. Worth checking Clay's plan limits against the 300/day target independently once inbox scale-up is underway; if sourcing can't keep pace, the extra send capacity just sits idle.

Phasing

PhaseScopeStatus
1Single shared inbox, test-harness form (admin.html), status dashboardDone
2Multi-inbox pool via bulk-paste (this doc's inbox management section)Done
3Additional sending domains, per-domain DNS — superseded by the ops platform's Step Functions provisioning workflow (bigger mechanism than originally scoped here, same outcome). Reply-rate-by-domain reporting: built as part of that platform's Phase D signal-evaluator, not this doc's original planSuperseded — see ops platform
3-BSmartlead trial-account spikeEvaluated, not pursued
3-CMailReach — register inboxes, pull warmup score into admin-status.tsDone (1 inbox connected via UI — no public API for mailbox creation, see README)
4Branded links (CloudFront in front of the API Gateway — not a direct API Gateway custom domain, see Security & delivery above)Done
5Postmaster Tools API integrationStill skippable — MailReach's score already covers most of this gap
6Reliability/observability/security sweep — DLQ, PITR, TTL, CloudWatch Dashboard, API Gateway access logs, X-Ray, WAF, SSM pause-toggle, CloudFront+ACM for docsDone
7Step Functions rearchitecture of the core pipeline's implicit state machineEvaluated, explicitly declined — live campaigns in flight
8Fictitious/test domain-picker preview (AI-suggested or manual domain names, simulated provisioning, zero real side effects)Done