Skip to content

Cleaning a newsletter list before you hit send

· 6 min read · Email validation

Before you send a newsletter, remove addresses that can't or shouldn't receive it: disposable domains, domains that don't exist or can't receive mail, obvious typos of big providers like gmial.com, system addresses like noreply@, and, if the list wasn't built from confirmed opt-ins, role addresses like info@. Do it with domain-level checks, deduplicated by domain and throttled to your API's limit. Don't hammer recipients' mail servers with SMTP probes to verify each mailbox yourself.

The reason to bother is your sender reputation. Bounces and spam complaints from a dirty list hurt delivery to everyone else on it. Google's email sender guidelines ask senders to keep the spam rate reported in Postmaster Tools below 0.30% (aiming for under 0.10%), to confirm addresses before subscribing them, and to automatically unsubscribe recipients after multiple bounces.

What to remove, and why

Remove How to detect Always?
Disposable domains Domain is on a disposable list Yes. The inbox probably no longer exists.
Domains that don't exist DNS: NXDOMAIN Yes
Domains that can't receive mail DNS: no MX and no A record Yes
Typos of major providers Small typo map (gmial.com, hotmial.com) Suppress; don't rewrite
System addresses noreply@, mailer-daemon@, postmaster@ Yes. Nobody reads them.
Role addresses info@, sales@, support@ Only for imported or unconfirmed lists
Previous hard bounces, unsubscribes, complaints Your email provider's suppression list Yes, and your provider usually does this already
Duplicates Lowercase and compare Yes

Two rows need judgment.

Role addresses. RFC 2142 lists common mailbox names, from business ones like info and sales to operational ones like postmaster and abuse. The operational ones are never newsletter subscribers. The business ones can be: a small business may use info@ as its only inbox. If that address clicked a confirmation link, keep it. If it arrived in a CSV from an event sponsor, remove it. Role-based email addresses goes into this in more detail.

Typos. Rewriting jane@gmial.com to jane@gmail.com and mailing it means sending to an address that never subscribed. Suppress the typo row instead. If you run double opt-in, these addresses never confirmed anyway, so they shouldn't be on the active list in the first place. Note that DNS won't always catch typos: some typo domains are registered and publish MX records, so a typo map is still useful alongside the DNS check.

Why not SMTP-verify every address yourself

It's tempting: connect to each domain's mail server, issue RCPT TO, and see whether it says the mailbox exists. In practice:

  • Many servers accept every address (catch-all), so "valid" means nothing.
  • Greylisting and rate limits return temporary failures that look like invalid addresses.
  • Large providers don't reliably reveal whether a mailbox exists.
  • Thousands of probes from your own IP look like a directory harvest attack, and can get that IP onto blocklists that then hurt your real sending.

We wrote up the details in why SMTP mailbox verification is unreliable. For a newsletter, the better tools are confirmed opt-in at signup and your email provider's bounce handling after each send. If a paid verification service fits your budget, it at least runs probes from its own infrastructure, not yours.

Dedupe by domain, then throttle

A newsletter list usually has far fewer unique domains than addresses, since a large share sits on a few webmail providers. Look up each domain once and apply the result to every address on it. Disposable status and DNS facts are properties of the domain, so this loses nothing.

pingutil allows 60 requests a minute per key, so a list with 3,000 unique domains takes about 50 minutes. With skip_enrichment: true you skip the company lookup you don't need, and check_dns: true adds domain_exists and can_receive_email. DNS results are cached for 7 days on our side, so re-running before the next send is quick for domains already seen.

The script

Python 3.10+ with requests. Input is a CSV with an email column; output is send.csv and removed.csv with a reason per row.

# clean_newsletter.py
import csv
import os
import sys
import time
import requests

API = "https://pingutil.com/v1/lookup"
KEY = os.environ["PINGUTIL_KEY"]
LIST_IS_CONFIRMED_OPT_IN = True  # set False for imported or bought lists

TYPOS = {"gmial.com", "gmai.com", "gamil.com", "hotmial.com", "hotmal.com",
         "yaho.com", "yahooo.com", "outlok.com", "iclod.com"}
SYSTEM = {"noreply", "no-reply", "donotreply", "mailer-daemon", "postmaster", "bounce", "abuse"}
ROLE = {"info", "admin", "support", "sales", "hr", "billing", "contact", "webmaster", "marketing"}

