Part 2 of my cold-lead pipeline: automated outreach and selective CRM promotion

Hey everyone,

In my last post I shared a cold-lead pipeline: it turns a Google Maps scrape into a scored, deduplicated table of cold leads and spends a paid Apollo reveal credit only on the contacts worth it. A few people asked the obvious next question: okay, now how do you actually email them? This is that half.

A cold-lead table does nothing sitting there. But working it has two traps. First, one template blasted to hundreds of businesses reads cold and all lands the same, and identical content is exactly what spam filters look for. Second, the moment someone replies, bounces, or unsubscribes, you have to stop, or you become the thing everyone hates. So the outbound side is less about sending and more about writing well and knowing when to stop.

Here is how it works.

What it does

Three workflows sit on top of the same Postgres database the lead pipeline fills:

  • Enroll. On a schedule it picks your best unworked leads (verified email, ICP score above your floor, not already enrolled, not suppressed) and lines them up. It only takes a handful per day, so a fresh sending domain warms up instead of getting flagged.
  • Send. For each lead whose next message is due, a model writes the email fresh from that business's real details and the step's intent, in your voice. ListMonk and Mailgun deliver it. It only sends inside the hours you choose, never more than your daily cap, and walks a 0 / 3 / 5 day sequence.
  • Listen. A reply stops that lead's sequence and hands the contact to your CRM. A bounce, spam complaint, or unsubscribe stops the sequence and adds the address to a permanent do-not-email list.

Cold leads stay out of your CRM

This is the point of the whole design. The cold-lead table is a staging area, not your contact list. These businesses never asked to hear from you, and pouring hundreds of them into your CRM pollutes it with dead records and makes your pipeline reporting meaningless. So nothing auto-syncs. A lead becomes a CRM record only the moment it replies, which is the moment it stops being cold. You manage the cold leads in the pipeline; only the real, engaged contacts ever cross into your CRM.

What you need

Nothing is bundled; you bring your own accounts and attach your own credentials, same as the lead pipeline.

  • Postgres. The same database the lead pipeline writes to. The included SQL file adds four tables: the outreach state, the send log, a suppression (do-not-email) list, and an optional settings table.
  • ListMonk. Self-hosted and open source. It holds your subscribers and owns the one-click unsubscribe and the open/click tracking, so you are not hand-rolling compliance in a workflow.
  • Mailgun. Delivery. It sends the mail, handles your sending domain's SPF, DKIM, and DMARC, and emits the bounce and complaint events the third workflow listens for. ListMonk relays through it.
  • An LLM API. Anthropic by default, called over plain HTTP, so it swaps to any provider by editing one node. This is the piece that writes each email.
  • An IMAP inbox. The address you send from. The events workflow watches it so a reply instantly stops that lead's sequence.
  • A CRM. Where a lead goes once they reply. It ships as a generic webhook out of the box, with notes for wiring HubSpot, Pipedrive, or Airtable directly.

How it is built

Enroller. A schedule trigger, then a Config node that holds this stage's settings: the campaign name, the minimum ICP score a lead has to clear, how many new leads to enroll per day, and your ListMonk connection. It reads the same settings from a Postgres table as well, and the table wins when it has a row, so you can change your daily volume straight from the database without opening the workflow. Then a query finds the ready leads, caps the batch to what is left of today's enrollment budget, upserts each into ListMonk, and writes it into an outreach-state table at step 0.

Sender. This is the part that actually emails people, and the part I spent the most time on. It loads the config, checks how many sends are left today and whether the clock is inside your window, and if not, it stops. If it is clear, it grabs the due leads, and for each one it builds a short brief (the lead's facts plus the step's intent line), hands that to the model, which returns a subject and body as JSON, sends it through ListMonk, logs it, and schedules the next touch. A send failure drops to a separate node that leaves the step where it is so the next run retries.

Events. Two entry points, one goal: stop emailing that person. An IMAP trigger on your sending inbox catches replies, matches the address to a lead, stops the drip, and posts the contact to your CRM. A Mailgun webhook catches bounces, complaints, and unsubscribes, and adds the address to the suppression list that both other workflows check before they ever enroll or send.

Why it is six workflows, not one

Across both parts this is six separate workflows: three to build the lead table (scrape, enrich, reveal) and three to work it (enroll, send, listen). That is deliberate, and it is the decision I would defend hardest, so it is worth a minute.

