The Counteroffensive: Automated Spam Reporting with Spamhaus A developer created a Python script that automates spam reporting to Spamhaus by scanning a Junk folder via IMAP, extracting sending IPs, domains, and URLs from authenticated spam messages, and submitting them to Spamhaus's REST API. The script uses a custom IMAP keyword flag to track processed messages and includes a capability test to ensure the server supports custom keywords before running. This automation enables continuous reporting of spam infrastructure that evades standard email authentication filters like SPF, DKIM, and DMARC. How to go from finding spam in your inbox to automatically reporting the infrastructure behind it In my previous article https://dev.to/battlehardened/why-your-email-is-an-open-door-for-spammers-and-how-to-lock-it-1k1n , I covered how to harden your email domain with SPF, DKIM, and DMARC. The configuration works well. It kills the vast majority of inbound spam before it ever reaches your device. But there's a category of spam those tools can't touch: mail from operators who set up authentication correctly, on purpose, specifically to evade your filters. Fully authenticated. Low spam scores. Rotating domains across a dozen TLDs. AI-generated cover text to confuse content classifiers. That mail gets through. It lands in your Junk folder, caught by client-side content analysis. And there it sits. Marking it as junk and deleting it is the wrong response. That spam is coming from infrastructure that Spamhaus may not know about yet — and Spamhaus is how blocklists get built that protect everyone. If you have evidence of an active spam campaign, reporting it is the right move. The problem is that reporting spam manually is tedious. Spamhaus has a submission portal, but visiting it for each individual message is not a workflow anyone will sustain. This article covers how to automate it. A Python script that: The script uses a custom IMAP keyword flag $SpamhausProcessed to track which messages have already been processed. This means: On startup, the script runs a functional capability test — it attempts to set and immediately remove a test flag on the first available message. If your server doesn't support custom keywords, the script aborts cleanly rather than failing silently mid-run. A message is flagged as processed once it has been examined, regardless of whether individual API submissions succeeded. This is an intentional design choice: it prevents the script from reprocessing the same message indefinitely if a single indicator fails. Spamhaus returns HTTP 208 for already-known indicators, which handles any duplicate submissions across runs gracefully. Spamhaus account and API token: Register at submit.spamhaus.org https://submit.spamhaus.org , then go to auth.spamhaus.org/account https://auth.spamhaus.org/account , scroll to "API Key Creation", and create a key. Copy it immediately — it's only shown once. Python dependencies: pip install bs4 requests Environment variables: export IMAP SERVER=mail.example.com export IMAP PORT=993 export IMAP USER=you@example.com export IMAP PASSWORD=your imap password export SPAMHAUS TOKEN=your spamhaus api token Optional variables: export IMAP FOLDER=Junk folder to watch default: Junk export DRY RUN=1 parse without submitting or flagging export DELAY=2 seconds between new API submissions default: 2 export VERBOSE LIST=1 log every submission with its status Spamhaus exposes a REST API at https://submit.spamhaus.org/portal/api/v1 . All requests require a Bearer token header. Four submission types are relevant here: | Endpoint | Threat type | What it submits | |---|---|---| POST submissions/add/ip | spam | Sending IP address | POST submissions/add/domain | spam | Sending or landing domain | POST submissions/add/url | scam | Malicious URL from message body | POST submissions/add/email | spam | Raw email as evidence | On threat type codes: The threat types used here are conservative defaults — spam for IPs and domains, scam for URLs. Stronger classifications like bulletproof or phish require evidence beyond what's available from a single message. The API documentation shows example codes that don't always work for your account tier. Verify valid codes first: curl -s -H "Authorization: Bearer $SPAMHAUS TOKEN" \ https://submit.spamhaus.org/portal/api/v1/lookup/threats-types Conservative classifications aren't a weakness — Spamhaus has far more context than any individual submitter and will reclassify based on their own intelligence. What carries the most weight is the raw email submission. The full message gives analysts everything: the authentication chain in the headers, the sending infrastructure, the evasion techniques in the HTML, and the campaign fingerprint in the MIME structure. A precise threat type label matters far less than giving Spamhaus the evidence to make that determination themselves. Rate limiting: The API returns HTTP 429 when you exceed your submission rate. The script retries up to 3 times with a 60-second wait between attempts. HTTP 208 means already reported. If you submit something Spamhaus already has, they return 208. This is not an error — the script logs it as "already reported" and moves on. No sleep is applied on 208 responses; the delay only fires on successful new submissions 200 to pace actual API writes. When a message arrives at your mail server, your MTA performs an SPF check and writes a Received-SPF header recording the result. That header contains client-ip= — the IP address of the server that connected to deliver the message. That's the sending IP we want. The script reads only the topmost Received-SPF header because headers are prepended on arrival — the topmost one was written by your server when the message came in, and is the only one you can trust. Lower headers could have been injected by the spammer before sending, forged to make the mail look like it came from somewhere legitimate. spf headers = msg.get all 'Received-SPF' or if spf headers: match = re.search r'client-ip= 0-9a-fA-F.: + ', str spf headers 0 If no Received-SPF header is present, no IP is extracted and the IP submission is skipped. The alternative — walking the Received chain — risks reporting a legitimate forwarding service or ESP as the spam source. Domain, URL, and email submissions still proceed regardless. Private, loopback, link-local, and reserved addresses are filtered using Python's ipaddress module, which covers the full RFC 1918/4193/6598 range correctly. Domains are extracted from four sources for maximum coverage: From , Reply-To , and Return-Path headers using email.utils.getaddresses for RFC-compliant address parsing DKIM-Signature d= tag, which identifies the signing domain regardless of what From claimsThe primary domain used as the anchor for the raw email submission prefers DKIM d= over Return-Path . Spammers often separate these deliberately — DKIM signs for the infrastructure domain while Return-Path uses a throwaway address. All domains are IDNA-normalized before submission to collapse internationalized variants. URLs are extracted from the HTML body and normalized before deduplication: utm , fbclid , gclid , etc. are stripped ?b=2&a=1 and ?a=1&b=2 deduplicate correctly :80 , :443 are strippedUnsubscribe links are skipped. Landing domains are extracted from each URL and submitted as domain indicators alongside the full URL — in spam campaigns, the URL domain is often the highest-value IOC. SPF, DKIM, and DMARC results are parsed from the topmost Authentication-Results header. Line folding is stripped before parsing so compound headers on multiple lines are read correctly. Results inform the submission reason string but do not change the threat type — authentication success alone doesn't imply intent. For each sending IP that passes the deduplication check, the script queries RIPE Stat which aggregates all five RIRs globally to get the network name, organization, and country. This enriches the submission reason with real infrastructure data: Spam source. RIR: netname=EXAMPLE-NET org=Example Hosting Ltd country=XX. Auth: spf=pass dkim=pass dmarc=pass p=none . Found in Junk folder. Results are cached using lru cache maxsize=2048 . Each IP lookup makes an HTTP request to RIPE Stat — without caching, a batch of 50 messages from the same sending IP would trigger 50 identical network requests. With caching, the first call for a given IP hits the network and stores the result; every subsequent call with the same IP returns the stored result instantly. The maxsize=2048 cap prevents unbounded memory growth in daemon mode. Without a limit, the cache accumulates one entry per unique IP seen since the script started — a slow memory leak over weeks of continuous operation. Once 2048 entries are cached, the least recently used are evicted to make room for new ones. For a personal inbox this limit is effectively never reached, but it's the right engineering choice regardless. The lookup is deferred until after the deduplication check — no network I/O for IPs already seen in the current run. Three layers work together: Within a single run, a state tracker dict holds sets of already-seen IPs, domains, URLs, and email domains. The same indicator is only submitted once per run regardless of how many messages contain it. Across runs, the IMAP flag on each message means already-processed messages are skipped entirely on the next run. At the Spamhaus level, HTTP 208 handles any indicators that slip through — the API is idempotent. Save this as spam-monitor.py The latest version is on Github https://github.com/Sageth/spamhaus-reporting : bash /usr/bin/env python3 """ spam-monitor.py — Automated spam analysis and Spamhaus submission Monitors an IMAP Junk folder for spam, extracts infrastructure indicators, and submits them to the Spamhaus API. Uses a custom IMAP flag for state tracking — no local database or flat files required. Required environment variables: IMAP SERVER — e.g. mail.example.com IMAP PORT — e.g. 993 default IMAP USER — your full email address IMAP PASSWORD — your IMAP password SPAMHAUS TOKEN — your Spamhaus submission API token Optional environment variables: IMAP FOLDER — folder to watch default: Junk DRY RUN — set to "1" to parse without submitting default: 0 DELAY — seconds between API calls default: 2 VERBOSE LIST — set to "1" to log every submission with its status default: 0 Usage: python3 spam-monitor.py run once python3 spam-monitor.py --daemon run continuously DRY RUN=1 python3 spam-monitor.py dry run """ import imaplib import email import email.policy import os import re import sys import json import time import logging import argparse import socket import ipaddress import urllib.request import requests from collections import defaultdict from email.utils import getaddresses from functools import lru cache from urllib.parse import urlparse, urlencode, parse qsl, urlunparse from bs4 import BeautifulSoup ───────────────────────────────────────────── CONFIGURATION FROM ENVIRONMENT ───────────────────────────────────────────── IMAP SERVER = os.environ.get 'IMAP SERVER', '' IMAP PORT = int os.environ.get 'IMAP PORT', 993 IMAP USER = os.environ.get 'IMAP USER', '' IMAP PASSWORD = os.environ.get 'IMAP PASSWORD', '' SPAMHAUS TOKEN = os.environ.get 'SPAMHAUS TOKEN', '' IMAP FOLDER = os.environ.get 'IMAP FOLDER', 'Junk' DRY RUN = os.environ.get 'DRY RUN', '0' .strip == '1' DELAY = float os.environ.get 'DELAY', '2' VERBOSE LIST = os.environ.get 'VERBOSE LIST', '0' .strip == '1' SPAMHAUS API = 'https://submit.spamhaus.org/portal/api/v1' RIR API = 'https://stat.ripe.net/data/whois/data.json' PROCESSED FLAG = '$SpamhausProcessed' CAPABILITY FLAG = '$SpamhausCapabilityTest' TRACKING PARAMS = frozenset { 'utm source', 'utm medium', 'utm campaign', 'utm term', 'utm content', 'fbclid', 'gclid', 'msclkid', 'mc eid', 'mc cid', } socket.setdefaulttimeout 60 ───────────────────────────────────────────── LOGGING ───────────────────────────────────────────── logging.basicConfig level=logging.INFO, format='% asctime s % levelname s % message s', datefmt='%Y-%m-%d %H:%M:%S' log = logging.getLogger name ───────────────────────────────────────────── UTILITIES ───────────────────────────────────────────── def normalize domain domain : if not domain: return '' try: return domain.strip .encode 'idna' .decode 'ascii' .lower except Exception: return domain.strip .lower def is internal ip ip : try: return is internal addr ipaddress.ip address ip except ValueError: return True def is internal addr addr : return addr.is private or addr.is loopback or addr.is link local or addr.is reserved ───────────────────────────────────────────── EMAIL PARSING ───────────────────────────────────────────── def extract sending ip msg : spf headers = msg.get all 'Received-SPF' or if spf headers: match = re.search r'client-ip= 0-9a-fA-F.: + ', str spf headers 0 if match: ip = match.group 1 .strip if not is internal ip ip : return ip return None def extract envelope domains msg : domains = set for field in 'From', 'Reply-To', 'Return-Path' : headers raw = str h for h in msg.get all field or for , addr in getaddresses headers raw : if '@' in addr: domain = normalize domain addr.rsplit '@', 1 1 if domain: domains.add domain for dkim header in msg.get all 'DKIM-Signature' or : flat = re.sub r'\s+', '', str dkim header match = re.search r'\bd= a-zA-Z0-9.- +\. a-zA-Z {2,} ', flat, re.IGNORECASE if match: domains.add normalize domain match.group 1 return domains def extract primary domain msg : for dkim header in msg.get all 'DKIM-Signature' or : flat = re.sub r'\s+', '', str dkim header match = re.search r'\bd= a-zA-Z0-9.- +\. a-zA-Z {2,} ', flat, re.IGNORECASE if match: return normalize domain match.group 1 headers raw = str h for h in msg.get all 'Return-Path' or for , addr in getaddresses headers raw : if '@' in addr: return normalize domain addr.rsplit '@', 1 1 return None def extract auth results msg : auth headers = msg.get all 'Authentication-Results' or if not auth headers: return {'spf': 'unknown', 'dkim': 'unknown', 'dmarc': 'unknown', 'dmarc policy': 'unknown'} auth = re.sub r'\s+', ' ', str auth headers 0 def extract pattern : m = re.search pattern, auth, re.IGNORECASE return m.group 1 .lower if m else 'unknown' spf = extract r'\bspf= pass|fail|softfail|neutral|none|permerror|temperror \b' dkim = extract r'\bdkim= pass|fail|none|policy|neutral|temperror|permerror \b' dmarc = extract r'\bdmarc= pass|fail|none|bestguesspass|temperror|permerror \b' dmarc policy = extract r'\b ?:policy\. A-Za-z - |p = A-Za-z + ' return {'spf': spf, 'dkim': dkim, 'dmarc': dmarc, 'dmarc policy': dmarc policy} def normalize url href : try: parsed = urlparse href port = parsed.port clean params = sorted k, v for k, v in parse qsl parsed.query if k.lower not in TRACKING PARAMS hostname = normalize domain parsed.hostname or '' if not hostname: return None if parsed.scheme == 'https' and port == 443 or parsed.scheme == 'http' and port == 80 : port = None netloc = hostname if port is None else f'{hostname}:{port}' return urlunparse parsed. replace netloc=netloc, query=urlencode clean params except Exception: return None def extract cta urls msg : urls = set for part in msg.walk : if part.get content type == 'text/html': soup = None try: html = part.get payload decode=True .decode 'utf-8', errors='ignore' soup = BeautifulSoup html, 'html.parser' for a in soup.find all 'a', href=True : href = a 'href' .strip if not href.startswith 'http://', 'https://' : continue if any s in href.lower for s in 'unsub', 'optout', 'opt-out', 'remove', 'list-unsubscribe' : continue normalized = normalize url href if normalized: urls.add normalized except Exception as e: log.debug f'URL extraction error: {e}' finally: if soup: soup.decompose return list urls @lru cache maxsize=2048 def rir lookup ip : if not ip: return {} try: url = f'{RIR API}?resource={ip}' req = urllib.request.Request url, headers={'Accept': 'application/json'} with urllib.request.urlopen req, timeout=8 as resp: data = json.loads resp.read records = data.get 'data', {} .get 'records', result = {} for group in records: for record in group: key = record.get 'key', '' .lower if key in 'netname', 'org', 'country', 'descr' : result key = record.get 'value', '' return result except Exception as e: log.debug f'RIR lookup failed for {ip}: {e}' return {} def parse message raw bytes : msg = email.message from bytes raw bytes, policy=email.policy.default return { 'ip': extract sending ip msg , 'primary domain': extract primary domain msg , 'envelope domains': extract envelope domains msg , 'urls': extract cta urls msg , 'auth': extract auth results msg , 'subject': str msg.get 'Subject', '' , 'rspamd': str msg.get 'X-Rspamd-Score', 'N/A' , } ───────────────────────────────────────────── SPAMHAUS API ───────────────────────────────────────────── THREAT IP = 'spam' THREAT DOMAIN = 'spam' THREAT URL = 'scam' THREAT EMAIL = 'spam' REASON IP = lambda ripe, auth: f'Spam source. RIR: netname={ripe.get "netname","unknown" } ' f'org={ripe.get "org", ripe.get "descr","unknown" } ' f'country={ripe.get "country","unknown" }. ' f'Auth: spf={auth.get "spf" } dkim={auth.get "dkim" } ' f'dmarc={auth.get "dmarc" } p={auth.get "dmarc policy","unknown" } . ' f'Found in Junk folder.' REASON DOMAIN = 'Spam domain found in Junk folder.' REASON URL = 'Scam URL extracted from spam email body.' REASON EMAIL = 'Spam email found in Junk folder.' def spamhaus request endpoint, payload=None, method='POST', retries=3 : url = f'{SPAMHAUS API}/{endpoint}' headers = {'Authorization': f'Bearer {SPAMHAUS TOKEN}'} for attempt in range 1, retries + 1 : try: resp = requests.request method, url, headers=headers, json=payload if payload is not None else None, timeout=30 if resp.status code == 429: log.warning f'Rate limited — waiting 60s attempt {attempt}/{retries} ' time.sleep 60 continue elif resp.status code == 208: return 208, resp.json if resp.text else {} elif not resp.ok: try: err payload = resp.json except Exception: err payload = {'error': resp.text} log.error f'HTTP {resp.status code}: {err payload}' return resp.status code, err payload return resp.status code, resp.json if resp.text else {} except Exception as e: log.error f'Request error: {e}' return 0, {} return 429, {'message': 'rate limit retries exhausted'} def submit submission type, key, object value, threat type, reason : label = key.replace 'email:', '' if submission type == 'email' else key if DRY RUN: log.info f' DRY RUN Would submit {submission type.upper }: {label}' return status, body = spamhaus request f'submissions/add/{submission type}', { 'threat type': threat type, 'reason': reason, 'source': {'object': object value} } if status in 200, 208 : log.info f' {submission type.upper } {label} — {"OK" if status == 200 else "already reported"}' if status == 200: time.sleep DELAY else: log.warning f' {submission type.upper } {label} — failed {status} : {body}' def check submission count : status, data = spamhaus request 'submissions/count', method='GET' if status = 200: log.warning f'Could not fetch submission count: HTTP {status}' return total = data.get 'total', 0 matched = data.get 'matched', 0 new = total - matched pct matched = int matched / total 100 if total else 0 pct new = int new / total 100 if total else 0 log.info f'Spamhaus totals 30 days : {total} submitted — ' f'{matched} corroborated {pct matched}% , ' f'{new} new intelligence {pct new}% ' status, items = spamhaus request 'submissions/list?items=10000', method='GET' if status = 200: log.warning f'Could not fetch submissions list: HTTP {status}' return groups = defaultdict lambda: {'listed': 0, 'checked': 0, 'pending': 0} for item in items: t = item.get 'submission type', 'unknown' if item.get 'listed' : groups t 'listed' += 1 elif item.get 'last check' : groups t 'checked' += 1 else: groups t 'pending' += 1 for t, counts in sorted groups.items : log.info f' {t.upper }: {counts "listed" } listed, ' f'{counts "checked" } checked/not listed, ' f'{counts "pending" } pending' if VERBOSE LIST: log.info '--- Verbose submission list ---' for item in items: stype = item.get 'submission type', '?' if stype == 'email': obj = item.get 'attributes', {} .get 'subject', ' no subject ' else: obj = item.get 'source', {} .get 'object', '?' listed = item.get 'listed' if listed: status str = f'listed: {", ".join listed }' elif item.get 'last check' : status str = 'checked, not listed' else: status str = 'pending review' log.info f' {stype.upper } {obj} — {status str}' ───────────────────────────────────────────── PROCESSING ───────────────────────────────────────────── def process message raw bytes, state tracker : parsed = parse message raw bytes auth = parsed 'auth' log.info f' IP={parsed "ip" } primary domain={parsed "primary domain" }' log.info f' Subject: {parsed "subject" }' log.info f' Rspamd: {parsed "rspamd" }' log.info f' Auth: spf={auth.get "spf" } dkim={auth.get "dkim" } dmarc={auth.get "dmarc" } p={auth.get "dmarc policy" } ' if parsed 'ip' and parsed 'ip' not in state tracker 'ips' : state tracker 'ips' .add parsed 'ip' ripe = rir lookup parsed 'ip' if ripe: log.info f' RIR: netname={ripe.get "netname" } country={ripe.get "country" }' submit 'ip', parsed 'ip' , parsed 'ip' , THREAT IP, REASON IP ripe, auth for domain in parsed 'envelope domains' : if domain not in state tracker 'domains' : state tracker 'domains' .add domain submit 'domain', domain, domain, THREAT DOMAIN, REASON DOMAIN if parsed 'primary domain' and parsed 'primary domain' not in state tracker 'emails' : state tracker 'emails' .add parsed 'primary domain' key = f'email:{parsed "primary domain" }' MAX EMAIL BYTES = 1024 1024 email sample = raw bytes :MAX EMAIL BYTES .decode 'utf-8', errors='replace' submit 'email', key, email sample, THREAT EMAIL, REASON EMAIL for url in parsed 'urls' : if url not in state tracker 'urls' : state tracker 'urls' .add url submit 'url', url, url, THREAT URL, REASON URL try: hostname = normalize domain urlparse url .hostname or '' if hostname and hostname not in parsed 'envelope domains' and hostname not in state tracker 'domains' : state tracker 'domains' .add hostname submit 'domain', hostname, hostname, THREAT DOMAIN, f'Landing domain extracted from spam URL. {REASON DOMAIN}' except Exception as e: log.debug f'Could not extract landing domain from URL: {e}' ───────────────────────────────────────────── IMAP ───────────────────────────────────────────── def connect imap : conn = imaplib.IMAP4 SSL IMAP SERVER, IMAP PORT, timeout=60 conn.login IMAP USER, IMAP PASSWORD log.info f'Connected to {IMAP SERVER}:{IMAP PORT} as {IMAP USER}' return conn def run once : if not all IMAP SERVER, IMAP USER, IMAP PASSWORD, SPAMHAUS TOKEN : log.error 'Missing required environment variables.' sys.exit 1 if DRY RUN: log.info ' DRY RUN mode — no submissions or flags will be applied ' conn = None total processed = 0 try: conn = connect imap if conn.select f'"{IMAP FOLDER}"', readonly=False 0 = 'OK': log.error f'Could not select folder: {IMAP FOLDER}' return status, data = conn.uid 'search', None, f'NOT KEYWORD {PROCESSED FLAG}' if status = 'OK' or not data 0 : log.info f'Folder {IMAP FOLDER}: No unprocessed messages.' return uids = data 0 .split log.info f'Folder {IMAP FOLDER}: {len uids } unprocessed message s ' if not DRY RUN: test status, = conn.uid 'store', uids 0 , '+FLAGS', CAPABILITY FLAG if test status = 'OK': log.critical 'IMAP server rejected custom keyword flags — cannot track state. Aborting.' return try: conn.uid 'store', uids 0 , '-FLAGS', CAPABILITY FLAG except Exception: pass state tracker = {'ips': set , 'domains': set , 'urls': set , 'emails': set } for uid in uids: status, msg data = conn.uid 'fetch', uid, ' RFC822 ' if status = 'OK' or not msg data or not msg data 0 : continue raw bytes = msg data 0 1 log.info f'Processing message UID {uid.decode }' try: process message raw bytes, state tracker total processed += 1 if not DRY RUN: conn.uid 'store', uid, '+FLAGS', PROCESSED FLAG log.info f' Flagged message UID {uid.decode } as processed' except Exception as e: log.error f' Failed to process message UID {uid.decode }: {e}' finally: log.info f'Done. {total processed} message s processed.' if conn: if total processed: try: check submission count except Exception as e: log.error f'Could not fetch submission count: {e}' try: conn.logout except Exception: pass def run daemon interval=300 : log.info f'Daemon mode — checking every {interval}s' while True: try: run once except Exception as e: log.error f'Error in run loop: {e}' log.info f'Sleeping {interval}s...' time.sleep interval ───────────────────────────────────────────── ENTRY POINT ───────────────────────────────────────────── if name == ' main ': parser = argparse.ArgumentParser description='Spam monitor and Spamhaus submitter' parser.add argument '--daemon', action='store true', help='Run continuously' parser.add argument '--interval', type=int, default=300, help='Daemon check interval in seconds default: 300 ' args = parser.parse args if args.daemon: run daemon args.interval else: run once Always dry run first: DRY RUN=1 python3 spam-monitor.py This parses every message and logs what would be submitted without touching the API or setting any flags. Check the output carefully before running live. Single run: python3 spam-monitor.py Daemon mode checks every 5 minutes : python3 spam-monitor.py --daemon --interval 300 Cron job every 10 minutes : /10 cd /path/to/script && python3 spam-monitor.py Full submission detail: VERBOSE LIST=1 python3 spam-monitor.py After each run that processes at least one message, the script logs a summary of your Spamhaus submissions for the past 30 days, broken down by type and listing status: Spamhaus totals 30 days : 312 submitted — 187 corroborated 59% , 125 new intelligence 40% DOMAIN: 84 listed, 12 checked/not listed, 7 pending EMAIL: 41 listed, 8 checked/not listed, 3 pending IP: 73 listed, 11 checked/not listed, 5 pending URL: 35 listed, 6 checked/not listed, 4 pending This script is designed for personal use — a single inbox, running periodically, low submission volume. It works well in that context. A few things to be aware of: IP extraction requires Received-SPF. The script only extracts IPs from the topmost Received-SPF header, which your MTA writes on arrival. If that header is absent — unusual on modern mail providers but possible on misconfigured servers — no IP is submitted. The Received chain is not used as a fallback because it risks reporting legitimate forwarding infrastructure. Domains from envelope headers may include spoofed legitimate domains. If a message spoofs paypal.com in the From header and your server doesn't drop it, the script will attempt to report it. Spamhaus's analyst review process handles false positives, but it's worth monitoring your submission acceptance rate. URL landing domains may be legitimate redirectors. CDN hostnames, link shorteners, and ESP tracking domains sometimes appear in spam. The script submits them — whether that's useful depends on the campaign. State is tied to IMAP keyword support. Most modern IMAP servers support custom keywords Dovecot, Cyrus, Gmail . Some hosted providers don't. The script tests for support at startup and aborts if the server rejects the flag. This is a personal-use tool, not an enterprise pipeline. SQLite-backed state, archive-instead-of-delete retention, and multi-account support are the natural next steps if you outgrow it. Follow github.com/Sageth/spamhaus-reporting https://github.com/Sageth/spamhaus-reporting for updates. Your submissions are breadcrumbs, not the whole map. Spamhaus has vastly better detection infrastructure than any individual submitter. What you're providing is timing — you're reporting infrastructure while it's actively sending, not after the campaign has ended. The RIR enrichment matters more than the reason text. Spamhaus analysts can see that a netname maps to a specific hosting provider known for bulletproof services. That infrastructure context is more useful than a paragraph of narrative about what the spam said. Submitting the raw email alongside the IP and domain gives analysts the full picture — headers showing the authentication chain, HTML showing the evasion techniques, MIME structure showing the hidden text. What the blocklists do: The Spamhaus Blocklist SBL lists IP addresses observed sending spam or hosting malicious infrastructure. Mail servers that check Spamhaus reject connections from listed IPs at the SMTP level — before a message is even accepted. A listed IP can't deliver mail to anyone using Spamhaus-backed filtering until the operator cleans up their act and gets delisted. The Domain Blocklist DBL lists domains observed in spam campaigns — sending domains, hosting domains, and URLs found in message bodies. DBL listings propagate into DNS firewalls, email security products, and browser filters. A listed domain gets blocked across every product that queries Spamhaus, not just email. The Hash Blocklist HBL lists cryptographic hashes of malicious content — email addresses, file hashes, cryptocurrency wallet addresses. Less visible in day-to-day reporting, but your raw email submissions contribute to it. What the submission statuses mean: After submission, Spamhaus reviews each indicator and returns one of three statuses: The corroboration percentage in the summary log shows how many of your submissions matched intelligence Spamhaus already had. A high corroboration rate means you're seeing the same infrastructure they're already tracking — your timing confirmation is still useful. A high new intelligence rate means you're getting there first. If a range gets listed on SBL, the operator has to spin up new infrastructure, acquire new IP space, reconfigure sending, and rebuild sending reputation from scratch. If a domain gets listed on DBL, they need new domains, new DNS, new authentication records. That costs time and money, and it's the friction that makes automated spam campaigns expensive to sustain. See also: Why Your Email Is an Open Door for Spammers — And How to Lock It