"I don't know how many follow-ups to send." That's the most common thing we hear from founders who've cleaned up their outreach copy and still aren't getting replies. The internet says 5-7 touches. Nobody explains the timing. Nobody shows the code that stops the sequence when someone responds.

Here's the actual sequence we run. Six touches, specific days, and the Python function that handles the stopping logic. Everything you need is in this post.

30-second TL;DR: Most B2B cold email replies come in touches 3-6, not 1-2. The right cadence is 6 touches over 21 days with gaps that grow as the sequence progresses. An 8-line Python IMAP function stops the whole thing automatically when a reply arrives. The $147 Quick Outreach Stack blueprint includes the full send/receive loop as one Python file.

Why 6 Touches? (And Why Most Sequences Die at 2)

Most founders stop at 2 touches because touch 3 feels pushy. Woodpecker's analysis of 8 million B2B cold email sequences shows reply rates peak between touches 3 and 6. Touches 1 and 2 capture the people who were already going to respond. Touches 3 through 6 capture the people who were interested but overwhelmed when your first emails landed.

The count matters less than the spacing. Touch 2 on Day 2 reads as surveillance. Touch 2 on Day 3 reads like a human who followed up. Touch 3 on Day 5 looks like spam. Touch 3 on Day 7 looks like persistence. The difference is 2 days. The signal to the reader is completely different.

Six touches over 21 days is deliberate and visible without being relentless. The sequence below is the exact one we use.

The Full Sequence: 6 Touches Over 21 Days

Touch Day Subject / Frame Wait Until Next
T1 Day 1 Observation-based hook (specific to their situation) +2 days
T2 Day 3 Short bump ("Following up on my note from Monday") +4 days
T3 Day 7 New angle (different value prop, same ICP) +3 days
T4 Day 10 Permission-based ("Worth a reply?") +5 days
T5 Day 15 Social proof ("One of our [ICP type] said...") +6 days
T6 Day 21 Hard close ("Last note from me on this") End of sequence

Here's the rationale for each gap, because "space your follow-ups" is not useful advice:

T1 to T2 (2 days). Inbox recency. You want to land at the top of their unread pile before they've fully cleared from the week. Two days means you're still in the same inbox-clearing window. Three days and you're starting fresh.

T2 to T3 (4 days). The inbox clears between Monday and Friday. A 4-day gap means Touch 3 arrives on a different weekday than Touches 1 and 2. It doesn't feel like the same person blasting every 48 hours.

T3 to T4 (3 days). Touch 4 is a permission-based reset: "Worth a reply?" earns a tighter spacing because the framing is different. You're not re-pitching. You're asking a question they can answer in 4 words.

T4 to T5 (5 days). You're now in persistence territory. The 5-day gap signals you're patient, not frantic. Touch 5 brings social proof, which works best when there's some distance from the hard ask of Touch 4.

T5 to T6 (6 days). The hard close only works if it doesn't feel like it came the day after the social proof email. Six days means the reader has cleared their inbox at least once. "Last note from me" has weight when there's been visible space before it.

The IMAP Reply-Stop Loop (8 Lines That Prevent the Worst Mistake)

The biggest mistake in outreach automation is sending Touch 4 to someone who already replied to Touch 2. It happens because most send loops check for replies once at the start of the sequence, then run the whole thing regardless of what comes in.

The right pattern: check for a reply before every send. Here's the function we use:

import imaplib
import email

def has_replied(imap_conn, contact_email, since_date):
    imap_conn.select("INBOX")
    _, msgs = imap_conn.search(None,
        f'(FROM "{contact_email}" SINCE "{since_date}")')
    return len(msgs[0].split()) > 0

Call it before each touch. If it returns True, skip all remaining touches for that contact and log the reply for routing.

Why SINCE and not UNSEEN

The temptation is to search for UNSEEN messages, but that misses replies the contact sent after you marked your original email as read. Use SINCE with the date of your first touch. That catches any reply in the sequence window, regardless of read state. The search string (FROM "{contact_email}" SINCE "{since_date}") does this correctly.

One gotcha with SINCE: IMAP's date format is DD-Mon-YYYY (e.g., 13-Jun-2026), not ISO format. Pass it as a string in that format or you'll get an IMAP parse error that doesn't surface clearly.

Thread detection: when a reply lands on a different subject line

Some contacts reply to one of your touches but change the subject line. Gmail threads strip "Re:" and match by conversation ID. IMAP doesn't have that luxury. If the reply comes in on a different subject, the FROM search still catches it because you're searching by sender address, not subject. This is intentional.

The one edge case: if the contact has multiple email addresses and replies from a different one. At OperatorIQ's volume (200-500 sends per week), we handle this manually when it comes up. At 10,000 sends per week, you'd want to check a list of known aliases per contact.

Rate-limit math: what 100 sends/hour actually means for a 6-touch loop

If you're sending from Namecheap Private Email, the limit is 100 sends per hour. For a 6-touch sequence across 100 contacts, that's 600 sends total. At 100/hour with a time.sleep(36) between sends (one send every 36 seconds), you clear 100 sends in one hour.

Look at the full picture: if you're sending 1 touch per contact per day across 100 contacts, the sequence takes 6 weeks to complete. That's not a bug. It's the math of running respectful volume outreach from a personal mailbox instead of a bulk ESP. The tradeoff is deliverability: your domain stays warm, your emails land in primary inboxes, and your reply rates reflect it.

One more thing: a clean cadence does nothing if the copy triggers spam filters. Our post on the 13 phrases that get cold B2B emails marked as spam walks through the exact phrases the linter catches on every outreach draft. Fix those before running this sequence.

Three Ways This Breaks in Practice

Not stopping on reply. You send Touch 4 to someone who replied to Touch 2 asking for a call. This isn't just annoying to the prospect. It tells them your outreach is automated and you're not actually reading replies. The IMAP check above prevents it. Run it. Every time.

Subject line drift from Touch 1 to Touch 3. Touch 3 introduces a new angle, but the subject line should still reference something from Touch 1. "Following up on the outreach automation question" is better than starting fresh with "Thought this might be relevant." The reader needs to recognize that Touch 3 is part of the same conversation, not a new cold email from a stranger.

Touching too fast on the first two touches. Day 1 and Day 2 reads as a sequence trigger, not a human. Day 1 and Day 3 reads like someone who sent a note on Monday and followed up Wednesday. The two-day gap is not a mistake. Don't compress it to "I'll follow up tomorrow" just because it feels more urgent from your end.

What This Looks Like in a Running Send Loop

Your outer loop iterates over contacts. For each contact, for each scheduled touch, you call has_replied() first. If it's clean, you call your SMTP sender to send the touch and log the send timestamp. Then sleep for 36 seconds before the next send. The whole loop is around 60 lines.

The Quick Outreach Stack blueprint packages this as one file: SMTP sender, IMAP reply-stop handler, cadence timer, contact deduplication, and send log. No monthly fee. No API key. You bring your contact list and your SMTP credentials.

Next post: the Supabase idempotency pattern for Stripe webhooks. Same principle as the IMAP reply check, applied to payment events rather than email replies.