Each workflow does exactly one job, and it does it on its own clock. The enroller runs hourly. The sender runs every few minutes, but only inside your send window. The event handler is not scheduled at all; it wakes on an inbound reply or a Mailgun event. Those are three different triggers with three different cadences. Fold them into one workflow and you are immediately fighting the trigger, because a single schedule cannot be hourly and every few minutes and also whenever an email happens to arrive.

They also do not call each other. They talk through the database. The enroller writes a lead into an outreach-state table; the sender reads whatever is due from that table; the event handler flips a lead's status when a reply or a bounce comes in; and both the enroller and the sender check a shared suppression list before they ever act. The state in Postgres is the interface between them, which means each one is replaceable on its own. You could rewrite the sender from scratch, and as long as it reads due leads and writes to the send log, nothing else has to know.

The payoff is blast radius. If the model call or Mailgun has a bad moment, that failure is contained in the sender. Enrollment keeps lining up leads, replies keep getting caught and handed to your CRM, and the sender just retries the stuck step on its next run. One piece breaking does not stop the other five. It also makes the thing debuggable: every workflow has its own execution history, so when something is off you know exactly which stage to open, and you can test the sender against a single hand-enrolled lead without running enrollment at all.

The same instinct shows up inside each workflow. The sender's send step has its own error path: a failure drops to a node that logs it and leaves the step where it was, so the lead retries cleanly instead of getting skipped or emailed twice. The model call has a fallback: if the JSON ever fails to parse, a plain deterministic message still goes out. Small blast radius at the workflow level, small blast radius at the node level.

The cost is honest. There are more moving parts to import and wire, and the logic lives in the shared table contract rather than in one file you can read top to bottom. For a throwaway script that would be over-engineering. For something that emails real people every day and has to stop on a dime the second one of them replies, I will take six small, boring, independently debuggable workflows every time.

The two decisions that made it good

A model writes each email, not a template. This is the whole reason it does not read like cold outreach. You give the model your offer and your voice once, in the Config node, and it personalizes every send from only the real facts about each business, no invented details. It returns strict JSON, and if a parse ever fails there is a plain deterministic fallback so a touch still goes out. The model is one HTTP node, so swapping providers or models is a one-node change.

A clear division of labor. n8n owns the sequence: eligibility, timing, step advancement, the reply-stop, the throttle. ListMonk owns compliance: it holds the subscribers, the one-click unsubscribe, and the tracking. Mailgun is pure transport and emits the bounce and complaint events. n8n never talks SMTP directly. That split keeps unsubscribe handling out of my workflow logic and makes the whole thing swappable: any SMTP provider behind ListMonk, any transactional sender in place of ListMonk.

On deliverability

Worth saying plainly, because it is easy to skip: cold email is a deliverability game, not a volume game. The defaults reflect that. A dedicated warmed sending subdomain, never your primary domain. Low throttled volume in business hours. One-click unsubscribe, and suppression enforced before every enroll and send. B2B, and check your provider's policy before your first real send. The workflows make the compliant path the default; they do not make the lawful basis in your jurisdiction someone else's problem, that part is on you.

Reusing it

It reads from a table of verified-email leads, so it is not locked to the Apollo pipeline; point it at any Postgres table in that shape. The CRM handoff ships as a generic webhook so it works with anything out of the box, and I left notes for wiring HubSpot, Pipedrive, or Airtable directly. Same swappable-model point as above.

Open source (MIT), sitting right next to the lead pipeline in the same repo: github.com/Mfrostbutter/n8n-workflow-templates

Happy to break down any piece: the model brief and the JSON parse with its fallback, the send-window and daily-cap math, the suppression logic, or the IMAP reply matching. Say the word in the thread.

Cheers,

Michael

2 Likes

The decoupled three-workflow architecture is solid - having Enroller, Sender, and Events as independent flows sharing Postgres state is much cleaner for debugging than a single monolith. One thing worth adding to the Sender: a per-domain send rate check before dispatching. Group leads by root domain (e.g., company.com) and enforce a minimum gap between sends to the same domain - some large companies run centralized spam filtering across all inboxes and back-to-back sends to different people at the same company can still trip them. A simple Postgres query for last_sent_at WHERE domain = X in the Enroller’s scoring step handles it before the lead even enters the queue.

1 Like