AI-Assisted Email Security Scanner

Build a Python CLI tool that audits email infrastructure and generates AI security reports.

Introduction

30 Second Summary

Every email you send passes through a chain of servers that check the sender's identity and encrypt the connection before delivery. A single misconfiguration in that chain can leave an entire organization open to email spoofing and interception.

In this project, you will build SecureMailScope, a Python command-line tool that scans any domain's email infrastructure and generates an AI-analyzed security posture report with risk scores and actionable remediation guidance.

What You'll Build

Point SecureMailScope at any domain and get back a complete, AI-generated security posture report that grades every layer of its email infrastructure from A+ to F.

By the end of this project, you'll have:

  • A scanner that discovers any domain's mail servers, SPF policy, DKIM selectors, DMARC policy, and MTA-STS configuration in seconds.
  • An SMTP STARTTLS prober that connects to each live mail server and extracts the negotiated TLS version, cipher suite, and X.509 certificate details.
  • An AI-powered report generated by Claude that grades every finding as CRITICAL, HIGH, MEDIUM, LOW, or INFO with a 0-100 risk score, letter grade (A+ through F), and specific remediation steps.
  • Secret Mission: Add DANE/TLSA record validation and certificate pinning verification to your scanner.

Are there any prerequisites?

This project assumes advanced Python experience and familiarity with TLS/SSL concepts and DNS. You should already have Python 3.10+ and VS Code installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Environment and Anthropic API Key

Before you can scan a single mail server, you need two things: a library that can query specific DNS record types like MX and TXT, and an API key that lets Claude AI analyze your scan results.

You'll set these up now so you can test the full pipeline end to end as you build each layer.

In this step, get ready to:
  • Create a project directory with a Python virtual environment and scanning libraries.
  • Get an Anthropic API key for AI-powered security analysis.
  • Verify everything works with a quick import test.
Create the project directory and virtual environment

SecureMailScope lives in its own folder with an isolated Python environment. This keeps its dependencies separate from anything else on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open a new terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir securemailscope
cd securemailscope

What do these commands do?

  • cd ~/Desktop moves your terminal to the Desktop so the project folder is easy to find.
  • mkdir securemailscope creates a new folder for the project.
  • cd securemailscope moves into that folder so everything you do next happens inside it.
  • Confirm you're in the right place by running:
pwd

You should see a path ending in securemailscope.

Now create a Python virtual environment and activate it. The commands differ by operating system.

macOS

  • Create and activate the virtual environment by running these commands:
python3 -m venv venv
source venv/bin/activate

Windows

  • Create and activate the virtual environment by running these commands:
python -m venv venv
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

  • Make sure you ran the activation command for your operating system. macOS uses source venv/bin/activate while Windows uses venv\Scripts\activate.
  • On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and try the activation again.
  • help me activate my Python virtual environment in VS Code
  • Install the two packages SecureMailScope needs by running:
pip install dnspython==2.8.0 anthropic==1.0.0

What are these packages?

  • dnspython lets you query specific DNS record types (MX, TXT, TLSA) that Python's built-in networking modules cannot fetch.
  • anthropic is the official Python SDK for the Claude API. It handles authentication, request formatting, and response parsing.

You should see output ending with a line that confirms both packages were successfully installed.

Seeing an install error?

  • If you see a permissions error, make sure your virtual environment is active. The (venv) prefix must be visible in your prompt before running pip install.
  • If the install fails with a network error, check your internet connection and try again.
  • help me fix a pip install error for dnspython and anthropic
Get your Anthropic API key

Anthropic is the company behind Claude. You need an API key from their platform so your scanner can send findings to Claude for analysis.

  • Open platform.claude.com in your browser.
  • Sign in, or click Create account if you don't have one yet.

Does this cost anything?

New users receive a small amount of free credits to test the API. A single SecureMailScope scan uses Claude Haiku 4.5, which costs $1 per million input tokens and $5 per million output tokens. One report uses a few thousand tokens total, costing fractions of a cent.

  • Click Settings in the sidebar.
  • Click API keys.
  • Click Create key.
  • Name the key securemailscope and confirm.
  • Copy the key immediately and store it somewhere safe. The key starts with sk-ant- and is only shown once.

You can only see the key once

The console shows the full key only at the moment of creation. If you navigate away before copying it, you will need to create a new key.

Now set the key as an environment variable so your Python code can read it. Replace sk-ant-your-key-here with the key you just copied.

macOS

  • Set the key for your current terminal session by running:
export ANTHROPIC_API_KEY="sk-ant-your-key-here"

Session-only variable

This export command sets the variable for the current terminal session only. If you close this terminal, you'll need to run it again. For this project, keep this terminal open.

Windows

  • Save the key as a permanent environment variable by running:
setx ANTHROPIC_API_KEY "sk-ant-your-key-here"

