GTM Engineering
Lint Your Outbound Before It Lints Your Domain
AI slop reads fine to the sender and burns the domain by the thousand. Watch reputation fall off a cliff as the complaint rate climbs, then build the regex lint gate that catches slop before it ships, tied to the 0.30% cap.
· 13 min read
The spam complaint cap is 0.30% and you are meant to never touch it. Gmail and Yahoo want you under 0.10%. Do the arithmetic on what that permits: at 0.30%, three complaints per thousand sends ends you. Send 10,000 AI-drafted emails that all open with “I hope this email finds you well” and “I came across your company,” and you do not need thirty spam reports to trip the wire, you need thirty across the whole send. The model wrote something grammatical, the sender skimmed it, it read fine, and it shipped. Grammatical clears a low bar. The bar that matters is whether this reads like a machine sent it to ten thousand people, because Gmail’s filter is trained to answer exactly that question.
The old habit was to trust the draft. Write a decent prompt, let the AI SDR run, spot-check a few, and assume grammatical means safe. Break that habit by watching what happens to the domain. Reputation does not erode gently as slop accumulates. It holds, holds, holds, and then falls off a cliff the moment the complaint rate crosses the line the mailbox providers enforce. Scroll the curve below: inbox placement against a rising complaint rate as an unsupervised model keeps sending. The drop is not a slope you can manage. It is a wall you hit.
I have watched a warmed, aged domain go from a 50% inbox rate to junk in under two weeks because an AI SDR ran unsupervised and every email carried the same three tells. The fix is not “write better prompts,” though you should. The fix is a linter: a set of rules that reads every draft before it sends and refuses the ones that smell like slop. Prompts are a suggestion to a model. A lint gate is a wall. You ship the wall, and the wall keeps you on the left side of that cliff.
Slop is detectable because it is repetitive
The reason AI slop is lintable at all is that models fall back to the same phrases. Ask a hundred models for a cold open and a huge share start with a hope-you-are-well or an I-came-across. That predictability is the vulnerability and the defense. A human writes “saw you just closed the Acme integration” once and never exactly again. A model writes “I hope this email finds you well” across your entire list, and a filter that sees the same opener ten thousand times from one domain does not need to read the body. The repetition is the signal it grades you on, and repetition is exactly what a regex is built to catch.
So the linter counts tells rather than judging quality in some abstract sense: the phrases, tokens, and structural shapes that mark a draft as machine-generated at volume. Each rule is a heuristic with a real regex behind it, not a vibe. Here is the tell list, scored:
| Tell | What it catches | Severity |
|---|---|---|
| Generic hope-opener | ”I hope this email finds you well” and variants | Block |
| Discovery cliche | ”I came across / stumbled upon your company” | Block |
| Broken merge token | Literal {{first_name}} or [COMPANY] survived to send | Block |
| Over-personalization stack | 3+ merge tokens crammed into one sentence | Warn |
| Em-dash density | More than 1 em dash per 40 words (a model tell) | Warn |
| LLM connective tissue | ”Moreover”, “Furthermore”, “In today’s fast-paced” | Warn |
| Length overrun | Body over 125 words (50 to 125 beat 200+ by 2.3x, Woodpecker) | Warn |
| Empty personalization | First line names the company and says nothing specific | Warn |
Source: tell severities from my own pre-send gates; the length band is Woodpecker’s 26,000-campaign analysis, the same study that puts spray-and-pray reply rates at 1% to 3% and signal-triggered outbound at 10% to 20%. The Block rows never send. The Warn rows route to a human queue. Nothing about this is exotic. It is a spellchecker for the specific way machines give themselves away.
The rules, as actual rules
A checklist you have to remember is a checklist you will skip at 4pm on a Friday send. So the rules live in code and run on every draft. Here is the lint core, patterns plus a scorer. Note the em-dash counter uses a unicode escape rather than a literal dash, so the rule file itself never smuggles the character it is hunting for:
import re
EM_DASH = chr(8212) # the em dash character (U+2014) the rule hunts for
# BLOCK rules: any hit refuses the send outright.
BLOCK = {
"generic_opener": re.compile(
r"\b(i hope (this|you)|hope you'?re doing (well|great)|"
r"hope this (email|message) finds you)\b", re.I),
"discovery_cliche": re.compile(
r"\b(i (came across|stumbled (up)?on)|"
r"i noticed (that )?(you|your company))\b", re.I),
"broken_token": re.compile(r"(\{\{.*?\}\}|\[[A-Z_]{2,}\]|\bfirst_?name\b)", re.I),
}
# WARN rules: hit routes the draft to a human review queue, does not block.
WARN = {
"llm_connective": re.compile(
r"\b(moreover|furthermore|in today'?s (fast[- ]paced|digital)|"
r"in conclusion|delve into|leverage|seamless(ly)?)\b", re.I),
"empty_personalization": re.compile(
r"^(hi|hey|hello)\b.{0,40}\bat [A-Z][a-z]+\b.{0,30}[.!]", re.I),
}
def lint(subject: str, body: str) -> dict:
text = f"{subject}\n{body}"
words = max(1, len(body.split()))
hits = {"block": [], "warn": []}
for name, rx in BLOCK.items():
if rx.search(text): hits["block"].append(name)
for name, rx in WARN.items():
if rx.search(text): hits["warn"].append(name)
# density heuristics, expressed as ratios so they scale with length
em_dashes = text.count(EM_DASH)
if em_dashes / words > (1 / 40): hits["warn"].append("em_dash_density")
token_count = len(re.findall(r"\{\{.*?\}\}", text))
if token_count >= 3: hits["warn"].append("over_personalization")
if words > 125: hits["warn"].append("length_overrun")
hits["verdict"] = "block" if hits["block"] else ("warn" if hits["warn"] else "pass")
return hits
The regexes are deliberately boring. Boring is the point: they are cheap to run, they run on every draft, and they never get tired at the end of a send. A model will happily write the same hope-you-are-well opener for the ten-thousandth time. The linter will refuse it the ten-thousandth time exactly as fast as the first.
The gate sits between draft and send
Rules do nothing until something enforces them. The gate is the enforcement: every draft, whether a human or a model wrote it, passes through the linter before it can enter the sending queue. Block means it never sends. Warn means it lands in a human review queue. Pass means it goes. No draft reaches the mail server without a verdict.
In practice the gate is a step in the sequence platform or an HTTP call before the send. Here is the enforcement wrapper, so the gate is an artifact and not an intention:
def presend_gate(draft, sequence_id):
result = lint(draft["subject"], draft["body"])
if result["verdict"] == "block":
# never hits the mail server. log the tell and stop.
log_block(sequence_id, draft["id"], result["block"])
return {"send": False, "reason": result["block"]}
if result["verdict"] == "warn":
route_to_human_queue(draft, reasons=result["warn"])
return {"send": False, "reason": "queued_for_review"}
return {"send": True}
# wire it as the last hook before enqueue. no draft skips it.
What the gate does to the complaint rate
The reason this is worth building rather than trusting to prompts is arithmetic, and the arithmetic is the same cliff you scrolled at the top. Slop and specificity differ in more than reply rate; they differ in complaint rate, and the complaint rate is the number that decides which side of the cliff your domain lives on. Spray-and-pray slop replies at 1% to 3% and generates complaints; signal-triggered, specific outbound replies at 10% to 20% and generates almost none (Woodpecker, 26,000 campaigns). The gate does not only lift replies. It keeps you under the 0.30% cap that keeps you in the inbox at all.
View as table
| Item | Value |
|---|---|
| Ungated slop | 0.4% |
| Warn-queued | 0.1% |
| Gated + specific | 0.0% |
The three bars reconcile to the curve at the top of this piece. The ungated bar sits at 0.42%, off the right edge of the cliff, where inbox placement is 6% and the domain is effectively junk. The warn-queued bar at 0.14% sits just past the 0.10% target, in the sliding zone. The gated bar at 0.04% keeps you at 92% placement, the flat left side of the curve where reputation holds. Same list, same domain, same product. The only difference is a linter refusing the drafts that read like a machine wrote them at scale, which is what pins you to the safe end of the curve instead of letting the complaint rate walk you off the edge.
Before and after, on one draft
Here is what the gate does to a single real-shaped email. The before is what an unsupervised model produced. The after is what passed the linter.
| Slop (blocked) | Passes the gate | |
|---|---|---|
| Opener | "I hope this email finds you well." | "Saw the Snowflake migration post on your eng blog." |
| Reason for reaching out | "I came across your company and..." | "Teams mid-migration usually hit our exact problem in week 3." |
| Personalization | Hi {{first_name}} at {{company}} in {{industry}} | One fact, no visible tokens |
| Length | 190 words, three paragraphs | 58 words, one ask |
| Lint verdict | BLOCK: generic_opener + discovery_cliche | PASS |
| Complaint risk | Above 0.30% at volume | Under 0.10% |
The after is shorter, not more clever: it names one thing the sender saw and makes one ask. That is the whole difference between something a filter reads as human and something it reads as a cannon. Specificity beats personalization, and the linter’s real job is to refuse the cosmetic personalization that pretends to be the real thing. For the upstream version of this discipline, where you decide whether to send at all, see signal-based outbound.
The build order: rules, gate, then the loop that keeps them honest
Six steps, in order. The first three are the linter and the gate. The last three are what keeps it honest over time, because a static rule set rots the moment a model learns a new cliche.
- 1
1. Seed the block and warn lists from real tells
Start with the generic opener, the discovery cliche, and the broken merge token as blocks. Add LLM connectives and empty personalization as warns. Pull your seed phrases from your own worst-performing sends, not a generic list. Your slop has a local accent.
- 2
2. Add the density heuristics
Em-dash-per-word, token-count-per-sentence, and body length. These catch the slop that dodges the phrase list. Express them as ratios so they scale with email length instead of firing on every long email.
- 3
3. Wire the gate as the last hook before enqueue
No draft reaches the mail server without a verdict. Block refuses and logs the tell. Warn routes to a human queue. Pass sends. Make it impossible to skip, because the send you skip it on is the one that burns you.
- 4
4. Sample the human queue, do not clear it blind
The warn queue is your training data. A reviewer reading warned drafts finds the new tells your rules missed. Every override is a candidate for a new rule. Clearing the queue without reading it turns a gate into theater.
- 5
5. Watch complaint rate in Postmaster, not only replies
Google Postmaster Tools shows your complaint rate against the 0.30% line for free. That is the number the gate protects. Alert when it crosses 0.10% and freeze the sequence that moved it before it reaches 0.30%.
- 6
6. Refresh the rules monthly
Models learn new cliches and yours will too. Once "I came across" gets linted everywhere, the next generic opener takes its place. Review the block list monthly against the previous month's human-queue overrides. A frozen rule set is a decaying one.
The reason to build the linter rather than trust the prompt is that a prompt is advice and a gate is enforcement. A model asked nicely to avoid cliches will avoid them most of the time, and most of the time is not the standard when three complaints per thousand ends your domain. The linter does not get tired, does not skip the Friday send, and does not decide this one is fine. It reads every draft, refuses the ones that smell like slop, and keeps the complaint rate on the flat left side of the curve instead of the cliff on the right. Build the wall. The prompt was only ever a suggestion.
Keep reading
One email. Every week.
One email a week: a system I built or broke, with the config, the numbers, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.
The newsletter opens soon.
Connect a provider in src/config.ts