SpfLookups.ps1

function Get-SpfLookupCount {
    <#
    .SYNOPSIS
        Count SPF include lookups for a domain or SPF record string.
 
    .DESCRIPTION
        Uses Resolve-DnsName to retrieve TXT records and find the SPF record (v=spf1).
        Recursively follows include: directives and counts each DNS lookup performed for includes.
        Enforces a maximum lookup limit (default 10) and avoids infinite loops by tracking visited domains.
 
    .PARAMETER Domain
        The domain to query for an SPF record. If you already have an SPF record string, pass it via -SpfRecord instead.
 
    .PARAMETER SpfRecord
        Provide the SPF record string directly. When supplied, no initial DNS lookup is performed (unless recursive includes require it).
 
    .PARAMETER MaxLookups
        Maximum number of DNS include lookups to perform. Default 10.
 
    .PARAMETER ResolveTimeoutSeconds
        Timeout for Resolve-DnsName calls (in seconds). Default 5.
 
    .OUTPUTS
        PSCustomObject with properties: Domain, SpfRecord, IncludeCount, LookupsPerformed, ReachedLimit, Errors
 
    .EXAMPLE
        Get-SpfLookupCount -Domain 'example.com'
 
    #>

    param(
        [Parameter(Mandatory=$false)] [string] $Domain,
        [Parameter(Mandatory=$false)] [string] $SpfRecord,
        [int] $MaxLookups = 10,
        [int] $ResolveTimeoutSeconds = 5
    )

    if (-not $Domain -and -not $SpfRecord) {
        throw 'Either -Domain or -SpfRecord must be supplied.'
    }

    $state = [pscustomobject]@{
        Domain = $Domain
        SpfRecord = $null
        IncludeCount = 0
        LookupsPerformed = 0
        ReachedLimit = $false
        Errors = @()
        Visited = @{}
    }

    function Get-TxtSpf {
        param([string] $d)
        try {
            # Resolve-DnsName may return multiple records; pick strings that start with v=spf1
            $res = Resolve-DnsName -Name $d -Type TXT -ErrorAction Stop
            $strings = $res | ForEach-Object { $_.Strings } | Where-Object { $_ -match '^v=spf1' }
            if ($strings) {
                return ($strings -join ' ')
            }
            return $null
        }
        catch {
            # Avoid parsing issues by using explicit variable delimiter and string concatenation
            $state.Errors += ('DNS lookup failed for ' + ${d} + ': ' + $_.ToString())
            return $null
        }
    }

    function Process-SpfString {
        param([string] $spf)

        if (-not $spf) { return }

        # split on whitespace but keep quoted parts intact
        $tokens = $spf -split '\s+'
        foreach ($t in $tokens) {
            if ($state.LookupsPerformed -ge $MaxLookups) {
                $state.ReachedLimit = $true
                return
            }

            if ($t -match '^include:(.+)$') {
                $incDomain = $Matches[1]
                if ($state.Visited.ContainsKey($incDomain)) {
                    continue
                }
                $state.Visited[$incDomain] = $true

                # Count this as a lookup
                $state.IncludeCount++
                $state.LookupsPerformed++

                # Retrieve SPF for included domain
                $incSpf = Get-TxtSpf -d $incDomain
                if ($incSpf) {
                    Process-SpfString -spf $incSpf
                    if ($state.ReachedLimit) { return }
                }
            }
            elseif ($t -like 'redirect=*') {
                # Process redirect=target at top level (counts as lookup)
                if ($t -match '^redirect=(.+)$') {
                    $redir = $Matches[1]
                    if (-not $state.Visited.ContainsKey($redir)) {
                        if ($state.LookupsPerformed -ge $MaxLookups) { $state.ReachedLimit = $true; return }
                        $state.Visited[$redir] = $true
                        $state.IncludeCount++
                        $state.LookupsPerformed++
                        $rspf = Get-TxtSpf -d $redir
                        if ($rspf) { Process-SpfString -spf $rspf }
                    }
                }
            }
        }
    }

    # Start: get initial SPF
    if ($SpfRecord) {
        $state.SpfRecord = $SpfRecord
        # If a direct SPF string provided, still process includes inside it
        Process-SpfString -spf $SpfRecord
    }
    else {
        $spf = Get-TxtSpf -d $Domain
        if ($spf) {
            $state.SpfRecord = $spf
            # mark domain as visited so we don't loop
            if ($Domain) { $state.Visited[$Domain] = $true }
            Process-SpfString -spf $spf
        }
        else {
            $state.Errors += "No SPF record found for $Domain"
        }
    }

    # Prepare output (do not expose Visited hashtable)
    return [pscustomobject]@{
        Domain = $state.Domain
        SpfRecord = $state.SpfRecord
        IncludeCount = $state.IncludeCount
        LookupsPerformed = $state.LookupsPerformed
        ReachedLimit = $state.ReachedLimit
        Errors = $state.Errors
    }
}

    # Export-ModuleMember -Function Get-SpfLookupCount
    # Note: exporting is commented out so this file can be dot-sourced in a session.