The setx command saves the variable permanently, but it only takes effect in new terminal sessions. You need to restart your terminal for it to work.

  • Close your current terminal by clicking the trash icon in the VS Code terminal panel.
  • Open a new terminal by pressing Ctrl+`.
  • Navigate back to the project and reactivate the virtual environment by running:
cd ~/Desktop/securemailscope
venv\Scripts\activate

You should see (venv) back in your prompt.

Create the project file and verify your setup

With the environment ready and the API key set, create the file where all your scanner code will live.

  • In VS Code's Explorer sidebar (left panel), hover over the SECUREMAILSCOPE folder header.
  • Click the New File icon (a page with a small plus sign).
  • Type scanner.py and press Enter.
  • Confirm the file exists by running:
ls

You should see scanner.py and the venv folder in the output.

Now for the moment of truth. Before you run the next command, what do you think will happen if either package failed to install?

  • Verify that both packages are importable by running:
python -c "import dns.resolver; import anthropic; print('Ready')"

You should see a single word printed: Ready.

What does this command do?

The -c flag tells Python to run the quoted string as code without needing a file. It imports both libraries and prints Ready only if both imports succeed. If either package is missing, you'll see an ImportError instead.

Seeing an ImportError?

  • Make sure your virtual environment is active. Look for (venv) at the start of your prompt. If it's missing, reactivate it before running the command.
  • If the import still fails, reinstall the packages by running pip install dnspython==2.8.0 anthropic==1.0.0 again.
  • Windows users who ran setx: make sure you restarted your terminal and reactivated the venv afterwards. The setx change only applies to new terminal sessions.
  • help me troubleshoot an ImportError for dns.resolver or anthropic in my virtual environment

Your environment is locked in and your AI pipeline is connected. Next up, you'll build the DNS authentication scanner that discovers a domain's mail servers and audits its email security records.

Build the DNS Email Authentication Scanner

Your environment is ready with dnspython and the Anthropic SDK installed. Now it is time to build the core of SecureMailScope.

Before you can probe the cryptographic security of a mail server, you need to discover what mail infrastructure a domain actually has. What are its mail servers? Does it publish SPF, DKIM, or DMARC records? This step builds the DNS reconnaissance layer that answers those questions. Point it at any domain, get back a structured audit of its email authentication posture.

In this step, get ready to:
  • Implement MX record discovery and SPF record parsing.
  • Add DMARC policy retrieval and DKIM selector probing.
  • Add MTA-STS support and wire up the CLI.
Implement MX record discovery and SPF record parsing

Every email security audit starts with the same question: which servers handle mail for this domain? MX records in DNS answer that question. From there, you can check whether the domain publishes authentication policies like SPF.

  • Open scanner.py in VS Code and add the following imports at the top of the file:
import argparse
import json
import os
import socket
import ssl
import sys
import urllib.request
import urllib.error
from datetime import datetime, timezone

import dns.resolver
import anthropic

Why import modules we don't use yet?

Modules like socket, ssl, and anthropic are needed in later steps for TLS probing and AI analysis. Adding all imports now means you will not need to revisit the top of the file again.

  • Save scanner.py and confirm the imports load without errors by running this command in your terminal:
python scanner.py

The command should exit silently with no errors. If you see ModuleNotFoundError, your virtual environment is not activated.

Seeing ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • If (venv) is missing, activate it by running venv\Scripts\activate on Windows or source venv/bin/activate on macOS/Linux.
  • help me fix a ModuleNotFoundError when importing dnspython or anthropic
  • Now add the section header comment and the resolve_mx() function below the imports in scanner.py:

# =============================================================================
# DNS Email Authentication Scanner
# =============================================================================

def resolve_mx(domain):
    """Discover MX records for a domain, sorted by priority."""
    results = []
    try:
        # Query DNS for MX records
        answers = dns.resolver.resolve(domain, "MX")
        for rdata in sorted(answers, key=lambda r: r.preference):
            results.append({
                "priority": rdata.preference,
                "host": str(rdata.exchange).rstrip(".")
            })
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.exception.Timeout) as e:
        results.append({"error": str(e)})
    return results

What does this code do?

  • dns.resolver.resolve(domain, "MX") queries the domain's DNS for its mail exchange records.
  • The results are sorted by preference (priority), so the primary mail server appears first.
  • The trailing dot is stripped from hostnames (e.g. alt1.gmail-smtp-in.l.google.com. becomes alt1.gmail-smtp-in.l.google.com) to keep output clean.
  • DNS failures are caught gracefully and returned as an error dict instead of crashing.
  • Save scanner.py and test resolve_mx() against a real domain by running:
python -c "from scanner import resolve_mx; import json; print(json.dumps(resolve_mx('gmail.com'), indent=2))"

You should see a JSON array of MX records with priority and host fields. Gmail typically returns five mail servers.

  • Next, add the check_spf() function below resolve_mx() in scanner.py:

def check_spf(domain):
    """Retrieve and parse SPF record."""
    try:
        answers = dns.resolver.resolve(domain, "TXT")
        for rdata in answers:
            txt = b"".join(rdata.strings).decode()
            if txt.startswith("v=spf1"):
                mechanisms = []
                lookup_count = 0
                # Mechanisms that consume a DNS lookup
                lookup_mechs = ("include:", "a:", "mx:", "ptr", "exists:", "redirect=")
                for part in txt.split():
                    mechanisms.append(part)
                    if any(part.startswith(m) or part == m.rstrip(":") for m in lookup_mechs):
                        lookup_count += 1
                return {
                    "record": txt,
                    "mechanisms": mechanisms,
                    "dns_lookup_count": lookup_count,
                    "exceeds_10_lookup_limit": lookup_count > 10
                }
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.exception.Timeout):
        pass
    return {"record": None, "error": "No SPF record found"}

What does this code do?

  • It queries all TXT records for the domain and looks for the one starting with v=spf1.
  • Each mechanism in the SPF record is parsed. Mechanisms like include:, a:, and mx: each consume a DNS lookup.
  • RFC 7208 limits SPF evaluation to 10 DNS-querying mechanisms. The function counts these lookups and flags when the limit is exceeded.
  • Save scanner.py and test both functions together by running:
python -c "from scanner import check_spf; import json; print(json.dumps(check_spf('gmail.com'), indent=2))"

You should see a JSON object with the full SPF record string, a list of mechanisms, and the DNS lookup count.

Seeing an empty result or error?

  • Double-check that you saved scanner.py before running the test command.
  • If you see No SPF record found, try a different domain like google.com. Some smaller domains do not publish SPF records.
  • help me debug why check_spf returns no SPF record
Add DMARC policy retrieval and DKIM selector probing

SPF tells receiving servers which IPs are authorized to send mail. DMARC goes further by telling receivers what to do when authentication fails (reject, quarantine, or do nothing). DKIM proves a message was not tampered with in transit using cryptographic signatures.

  • Add the check_dmarc() function below check_spf() in scanner.py:

def check_dmarc(domain):
    """Retrieve and parse DMARC record."""
    try:
        # DMARC records live at _dmarc.{domain}
        answers = dns.resolver.resolve(f"_dmarc.{domain}", "TXT")
        for rdata in answers:
            txt = b"".join(rdata.strings).decode()
            if txt.startswith("v=DMARC1"):
                tags = {}
                for item in txt.split(";"):
                    item = item.strip()
                    if "=" in item:
                        key, value = item.split("=", 1)
                        tags[key.strip()] = value.strip()
                return {"record": txt, "tags": tags, "policy": tags.get("p", "not set")}
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.exception.Timeout):
        pass
    return {"record": None, "error": "No DMARC record found"}

What does this code do?

  • DMARC records are published as TXT records at _dmarc.{domain}.
  • The function parses semicolon-separated tags from the record. The most important tag is p= (policy), which can be none, quarantine, or reject.
  • A domain with p=reject has the strongest DMARC enforcement. A domain with p=none is only monitoring, not blocking spoofed messages.
  • Add the COMMON_DKIM_SELECTORS list and the check_dkim() function below check_dmarc():

COMMON_DKIM_SELECTORS = ["google", "default", "s1", "s2", "k1", "selector1", "selector2", "dkim"]


def check_dkim(domain, selectors=None):
    """Probe common DKIM selectors."""
    if selectors is None:
        selectors = COMMON_DKIM_SELECTORS
    found = {}
    for sel in selectors:
        try:
            # DKIM public keys live at {selector}._domainkey.{domain}
            answers = dns.resolver.resolve(f"{sel}._domainkey.{domain}", "TXT")
            for rdata in answers:
                txt = b"".join(rdata.strings).decode()
                if "v=DKIM1" in txt or "p=" in txt:
                    found[sel] = txt
        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.exception.Timeout):
            continue
    return {"selectors_found": found, "selectors_probed": selectors}

Why probe a list of selectors?

DKIM selectors are not enumerable from DNS alone. You cannot ask a domain "list all your DKIM selectors." Each selector is a separate DNS record at {selector}._domainkey.{domain}.

The common selectors list covers the defaults used by major providers like Google, Microsoft, and Amazon SES. For comprehensive DKIM validation, you would need an actual signed email header to read the s= tag.

  • Save scanner.py and test the DMARC and DKIM functions by running:
python -c "from scanner import check_dmarc, check_dkim; import json; print('DMARC:', json.dumps(check_dmarc('gmail.com'), indent=2)); print('DKIM:', json.dumps(check_dkim('gmail.com'), indent=2))"

You should see DMARC output with a policy field showing reject or none. For DKIM, you should see at least one selector found (typically google for Gmail).

Add MTA-STS support and wire up the CLI

MTA-STS (Mail Transfer Agent Strict Transport Security) tells sending mail servers that they must use TLS when delivering mail. It is a newer standard that complements STARTTLS by publishing an enforceable policy via DNS and HTTPS.

  • Add the first part of the check_mta_sts() function below check_dkim() in scanner.py. This queries the DNS TXT record:

def check_mta_sts(domain):
    """Check MTA-STS DNS record and fetch policy file."""
    result = {"dns_record": None, "policy": None}

    # Check DNS TXT record for MTA-STS
    try:
        answers = dns.resolver.resolve(f"_mta-sts.{domain}", "TXT")
        for rdata in answers:
            txt = b"".join(rdata.strings).decode()
            if "v=STSv1" in txt:
                result["dns_record"] = txt
                break
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.exception.Timeout):
        result["dns_record_error"] = "No MTA-STS TXT record found"

    return result

What does this code do?

MTA-STS records live at _mta-sts.{domain} as TXT records containing v=STSv1. The function checks if this record exists. Next, you will add the HTTPS policy fetch that retrieves the full enforcement policy.

  • Now find the return result line at the bottom of check_mta_sts(). Replace it with the HTTPS policy fetch code below. The return result moves to the end of the new block:
    # Fetch policy file over HTTPS
    policy_url = f"https://mta-sts.{domain}/.well-known/mta-sts.txt"
    try:
        req = urllib.request.Request(policy_url, headers={"User-Agent": "SecureMailScope/1.0"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            policy_text = resp.read().decode()
            policy = {}
            for line in policy_text.strip().splitlines():
                if ":" in line:
                    key, value = line.split(":", 1)
                    key = key.strip().lower()
                    value = value.strip()
                    if key == "mx":
                        policy.setdefault("mx", []).append(value)
                    else:
                        policy[key] = value
            result["policy"] = policy
    except (urllib.error.URLError, urllib.error.HTTPError, OSError):
        result["policy_error"] = "Could not fetch MTA-STS policy file"

    return result

What does this code do?

  • MTA-STS policies are hosted at a well-known HTTPS URL: https://mta-sts.{domain}/.well-known/mta-sts.txt.
  • The function fetches this file and parses key-value pairs. The mode field tells you whether the policy is enforce, testing, or none.
  • Multiple mx lines are collected into a list, since a domain can specify several mail server patterns.
  • Save scanner.py and test check_mta_sts() by running:
python -c "from scanner import check_mta_sts; import json; print(json.dumps(check_mta_sts('gmail.com'), indent=2))"

You should see a JSON object with the dns_record field populated, and a policy object containing mode, mx, and max_age fields.

Seeing policy_error instead of a policy?

  • Some domains do not publish MTA-STS policies. If gmail.com does not return a policy, try outlook.com or microsoft.com.
  • If you see a URLError, check your internet connection. The policy fetch requires HTTPS access.
  • help me troubleshoot MTA-STS policy fetch errors
  • Now add the scan_dns_auth() wrapper function and the CLI entry point below check_mta_sts() in scanner.py:

def scan_dns_auth(domain):
    """Run all DNS email authentication checks."""
    return {
        "mx": resolve_mx(domain),
        "spf": check_spf(domain),
        "dmarc": check_dmarc(domain),
        "dkim": check_dkim(domain),
        "mta_sts": check_mta_sts(domain),
    }


def main():
    parser = argparse.ArgumentParser(
        description="SecureMailScope - AI-Assisted Email Security Posture Scanner"
    )
    parser.add_argument("domain", help="Domain to scan (e.g., example.com)")
    args = parser.parse_args()

    domain = args.domain.strip().lower()
    print(f"\n[*] Scanning {domain}...")

    # Run all DNS authentication checks and output as JSON
    dns_results = scan_dns_auth(domain)
    print(json.dumps(dns_results, indent=2, default=str))


if __name__ == "__main__":
    main()

What does this code do?

  • scan_dns_auth() is a single entry point that runs all five DNS checks and returns one combined dict.
  • main() uses argparse to accept a domain name from the command line, then prints the results as formatted JSON.
  • The if __name__ == "__main__" guard ensures main() only runs when you execute the script directly, not when importing functions for testing.

Before you run the full scanner, what do you think the output will look like? Will you see all five sections (MX, SPF, DMARC, DKIM, MTA-STS) with data?

  • Run the full scanner against gmail.com by running:
python scanner.py gmail.com

You should see structured JSON output with five top-level keys: mx, spf, dmarc, dkim, and mta_sts. The MX section lists Gmail's mail servers sorted by priority. The DMARC section shows a policy value parsed from the p= tag. The DKIM section shows which selectors were found.

Not seeing JSON output?

  • If you see a SyntaxError, check for mismatched parentheses or missing commas in your function definitions.
  • If the script hangs, a DNS query might be timing out. Press Ctrl+C to cancel and try a different domain like google.com.
  • If you see NameError, make sure every function is defined before scan_dns_auth() calls it. The order in your file matters.
  • help me fix errors when running python scanner.py gmail.com

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and the output from python scanner.py gmail.com shows all five sections.

ⓧ I'd like to double check the full code

Your scanner.py should contain these sections in order. Scroll through your file and confirm each one is present:

  • Imports at the top (argparse, json, os, socket, ssl, sys, urllib.request, urllib.error, datetime, dns.resolver, anthropic).
  • The # DNS Email Authentication Scanner section comment.
  • resolve_mx() function querying MX records.
  • check_spf() function parsing SPF TXT records with mechanism counting.
  • check_dmarc() function parsing DMARC tags at _dmarc.{domain}.
  • COMMON_DKIM_SELECTORS list and check_dkim() function probing selectors at {selector}._domainkey.{domain}.
  • check_mta_sts() function querying the DNS record and fetching the HTTPS policy file.
  • scan_dns_auth() function combining all five checks.
  • main() function with argparse and JSON output, plus the if __name__ == "__main__" guard at the very end.

Your DNS reconnaissance layer is live. Point it at any domain and you get a full audit of its email authentication posture in seconds. Next up, you will add SMTP TLS probing to check whether those mail servers actually have secure cryptographic configurations.

Add SMTP TLS Cryptographic Probing

In the previous step, you built a scanner that queries DNS records to find out what a domain claims about its email security.

Claims and reality are different things. A domain can publish perfect SPF, DKIM, and DMARC records while its mail servers run obsolete TLS, weak ciphers, or expired certificates. This step adds the ground-truth layer: your scanner will open a real SMTP connection to each mail server, negotiate a STARTTLS handshake, and inspect the actual cryptographic configuration.

In this step, get ready to:
  • Build an SMTP STARTTLS prober using Python's socket and ssl modules.
  • Grade TLS configurations and flag weak cipher suites and expiring certificates.
  • Wire TLS probing into the scan pipeline alongside DNS results.
Build the SMTP STARTTLS prober

The SMTP STARTTLS protocol upgrades a plain-text SMTP connection to an encrypted one mid-conversation. Your prober will connect to port 25, speak raw SMTP to discover whether the server supports STARTTLS, then upgrade to TLS and inspect what the server actually negotiated.

  • In scanner.py, scroll down below the scan_dns_auth() function.
  • Add a section header comment and the beginning of the STARTTLS prober function by pasting this code:
# =============================================================================
# SMTP TLS Cryptographic Prober
# =============================================================================

def probe_smtp_tls(hostname, port=25, timeout=15):
    """Connect to SMTP server, perform STARTTLS, and extract TLS details."""
    result = {
        "host": hostname,
        "port": port,
        "starttls_supported": False,
        "tls": None,
        "certificate": None,
        "error": None,
    }

    try:
        sock = socket.create_connection((hostname, port), timeout=timeout)
        # Read banner
        banner = sock.recv(1024).decode(errors="replace").strip()
        result["banner"] = banner

        # Send EHLO
        sock.sendall(b"EHLO securemailscope\r\n")
        ehlo_resp = sock.recv(4096).decode(errors="replace")

        if "STARTTLS" not in ehlo_resp.upper():
            result["error"] = "STARTTLS not advertised"
            sock.sendall(b"QUIT\r\n")
            sock.close()
            return result

What does this code do?

This is the first half of the SMTP handshake. The function opens a raw TCP socket to port 25, reads the server's greeting banner, then sends EHLO securemailscope to introduce itself.

The server's EHLO response lists its capabilities. If STARTTLS is missing from that list, the server does not support encryption at all. The function logs the error and returns early.

  • Directly below the code you just pasted (still inside the try: block), add the TLS upgrade and cipher extraction logic:

        result["starttls_supported"] = True

        # Send STARTTLS
        sock.sendall(b"STARTTLS\r\n")
        starttls_resp = sock.recv(1024).decode(errors="replace")

        if not starttls_resp.startswith("220"):
            result["error"] = f"STARTTLS rejected: {starttls_resp.strip()}"
            sock.close()
            return result

        # Wrap socket with TLS
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        tls_sock = ctx.wrap_socket(sock, server_hostname=hostname)

        # Extract TLS info
        cipher_info = tls_sock.cipher()
        result["tls"] = {
            "version": tls_sock.version(),
            "cipher_suite": cipher_info[0] if cipher_info else None,
            "cipher_bits": cipher_info[2] if cipher_info else None,
        }

Why disable hostname verification?

Setting check_hostname=False and verify_mode=ssl.CERT_NONE means the scanner accepts any certificate without validation. This is intentional for a scanning tool. You need to inspect the certificate data regardless of whether it is valid, expired, or self-signed.

After the TLS upgrade, tls_sock.version() returns the negotiated protocol (like TLSv1.3), and tls_sock.cipher() returns a tuple with the cipher suite name and key size.

  • Continue adding code directly below the cipher extraction (still inside try:). Add the certificate extraction, cleanup, and error handling to finish the function:

        # Extract certificate
        der_cert = tls_sock.getpeercert(binary_form=True)
        pem_cert = ssl.DER_cert_to_PEM_cert(der_cert)
        cert_dict = tls_sock.getpeercert()

        if cert_dict:
            not_after = cert_dict.get("notAfter", "")
            not_before = cert_dict.get("notBefore", "")

            # Extract subject and issuer
            subject = dict(x[0] for x in cert_dict.get("subject", ()) if x)
            issuer = dict(x[0] for x in cert_dict.get("issuer", ()) if x)

            result["certificate"] = {
                "subject": subject,
                "issuer": issuer,
                "not_before": not_before,
                "not_after": not_after,
                "serial_number": cert_dict.get("serialNumber", ""),
                "san": [entry[1] for entry in cert_dict.get("subjectAltName", ())],
            }
            result["der_cert"] = der_cert

        tls_sock.sendall(b"QUIT\r\n")
        tls_sock.close()

    except (socket.timeout, socket.gaierror, ConnectionRefusedError, OSError, ssl.SSLError) as e:
        result["error"] = f"{type(e).__name__}: {e}"

    return result

What does the certificate extraction do?

The function calls getpeercert() twice. The binary_form=True call always returns the raw DER-encoded certificate bytes (needed later for DANE fingerprinting). The call without binary_form returns a parsed dict with subject, issuer, dates, and SANs.

Because verify_mode is set to CERT_NONE, the parsed dict may be empty on some Python versions. The if cert_dict: check handles this gracefully. The broad except at the bottom catches connection timeouts, DNS failures, and firewall blocks so one unreachable server does not crash the entire scan.

  • Save scanner.py.
  • Test the prober by running this command in your terminal:
python -c "from scanner import probe_smtp_tls; import json; r = probe_smtp_tls('gmail-smtp-in.l.google.com'); print(json.dumps({k:v for k,v in r.items() if k != 'der_cert'}, indent=2, default=str))"

You should see a JSON object with starttls_supported, negotiated TLS version, cipher suite, and certificate details. If your ISP blocks port 25, you will see a timeout error instead. Both outcomes confirm the function is working.

Seeing a timeout or connection refused?

Many residential ISPs block outbound port 25 to prevent spam. If every connection times out, your ISP likely blocks this port. The scanner handles this gracefully with error messages. You will still see full results once the pipeline runs against domains whose MX servers accept connections.

If you see a ModuleNotFoundError or ImportError, check that your virtual environment is activated and that the code in scanner.py has no syntax errors.

Help me debug my SMTP STARTTLS prober

Grade TLS configuration and check certificates

Raw TLS data is useful, but a security analyst needs findings sorted by severity. The grading function will classify each result as CRITICAL, HIGH, MEDIUM, or INFO based on industry standards like RFC 8996 (TLS deprecation).

  • Below the probe_smtp_tls() function, add the first part of the grading function that checks for missing STARTTLS and obsolete TLS versions:


def grade_tls(tls_result):
    """Grade a TLS probe result and return findings with severity."""
    findings = []

    if tls_result.get("error") and not tls_result.get("starttls_supported"):
        findings.append({
            "severity": "CRITICAL",
            "title": "STARTTLS Not Available",
            "detail": f"MX host {tls_result['host']} does not support STARTTLS. "
                       "All email transit is unencrypted.",
        })
        return findings

    tls = tls_result.get("tls")
    if tls:
        version = tls.get("version", "")
        cipher = tls.get("cipher_suite", "")
        bits = tls.get("cipher_bits", 0)

        if version in ("TLSv1", "TLSv1.0", "TLSv1.1"):
            findings.append({
                "severity": "CRITICAL",
                "title": f"Obsolete TLS Version: {version}",
                "detail": f"MX host {tls_result['host']} negotiated {version}, "
                           "which is deprecated per RFC 8996.",
            })

What does this grading logic do?

The function checks the probe result in order of severity. If STARTTLS was not advertised at all, that is a CRITICAL finding because all email to that server travels unencrypted.

TLS 1.0 and 1.1 are deprecated by RFC 8996 and flagged as CRITICAL. These versions have known vulnerabilities that make encrypted connections unreliable.

  • Directly below the TLS 1.0/1.1 check (still inside the if tls: block), add the TLS 1.2/1.3 grading and certificate expiry checking to complete the function:
        elif version == "TLSv1.2":
            if "DHE" not in cipher and "ECDHE" not in cipher:
                findings.append({
                    "severity": "HIGH",
                    "title": "TLS 1.2 Without Forward Secrecy",
                    "detail": f"Cipher {cipher} does not provide forward secrecy.",
                })
            else:
                findings.append({
                    "severity": "INFO",
                    "title": f"TLS 1.2 with Forward Secrecy",
                    "detail": f"Cipher: {cipher} ({bits} bits).",
                })
        elif version == "TLSv1.3":
            findings.append({
                "severity": "INFO",
                "title": "TLS 1.3 Negotiated",
                "detail": f"Cipher: {cipher} ({bits} bits). Excellent.",
            })

    cert = tls_result.get("certificate")
    if cert:
        not_after = cert.get("not_after", "")
        if not_after:
            try:
                expiry = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
                days_left = (expiry - datetime.now(timezone.utc)).days
                if days_left < 0:
                    findings.append({
                        "severity": "CRITICAL",
                        "title": "Certificate Expired",
                        "detail": f"Certificate expired {abs(days_left)} days ago.",
                    })
  • Right after the expired-cert check, add the remaining certificate check and close out the function:
                elif days_left < 30:
                    findings.append({
                        "severity": "MEDIUM",
                        "title": "Certificate Expiring Soon",
                        "detail": f"Certificate expires in {days_left} days.",
                    })
            except ValueError:
                pass

    return findings

How does the grading hierarchy work?

  • CRITICAL: No STARTTLS, obsolete TLS (1.0/1.1), or expired certificate.
  • HIGH: TLS 1.2 without forward secrecy. If the server's private key is compromised, past sessions can be decrypted.
  • MEDIUM: Certificate expiring within 30 days. Not broken yet, but needs attention.
  • INFO: TLS 1.2 with forward secrecy or TLS 1.3. These are healthy configurations.
  • Save scanner.py.
  • Test the grading logic with a simulated probe result by running:
python -c "from scanner import grade_tls; print(grade_tls({'host': 'test.com', 'error': 'timeout', 'starttls_supported': False}))"

You should see a list with one CRITICAL finding titled STARTTLS Not Available. This confirms the grading function classifies missing encryption as the highest severity.

Seeing a SyntaxError or IndentationError?

The grade_tls() function has nested if/elif chains. Make sure the elif version == "TLSv1.2": line aligns with the if version in ("TLSv1", ...) line above it. The certificate checking block (cert = tls_result.get("certificate")) should be at the same indentation level as tls = tls_result.get("tls").

Help me fix indentation in my grade_tls function

Integrate TLS probing into the scan pipeline

The prober and grader are ready. Now you need a function that runs the prober against every MX host and collects the graded findings, plus an updated main() that runs both the DNS and TLS phases.

  • Below the grade_tls() function, add the scan_tls() function that probes all MX hosts:


def scan_tls(mx_records):
    """Probe TLS on all MX hosts."""
    results = []
    for mx in mx_records:
        if "error" in mx:
            continue
        host = mx["host"]
        probe = probe_smtp_tls(host)
        findings = grade_tls(probe)
        # Remove binary cert data from JSON output
        probe_clean = {k: v for k, v in probe.items() if k != "der_cert"}
        results.append({"probe": probe_clean, "findings": findings})
    return results

What does scan_tls do?

This function loops through the MX records discovered by resolve_mx(), calls probe_smtp_tls() on each host, and grades the result. It strips out the raw binary certificate (der_cert) from the output since that is not human-readable JSON.

  • Scroll down to the existing main() function.
  • Find this line:
def main():
  • Replace the entire main() function (everything from def main(): down to the print(json.dumps(...)) call) with this updated version:
def main():
    parser = argparse.ArgumentParser(
        description="SecureMailScope - AI-Assisted Email Security Posture Scanner"
    )
    parser.add_argument("domain", help="Domain to scan (e.g., example.com)")
    args = parser.parse_args()

    domain = args.domain.strip().lower()
    print(f"\n[*] Scanning {domain}...")

    # Phase 1: DNS Authentication
    print("[*] Phase 1: DNS email authentication audit...")
    dns_results = scan_dns_auth(domain)

    # Phase 2: TLS Probing
    print("[*] Phase 2: SMTP TLS cryptographic probing...")
    tls_results = scan_tls(dns_results.get("mx", []))

    # Combine results
    scan_results = {
        "domain": domain,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "dns_authentication": dns_results,
        "tls_probes": [
            {k: v for k, v in entry["probe"].items()}
            for entry in tls_results
        ],
        "tls_findings": [
            finding
            for entry in tls_results
            for finding in entry.get("findings", [])
        ],
    }

    print(json.dumps(scan_results, indent=2, default=str))

What changed in main?

The updated main() now runs two phases. Phase 1 calls scan_dns_auth() (your existing DNS scanner). Phase 2 calls scan_tls() on the discovered MX records.

Both sets of findings are combined into a single scan_results dict that includes the domain, timestamp, DNS authentication data, raw TLS probes, and severity-graded TLS findings.

  • Make sure the if __name__ == "__main__": block at the bottom of the file is still intact after your edit.
  • Save scanner.py.

Before you run the scanner, think about what you expect to see. The DNS phase should complete quickly. What do you think will happen during the TLS probing phase? Will every MX server respond, or might some connections time out?

  • Run the full scanner by running:
python scanner.py gmail.com

You should see Phase 1 and Phase 2 progress messages, followed by a JSON output containing both dns_authentication and tls_probes data. The TLS probes show the negotiated TLS version, cipher suite name, key size, and certificate subject/issuer/expiry for each MX server.

What if my ISP blocks port 25?

Most residential ISPs block outbound port 25 to prevent spam. If you see timeout errors for every MX host, this is likely the cause. The scanner handles these errors gracefully and still outputs DNS findings.

You will still see TLS findings with severity grades (the error itself becomes a finding). The full pipeline test in Step 4 works regardless, because the AI analysis processes whatever data the scanner collects.

Seeing a NameError or AttributeError?

A NameError on scan_tls or probe_smtp_tls means one of those functions is not defined above main() in the file. Check that all three new functions (probe_smtp_tls, grade_tls, scan_tls) appear between scan_dns_auth() and main().

Help me debug my scanner pipeline integration

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py.

ⓧ I'd like to double check the full code

The full file is too long to display here. Scroll up and confirm these sections are present in order:

  • Imports at the top (argparse, json, os, socket, ssl, etc.).
  • DNS Email Authentication Scanner section: resolve_mx(), check_spf(), check_dmarc(), check_dkim(), check_mta_sts(), scan_dns_auth().
  • SMTP TLS Cryptographic Prober section: probe_smtp_tls(), grade_tls(), scan_tls().
  • Updated main() with Phase 1 and Phase 2, combining results into scan_results.
  • The if __name__ == "__main__": block calling main() at the very bottom.

Your scanner now probes real mail servers and grades their cryptographic configuration. You have ground truth alongside DNS claims. Next up, you will feed all of these findings into Claude AI to generate a professional security posture report with risk scores and remediation guidance.

Generate AI-Powered Security Posture Reports

Right now your scanner dumps a wall of technical JSON. That is useful for machines, but an IT manager or CERT analyst needs a prioritized list of findings with severity ratings and concrete remediation steps.

The gap between raw data and actionable intelligence is exactly where AI adds value. In this step, you will build a structured prompt for Claude Haiku 4.5, wire up the Anthropic API call, and format the output into a polished security posture report with color-coded severity levels.

In this step, get ready to:
  • Design a structured analysis prompt that instructs Claude to produce a risk score, letter grade, and categorized findings.
  • Call the Claude API, parse the response, and handle errors gracefully.
  • Build a formatted terminal report with ANSI color-coded severity levels and CLI flags for JSON output.
Design the analysis prompt

A well-structured prompt is the difference between a vague summary and a professional security report. You need to tell Claude exactly what output format you expect, which severity levels to use, and which RFCs to reference.

  • In scanner.py, scroll down to the bottom of the scan_tls() function. Add the following build_analysis_prompt() function below it:
def build_analysis_prompt(scan_results):
    """Construct the system and user prompts for Claude."""
    system_prompt = (
        "You are an expert email security analyst performing a cryptographic "
        "security posture assessment. Analyze the provided scan results and "
        "produce a structured security report.\n\n"
        "Your report MUST include:\n"
        "1. RISK SCORE: A number from 0 (critical risk) to 100 (excellent) "
        "with a letter grade (A+ through F).\n"
        "2. EXECUTIVE SUMMARY: 2-3 sentences summarizing the overall posture.\n"
        "3. FINDINGS: Each finding must have:\n"
        "   - Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO\n"
        "   - Title: Short description\n"
        "   - Detail: What was found and why it matters\n"
        "   - Remediation: Specific action to fix, referencing relevant RFCs\n"
        "4. Sort findings by severity (CRITICAL first).\n"
        "5. RECOMMENDATIONS: Top 3 prioritized actions.\n\n"
        "Be specific and actionable. Reference RFC numbers where relevant "
        "(RFC 7208 for SPF, RFC 6376 for DKIM, RFC 7489 for DMARC, "
        "RFC 8461 for MTA-STS, RFC 8996 for TLS deprecation, "
        "RFC 8446 for TLS 1.3)."
    )

    user_message = (
        f"Analyze the following email security scan results and produce "
        f"your security posture report:\n\n"
        f"```json\n{json.dumps(scan_results, indent=2, default=str)}\n```"
    )

    return system_prompt, user_message

What does this code do?

The system_prompt tells Claude to act as an email security analyst. It specifies the exact output structure you want: a 0-100 risk score with a letter grade, severity-categorized findings, and RFC-referenced remediation steps.

The user_message serializes your scan results as JSON and asks Claude to analyze them. By embedding the data as a code block, Claude can parse it more reliably.

  • Save scanner.py.
Call the Claude API and parse the response

With the prompt designed, you need a function that sends it to the Anthropic API, extracts the analysis text from the response, and handles errors so your scanner never crashes on a failed API call.

  • Still in scanner.py, add the analyze_with_ai() function directly below build_analysis_prompt():
def analyze_with_ai(scan_results):
    """Send scan results to Claude for AI analysis."""
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        return {"error": "ANTHROPIC_API_KEY not set. Set it with: setx ANTHROPIC_API_KEY \"your-key\""}

    system_prompt, user_message = build_analysis_prompt(scan_results)

    try:
        client = anthropic.Anthropic(api_key=api_key)
        message = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=4096,
            system=system_prompt,
            messages=[{"role": "user", "content": user_message}],
        )

        analysis_text = ""
        for block in message.content:
            if block.type == "text":
                analysis_text += block.text

        return {
            "analysis": analysis_text,
            "model": message.model,
            "usage": {
                "input_tokens": message.usage.input_tokens,
                "output_tokens": message.usage.output_tokens,
            },
        }
    except anthropic.APIError as e:
        return {"error": f"API error: {e}"}

What does this code do?

The function first checks for the ANTHROPIC_API_KEY environment variable. If it is missing, the function returns an error dict instead of crashing.

It creates an anthropic.Anthropic() client and calls client.messages.create() with model claude-haiku-4-5 and max_tokens=4096. The response content comes back as a list of blocks, so it loops through to extract text. Token usage is tracked for cost awareness.

The except anthropic.APIError catch handles all API failures (rate limits, auth errors, server errors) with a clean error message. The SDK also auto-retries connection errors and 429s up to 2 times with exponential backoff.

  • Save scanner.py.
  • Verify both functions load correctly by running this command:
python -c "from scanner import build_analysis_prompt, analyze_with_ai; print('AI functions loaded')"

You should see AI functions loaded printed in your terminal.

Seeing a syntax error?

  • Check that your build_analysis_prompt() function ends with return system_prompt, user_message. A missing return causes the next function to fail.
  • Make sure every opening parenthesis ( in the string concatenations has a matching closing ).
  • help me debug a syntax error when importing my AI analysis functions
Format the report and wire up the CLI

The AI analysis returns plain text. To make the report scannable in a terminal, you will add ANSI color codes that highlight severity levels at a glance. CRITICAL findings glow red, INFO findings appear green.

  • In scanner.py, add the severity color map and the first part of print_report() below analyze_with_ai():
SEVERITY_COLORS = {
    "CRITICAL": "\033[91m",  # Red
    "HIGH": "\033[93m",      # Yellow
    "MEDIUM": "\033[33m",    # Orange
    "LOW": "\033[96m",       # Cyan
    "INFO": "\033[92m",      # Green
}
RESET = "\033[0m"


def print_report(domain, scan_results, ai_result, tls_findings):
    """Print a formatted security posture report."""
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")

    print(f"\n{'='*70}")
    print(f"  SecureMailScope - Email Security Posture Assessment")
    print(f"  Domain: {domain}")
    print(f"  Scan Time: {timestamp}")
    print(f"{'='*70}\n")

    if tls_findings:
        print(f"--- TLS Probe Findings ---\n")
        for tls_entry in tls_findings:
            for finding in tls_entry.get("findings", []):
                sev = finding["severity"]
                color = SEVERITY_COLORS.get(sev, "")
                print(f"  [{color}{sev}{RESET}] {finding['title']}")
                print(f"         {finding['detail']}\n")

What does this code do?

The SEVERITY_COLORS dictionary maps each severity level to an ANSI escape code. These codes tell the terminal to render the following text in a specific color. The RESET code returns text to its default color after the severity label.

The first half of print_report() prints a banner with the domain name and scan timestamp, then loops through TLS findings from Step 3 and prints each one with its color-coded severity tag.

  • Still inside print_report(), add the AI analysis display section directly below the TLS findings block (keep the same indentation level):
    if "error" in ai_result:
        print(f"\n[!] AI Analysis Error: {ai_result['error']}")
        print("[*] Falling back to raw scan data output.\n")
        print(json.dumps(scan_results, indent=2, default=str))
    else:
        print(f"\n--- AI Security Analysis (Model: {ai_result.get('model', 'N/A')}) ---\n")
        print(ai_result.get("analysis", "No analysis generated."))
        usage = ai_result.get("usage", {})
        print(f"\n  [Tokens used: {usage.get('input_tokens', 0)} in / "
              f"{usage.get('output_tokens', 0)} out]")

    print(f"\n{'='*70}")
    print(f"  Scan complete.")
    print(f"{'='*70}\n")

What does this code do?

The error branch handles API failures gracefully. If the AI call failed, the report falls back to printing the raw scan JSON so you still get useful data.

The success branch prints Claude's full analysis and a token usage summary so you can see exactly how much each scan costs. The closing banner marks the end of the report.

  • Save scanner.py.
  • Verify the report formatter loads by running:
python -c "from scanner import print_report; print('Report formatter ready')"

You should see Report formatter ready in your terminal.

Now you need to wire everything into main() so the scanner actually calls these new functions.

  • In scanner.py, scroll down to the main() function. Find these two lines near the top of the function:
    parser.add_argument("domain", help="Domain to scan (e.g., example.com)")
    args = parser.parse_args()
  • Add two new argument lines between parser.add_argument("domain", ...) and args = parser.parse_args(), so the block looks like this:
    parser.add_argument("domain", help="Domain to scan (e.g., example.com)")
    parser.add_argument("--json", action="store_true", dest="json_output",
                        help="Output results as JSON")
    parser.add_argument("--no-ai", action="store_true",
                        help="Skip AI analysis (scan only)")
    args = parser.parse_args()
  • Now scroll to the very bottom of main(). Find this line:
    print(json.dumps(scan_results, indent=2, default=str))
  • Replace that single print(json.dumps(...)) line with the Phase 3 AI analysis and output routing below:
    # Phase 3: AI Analysis
    ai_result = {}
    if not args.no_ai:
        print("[*] Phase 3: AI-powered security analysis...")
        ai_result = analyze_with_ai(scan_results)
    else:
        ai_result = {"error": "AI analysis skipped (--no-ai flag)"}

    # Output results
    if args.json_output:
        output = {**scan_results, "ai_analysis": ai_result}
        print(json.dumps(output, indent=2, default=str))
    else:
        print_report(domain, scan_results, ai_result, tls_results)

What do these changes do?

The --json flag outputs the complete scan data plus AI analysis as machine-readable JSON, useful for piping into other tools. The --no-ai flag skips the API call entirely, so you can inspect raw scan results without spending credits.

When neither flag is set, print_report() renders the full formatted report with color-coded findings and the AI analysis.

  • Save scanner.py.
  • First, test the formatting without making an API call by running:
python scanner.py gmail.com --no-ai

You should see a formatted report with a banner showing the domain and scan time, color-coded TLS findings, and a fallback message saying AI analysis was skipped. This confirms the report formatter and CLI flags work correctly.

Seeing a NameError or AttributeError?

  • If you see NameError: name 'print_report' is not defined, make sure print_report() is defined at the module level (not indented inside another function).
  • If you see AttributeError: 'Namespace' object has no attribute 'json_output', check that you added both new parser.add_argument lines before args = parser.parse_args().
  • help me fix an error when running my scanner with the --no-ai flag

Now for the real test. Before you run this, think about what the AI analysis will produce. Will it just repeat the TLS findings, or add new context the raw data does not contain?

  • Run the full scanner with AI analysis enabled:
python scanner.py gmail.com

The scan takes 30-60 seconds as it connects to each MX server on port 25 and then sends the results to Claude. You should see a formatted security posture report with a risk score, letter grade, findings sorted by severity (CRITICAL, HIGH, MEDIUM, LOW, INFO), and specific remediation recommendations for each finding.

Connection timed out on port 25?

Some ISPs block outbound port 25 from residential networks. If every MX host times out, your ISP is likely filtering SMTP traffic. The DNS authentication section of the report still works. You can verify the AI analysis layer by running with the --no-ai flag first to confirm TLS probing is the issue, then try from a different network or a cloud server.

help me troubleshoot SMTP connection timeouts in my scanner

Seeing an API authentication error?

  • Make sure your ANTHROPIC_API_KEY environment variable is set. If you used setx on Windows, you need to restart your terminal for it to take effect.
  • Verify the key starts with sk-ant-. If it does not, you may have copied it incorrectly from the Claude Console.
  • help me fix an Anthropic API authentication error in my scanner

✔️ Awesome, I've got everything!

Double check you have saved scanner.py.

ⓧ I'd like to double check the full code

Your scanner.py is now too long to display in one block. Scroll through your file and confirm each section is present in order:

  • Imports at the top (argparse, json, os, socket, ssl, sys, urllib, datetime, dns.resolver, anthropic).
  • DNS Email Authentication Scanner section: resolve_mx(), check_spf(), check_dmarc(), check_dkim(), check_mta_sts(), scan_dns_auth().
  • SMTP TLS Cryptographic Prober section: probe_smtp_tls(), grade_tls(), scan_tls().
  • AI-Powered Security Analysis section: build_analysis_prompt(), analyze_with_ai().
  • Report Formatting section: SEVERITY_COLORS dict, RESET constant, print_report().
  • Main CLI section: main() with --json and --no-ai flags, Phase 3 AI analysis, and print_report() output routing.

Your scanner now goes from raw data to actionable intelligence in one command. Next up, the Secret Mission challenges you to add DANE/TLSA validation for cutting-edge DNS-based certificate pinning.

Secret mission

Add DANE/TLSA Validation

Your scanner covers SPF, DKIM, DMARC, MTA-STS, and live TLS probing. But the most advanced email transport security mechanism is missing: DANE. In this mission, you will query TLSA DNS records, hash live server certificates, and cross-reference fingerprints to verify certificate pinning. This elevates your tool from standard to cutting-edge.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources, pause them to come back later, or delete them entirely. This project runs locally, and your Anthropic API key only incurs charges when you actively make API calls.

Resources you used:

  • Anthropic API key stored in the Claude Console at platform.claude.com.
  • ANTHROPIC_API_KEY environment variable set on your machine.
  • securemailscope project directory on your Desktop (includes scanner.py and the venv virtual environment).

Keep everything running

No action needed. Choose this if you plan to keep scanning domains or extending SecureMailScope.

  • Your Anthropic API key remains valid until its expiration date. Free credits are consumed only when you make API calls. No charges accrue while the key is idle.
  • The ANTHROPIC_API_KEY environment variable stays set on your machine. It has no effect on other applications.
  • Your local project files stay on your Desktop until you manually delete them.

Pause - I'll come back to this later

No running processes need to be stopped. The API key remains valid and your local files stay on disk.

  • Your Anthropic API key stays active. No charges accrue while you are not making API calls.
  • When you come back, reactivate your virtual environment before running the scanner. Navigate to your securemailscope directory and run venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).

Delete - I don't want to use this again

Remove all project resources and revoke your API key to start fresh.

  • Revoke your Anthropic API key. Go to platform.claude.com and sign in.
  • Click Settings, then API keys.
  • Find your "securemailscope" key in the list and revoke it.

Remove the ANTHROPIC_API_KEY environment variable from your machine.

  • On Windows, press the Windows key, type Environment Variables, and select Edit environment variables for your account. Find ANTHROPIC_API_KEY in the list, select it, and click Delete.
  • On macOS/Linux, run unset ANTHROPIC_API_KEY in your terminal. If you added an export line to your shell profile (.zshrc or .bashrc), remove that line as well.

Delete the project directory from your Desktop.

macOS

  • Delete the project folder by running this command:
rm -rf ~/Desktop/securemailscope

Windows

  • Delete the project folder by running this command:
rmdir /s /q %USERPROFILE%\Desktop\securemailscope

Nice Work!

Nice Work!

Nice work. You just built SecureMailScope, a professional-grade email security posture scanner powered by Python and Claude AI.

You've learned how to:

  • Built a DNS email authentication scanner that audits SPF records (with 10-lookup limit analysis), discovers DKIM selectors, parses DMARC policies, and retrieves MTA-STS configurations for any domain.
  • Built an SMTP STARTTLS prober using Python's ssl and socket modules to extract the negotiated TLS version, cipher suite, key size, and X.509 certificate details from live mail servers.
  • Designed structured AI prompts and integrated Claude Haiku 4.5 to generate professional security posture reports with risk scoring, severity-graded findings, and actionable remediation guidance.
  • Secret Mission: Added DANE/TLSA record validation with certificate fingerprint matching, the most advanced DNS-based email security mechanism.

Ready to quiz yourself?