def lookup_domain(domain: str) -> dict | None:
    for _ in range(3):
        r = requests.post(API, headers={"Authorization": f"Bearer {KEY}"},
                          json={"input": domain, "skip_enrichment": True, "check_dns": True},
                          timeout=20)
        if r.status_code == 429:
            time.sleep(r.json().get("retry_after_seconds", 60))
            continue
        if r.status_code == 400:
            return None  # invalid or reserved domain
        r.raise_for_status()
        return r.json()
    return None

rows = list(csv.DictReader(open(sys.argv[1], newline="")))
seen, keep, removed = set(), [], []
domains = {}

for row in rows:
    email = row["email"].strip().lower()
    if email in seen or "@" not in email:
        removed.append({**row, "reason": "duplicate_or_malformed"})
        continue
    seen.add(email)
    local, domain = email.rsplit("@", 1)
    local_base = local.split("+", 1)[0]

    if domain in TYPOS:
        removed.append({**row, "reason": "provider_typo"}); continue
    if local_base in SYSTEM:
        removed.append({**row, "reason": "system_address"}); continue
    if local_base in ROLE and not LIST_IS_CONFIRMED_OPT_IN:
        removed.append({**row, "reason": "role_address"}); continue

    if domain not in domains:
        domains[domain] = lookup_domain(domain)
        time.sleep(1.05)  # stay under 60 requests/min
    data = domains[domain]

    if data is None:
        removed.append({**row, "reason": "invalid_domain"}); continue
    if data["category"] == "disposable":
        removed.append({**row, "reason": "disposable_domain"}); continue
    dns = data.get("domain_signals") or {}
    if dns and not dns.get("domain_exists"):
        removed.append({**row, "reason": "domain_does_not_exist"}); continue
    if dns and not dns.get("can_receive_email"):
        removed.append({**row, "reason": "no_mail_server"}); continue
    keep.append(row)

def write(path, data, extra=()):
    if not data:
        return
    fields = list(rows[0].keys()) + list(extra)
    with open(path, "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        w.writerows(data)

write("send.csv", keep)
write("removed.csv", removed, extra=("reason",))
print(f"{len(keep)} to send, {len(removed)} removed, {len(domains)} domains looked up")

A few notes on what it does and doesn't do:

  • It only sleeps after a real API call, so the run time is driven by unique domains, not list size. The local checks (typos, system and role addresses) never cost a request.
  • Bare-domain lookups don't return email_signals, so this won't flag machine-generated local parts. For a newsletter that's usually fine, because random-looking addresses that went through double opt-in belong to someone who confirmed. If you import an unconfirmed list, look up full addresses for the free-webmail rows and check verdict.is_spam.
  • can_receive_email treats a domain with a null MX record as mail-capable, so a few domains that explicitly refuse mail will pass. Your provider's bounce handling catches those on the first send.
  • Exact-match dedupe only. Whether jane.doe@gmail.com and janedoe@gmail.com are the same subscriber is a separate decision.

For a longer version with resumable runs and keep/review/drop files, see cleaning an email list from a CSV with a short script. If you want to understand what the DNS check is really testing, checking MX records explains it.

Before you hit send

  1. Export the list and run the domain checks. Review removed.csv by reason, not row by row.
  2. Decide on role addresses based on how the list was built, not by default.
  3. Import removals into your provider as suppressions, so they don't come back on the next import.
  4. Make sure one-click unsubscribe works and the unsubscribe link is visible in the body.
  5. After the send, look at bounces by domain. A cluster on one domain usually means a dead domain or a typo the map missed.

The domains in the disposable bucket come from the community list we sync weekly; you can browse them on the disposable domains page.

Questions people ask

Should I remove role addresses like info@ from my newsletter?
It depends on how they got there. If info@ confirmed a double opt-in, someone is reading that inbox and asked for your email, so keep it. If the list was imported, scraped or bought, remove role addresses, since nobody in particular agreed to receive mail there.
Should I run SMTP verification on my whole list before sending?
We don't recommend doing it yourself. Probing mail servers from your own infrastructure can get your IPs flagged, many servers accept every address or greylist unknown senders, and the results are often inconclusive. Rely on domain-level checks, confirmed opt-in and your provider's bounce handling.
How often should I clean a newsletter list?
Run the domain checks before any send to a segment you haven't mailed in months, and on every import. For a list you mail weekly, your email provider's automatic bounce and complaint suppression does most of the ongoing work.