01Fail-closed send gate
can_send() is a pure function with no I/O. The service layer resolves every fact first — suppression, rate limit, domain auth, global pause, lawful basis — and the domain layer only decides. A lead's country selects a rule from a table of CAN-SPAM, CASL, PECR-UK, GDPR-PECR and Australian rules; consent-requiring regions refuse unless the operator has affirmed a lawful basis, and an unknown country falls to a conservative baseline rather than the permissive one. I store suppression entries as sha256 hashes, so an opt-out survives the lead record being deleted.
02Incapable of sending
I ran the parallel build behind BUILD_MODE=true, defended three times over. The adapter factories return the mock branch first and unconditionally. The real senders raise from their own constructors. I lazy-import the network SDKs inside method bodies rather than at module level, so no real host is reachable from an import. Deliberately redundant, so nobody could email a real business by accident while I was building it.
03A review is hostile input
Sample Google reviews are attacker-controllable text heading straight into an LLM prompt. I wrap each snippet in a labelled untrusted_review block, cap its length, and defang any attempt to forge or close that delimiter by swapping the angle brackets for full-width look-alikes. Then I check the output rather than trust it: every standalone number in the generated subject and body is matched against the lead's actual facts and stripped if it isn't grounded, and I exclude URL and path tokens first so a mockup id's digits aren't read as a claim.
04The mockup is the wedge
A separate Node and Playwright service renders six category-specific templates — restaurant, salon, clinic, retail, trades, generic — from listing data into genuine HTML and PNG, hosted as a live preview link and screenshotted for the email call to action. I built the PDF proposal path but never switched it on. I gave the renderer one hard invariant: it never fetches a lead's own assets server-side, which keeps SSRF out of the rendering path entirely.
05Cut along the real seam
An audit found the engine was already vertical-blind — scraping, dedup, suppression, sequencing, deals and analytics don't care what you're selling. The website assumption lived in four places. I extracted the load-bearing one, the need-signal detector, into a NeedSignalDetector protocol with a cheap pure prescreen and a separate expensive-I/O check, and the original website classifier became plugin number one with byte-identical behaviour. SEO-audit, social-media and generic detectors register beside it. I have declared the fact schema, prompt pack and artifact generator as seams but not yet wired them, so pitch copy stays web-design-specific for every vertical.