DEV Community

Cover image for Cold Email to Local Businesses: Check This Before You Hit Send
Galactic
Galactic

Posted on

Cold Email to Local Businesses: Check This Before You Hit Send

Your code doesn't ship without a linter. Here's the sending-domain setup and the pre-send checks that keep cold email to local businesses from costing you your domain.

You can spot a local business's broken website in about ten seconds: the expired SSL certificate, the contact form that posts to nowhere, the menu that still says 2019. For a freelance developer, that's a warm lead sitting in plain sight.

Writing to them is the easy part. What catches developers out is everything around the email. Google's sender guidelines, in force since 2024, require every sender to authenticate with SPF or DKIM. Anyone sending 5,000 or more messages a day to Gmail addresses needs SPF, DKIM and DMARC, one-click unsubscribe, and a spam complaint rate under 0.3%. Most freelancers never get near 5,000 a day, but the same filters are judging every message they send.

So the risky part of cold email isn't the copy. It's the batch you send from your main domain at 11 p.m. with a merge field that didn't merge. We'd never ship code that way. Why do we ship email that way?

Why "just be careful" fails

The typical freelancer setup is a spreadsheet, a template, and either a Gmail tab or a mail-merge add-on. It works right up until it doesn't:

  • One domain for everything. Client invoices, support replies and cold outreach share a reputation. Burn it with outreach, and your invoices land in spam too.
  • The template is checked, the output isn't. Hi {{first_name}} looks fine in the editor. The fortieth rendered email, where the scraper found no name, is the one that goes out broken.
  • Memory as a database. Who did you email last month? Who asked you to stop? If the answer lives in your head or a sheet you didn't open, someone gets emailed twice.
  • Provider terms nobody read. Every major email provider restricts cold outreach in its acceptable-use policy, and Google can suspend a whole organisation's account for spam, not just the mailbox that sent it.

None of these are copywriting problems, so better copywriting doesn't fix them.

Treat email like a deploy

The shift is to stop thinking about "sending emails" and start thinking about "shipping a batch". A batch gets built, checked and then released, and a failed check blocks the release.

Step 1: give outreach its own sending domain

Send from a separate domain or subdomain, never your main one, and publish its authentication records before the first email:

; SPF: which servers may send for this domain (use your provider's include)
send.yourstudio.dev.         TXT  "v=spf1 include:_spf.google.com ~all"

; DMARC: start with p=none to collect reports, tighten once they're clean
_dmarc.send.yourstudio.dev.  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@yourstudio.dev"
Enter fullscreen mode Exit fullscreen mode

Swap the SPF include for your provider's: _spf.google.com for Google Workspace, spf.protection.outlook.com for Microsoft 365, or whatever your sending service documents. DKIM is a key your provider generates for you to publish.

Then check it from code, not by eyeballing a DNS panel:

import { resolveTxt } from "node:dns/promises";

async function hasRecord(name: string, prefix: string) {
  try {
    return (await resolveTxt(name)).some((chunks) => chunks.join("").startsWith(prefix));
  } catch {
    return false;
  }
}

console.log("SPF:", await hasRecord("send.yourstudio.dev", "v=spf1"));
console.log("DMARC:", await hasRecord("_dmarc.send.yourstudio.dev", "v=DMARC1"));
Enter fullscreen mode Exit fullscreen mode

Step 2: lint the rendered batch, not the template

Run your checks over the emails exactly as they'll be sent. Every check is a rule, and any failure holds the whole batch:

type Email = { to: string; subject: string; body: string; country?: string };

// Opt-in required before a first email, or no mailing addresses collected from the web.
const OPT_IN_FIRST = new Set(["DE", "ES", "PL", "NL", "SG"]);

function lintBatch(batch: Email[], sent: Set<string>, doNotContact: Set<string>, postalAddress: string) {
  const problems: string[] = [];
  const seen = new Set<string>();
  if (batch.length === 0) problems.push("empty batch: nothing was checked");

  for (const email of batch) {
    const to = email.to.trim().toLowerCase();
    const text = `${email.subject}\n${email.body}`;
    if (/\{\{.*?\}\}/.test(text)) problems.push(`${to}: leftover placeholder`);
    if (!/unsubscribe/i.test(email.body)) problems.push(`${to}: no unsubscribe link`);
    if (!email.body.includes(postalAddress)) problems.push(`${to}: no postal address`);
    if (sent.has(to) || seen.has(to)) problems.push(`${to}: already emailed`);
    if (doNotContact.has(to)) problems.push(`${to}: on the do-not-contact list`);
    if (email.country && OPT_IN_FIRST.has(email.country)) problems.push(`${to}: opt-in country`);
    seen.add(to);
  }
  return { verdict: problems.length ? "HOLD" : "SEND", problems };
}
Enter fullscreen mode Exit fullscreen mode

Note the empty-batch line. Without it, every check passes on zero emails and you get SEND having examined nothing. I learned that one the hard way while building a send gate.

The template is checked, the output isn't. Lint the batch you're actually about to send.

Step 3: make the dangerous rules unconfigurable

Unsubscribes, bounces and opt-in countries aren't judgement calls, so don't give yourself a flag to skip them. The day you'd reach for --force is the day you're in a hurry.

If you'd rather not maintain it

That script is the core of the idea, and it's yours to use. Keeping it alive is the tedious part: the sent log, the suppression list, the per-country rules, and finding the businesses in the first place.

I built Galactic Outreach to be that pipeline in one place. You pick an industry and a city, and it finds matching businesses from their own websites and OpenStreetMap (it doesn't sell a contact database). It writes a personalised email to each one from your template, then runs up to 29 checks over the whole rendered batch. If a blocking check fails, the batch is held and the check is named. Every check is listed in the docs.

It sends through your own provider: Resend, Amazon SES, Mailgun, SendGrid, or your own Microsoft 365 or Google Workspace mailbox. So the reputation you build is yours. Read your provider's policy first, though, because some, Resend included, forbid cold outreach outright.

It isn't for high-volume teams rotating many mailboxes, or for anyone who needs named decision-makers. Instantly or Apollo fit those better.

What changes in practice

Nothing magical, which is the point. Before, you'd find the broken merge field when someone replied "Hi {{first_name}}". After, the batch is held on the review screen with the line that failed. Nobody gets the same email twice, because every contact is logged. Unsubscribes and bounces never come back. Leads in opt-in countries are never sent to.

A connected Google Workspace or Microsoft 365 mailbox is capped at 50 emails a day, about 250 in a working week. That's a steady pace for personal outreach to local businesses, and it isn't a blast.

What no tool can honestly promise is replies or inbox placement. What a gate removes is the category of mistake you can't take back.

Wrapping up

Cold email goes wrong before it's sent more often than after. Give it its own domain, lint the rendered batch, and make the dangerous rules impossible to switch off. The script above gets you most of the way.

If you'd rather have that pipeline built and maintained for you, Galactic Outreach is free to start with no card, and paid credits are bought once, not on a subscription.

What's the worst thing that ever slipped into one of your outgoing emails? And which check would have caught it?

Top comments (0)