MMedia.Deliverability.psm1

#Requires -Version 5.1
<#
.SYNOPSIS
  Email deliverability diagnostics - SPF, DKIM, DMARC, MX, PTR, blacklist sweep.
  M Media Software Lab - https://www.mmediasoftwarelab.com
#>

Set-StrictMode -Version Latest

#region Module-level data

$Script:COMMON_SELECTORS = @(
    'google', 'selector1', 'selector2', 'default', 'mail', 'dkim',
    'k1', 'k2', 'smtp', 'email', 's1', 's2', 'mailjet', 'sendgrid',
    'amazonses', 'mandrill', 'postmark'
)

$Script:RBL_LISTS = @(
    'zen.spamhaus.org',
    'b.barracudacentral.org',
    'bl.spamcop.net',
    'dnsbl.sorbs.net',
    'dul.dnsbl.sorbs.net',
    'smtp.dnsbl.sorbs.net',
    'spam.dnsbl.sorbs.net',
    'http.dnsbl.sorbs.net',
    'misc.dnsbl.sorbs.net',
    'socks.dnsbl.sorbs.net',
    'web.dnsbl.sorbs.net',
    'zombie.dnsbl.sorbs.net',
    'dnsbl-1.uceprotect.net',
    'dnsbl-2.uceprotect.net',
    'dnsbl-3.uceprotect.net',
    'psbl.surriel.com',
    'ubl.lashback.com',
    'multi.uribl.com',
    'bl.mailspike.net',
    'z.mailspike.net',
    'bl.0spam.org',
    'spam.abuse.ch',
    'drone.abuse.ch',
    'cbl.abuseat.org',
    'bl.nordspam.com',
    'ips.backscatterer.org',
    'noptr.spamrats.com',
    'dyna.spamrats.com',
    'spam.spamrats.com',
    'bl.blocklist.de',
    'bl.spameatingmonkey.net',
    'db.wpbl.info',
    'rbl.interserver.net',
    'dnsbl.justspam.org',
    'dnsbl.spfbl.net',
    'ix.dnsbl.manitu.net',
    'truncate.gbudb.net',
    'bl.spamcannibal.org',
    'dnsbl.inps.de',
    'pofon.fmb.la',
    'orvedb.aupads.org'
)

#endregion

#region Internal helpers

function New-CheckResult {
    param(
        [string]$Name,
        [string]$Status,
        [string]$Record          = '',
        [hashtable]$Details      = @{},
        [string[]]$Issues        = @(),
        [string[]]$Recommendations = @()
    )
    [PSCustomObject]@{
        PSTypeName      = 'MMedia.Deliverability.Result'
        Name            = $Name
        Status          = $Status
        Record          = $Record
        Details         = $Details
        Issues          = $Issues
        Recommendations = $Recommendations
    }
}

function Invoke-DnsTxtLookup {
    param([string]$Name)
    try {
        $records = Resolve-DnsName -Name $Name -Type TXT -ErrorAction Stop
        $records |
            Where-Object { $_.Type -eq 'TXT' } |
            ForEach-Object {
                if ($_.Strings) { $_.Strings -join '' }
                elseif ($null -ne $_.Text) { $_.Text }
            } |
            Where-Object { $_ }
    } catch {
        @()
    }
}

function Invoke-DnsMxLookup {
    param([string]$Name)
    try {
        Resolve-DnsName -Name $Name -Type MX -ErrorAction Stop |
            Where-Object { $_.Type -eq 'MX' } |
            Sort-Object Preference
    } catch {
        @()
    }
}

function Invoke-DnsALookup {
    param([string]$Name)
    try {
        Resolve-DnsName -Name $Name -Type A -ErrorAction Stop |
            Where-Object { $_.Type -eq 'A' } |
            Select-Object -ExpandProperty IPAddress
    } catch {
        @()
    }
}

function Invoke-DnsPtrLookup {
    param([string]$IP)
    try {
        $parts   = $IP.Split('.')
        $arpa    = "$($parts[3]).$($parts[2]).$($parts[1]).$($parts[0]).in-addr.arpa"
        $result  = Resolve-DnsName -Name $arpa -Type PTR -ErrorAction Stop
        $result | Where-Object { $_.Type -eq 'PTR' } |
            Select-Object -First 1 -ExpandProperty NameHost
    } catch {
        $null
    }
}

function Get-ReversedIP {
    param([string]$IP)
    $p = $IP.Split('.')
    "$($p[3]).$($p[2]).$($p[1]).$($p[0])"
}

#endregion

#region Public cmdlets

function Get-SPFRecord {
    <#
    .SYNOPSIS
      Checks the SPF record for a domain.
    .EXAMPLE
      Get-SPFRecord mmediausa.com
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Result')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain
    )
    process {
        $Domain = $Domain.Trim().ToLower()
        $txts   = Invoke-DnsTxtLookup -Name $Domain
        $spf    = $txts | Where-Object { $_ -match '^v=spf1' } | Select-Object -First 1

        if (-not $spf) {
            return New-CheckResult -Name 'SPF' -Status 'missing' `
                -Issues @('No SPF record found') `
                -Recommendations @('Add a TXT record: v=spf1 include:your-provider.com ~all')
        }

        $issues = [System.Collections.Generic.List[string]]::new()
        $recs   = [System.Collections.Generic.List[string]]::new()
        $status = 'pass'

        if ($spf -match '\+all') {
            $issues.Add('+all permits any server to send mail - critical misconfiguration')
            $status = 'fail'
        } elseif ($spf -notmatch '[-~?]all') {
            $issues.Add('No explicit all mechanism found')
            $recs.Add('End your SPF record with ~all or -all')
            if ($status -ne 'fail') { $status = 'warning' }
        }

        $lookups = ([regex]::Matches($spf, '\b(include|a|mx|ptr|exists):')).Count
        if ($lookups -gt 10) {
            $issues.Add("SPF record has $lookups DNS lookups (max allowed: 10)")
            $status = 'fail'
        } elseif ($lookups -gt 8) {
            $recs.Add("$lookups DNS lookups - approaching the 10-lookup limit")
            if ($status -eq 'pass') { $status = 'warning' }
        }

        New-CheckResult -Name 'SPF' -Status $status -Record $spf `
            -Issues $issues.ToArray() -Recommendations $recs.ToArray()
    }
}

function Get-DKIMRecord {
    <#
    .SYNOPSIS
      Checks for a DKIM public key record. Auto-detects common selectors.
    .EXAMPLE
      Get-DKIMRecord mmediausa.com
    .EXAMPLE
      Get-DKIMRecord mmediausa.com -Selector google
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Result')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain,
        [string]$Selector = ''
    )
    process {
        $Domain    = $Domain.Trim().ToLower()
        $selectors = if ($Selector) { @($Selector) } else { $Script:COMMON_SELECTORS }
        $found     = $null
        $foundSel  = ''

        foreach ($sel in $selectors) {
            $txts = Invoke-DnsTxtLookup -Name "$sel._domainkey.$Domain"
            $rec  = $txts | Where-Object { $_ -match 'v=DKIM1' -or $_ -match 'k=rsa' -or $_ -match '\bp=' } |
                    Select-Object -First 1
            if ($rec) { $found = $rec; $foundSel = $sel; break }
        }

        if (-not $found) {
            $msg = if ($Selector) { "No DKIM record found for selector '$Selector'" } `
                   else { 'No DKIM record found (tried common selectors)' }
            return New-CheckResult -Name 'DKIM' -Status 'missing' `
                -Issues @($msg) `
                -Recommendations @('Configure DKIM signing with your email provider and publish the public key TXT record')
        }

        $issues  = [System.Collections.Generic.List[string]]::new()
        $recs    = [System.Collections.Generic.List[string]]::new()
        $status  = 'pass'
        $details = @{ selector = $foundSel }

        if ($found -match 'p=([A-Za-z0-9+/=]+)') {
            $keyB64 = $Matches[1]
            $keyBits = [int][Math]::Floor($keyB64.Length * 6 / 8) * 8
            $details['key_length_estimate'] = $keyBits
            if ($keyBits -lt 1024) {
                $issues.Add("DKIM key appears short (~$keyBits bits) - use 2048-bit keys")
                $status = 'warning'
            }
        }

        $details['key_type'] = if ($found -match 'k=([a-z]+)') { $Matches[1] } else { 'rsa' }

        New-CheckResult -Name 'DKIM' -Status $status -Record $found `
            -Details $details -Issues $issues.ToArray() -Recommendations $recs.ToArray()
    }
}

function Get-DMARCRecord {
    <#
    .SYNOPSIS
      Checks the DMARC record for a domain.
    .EXAMPLE
      Get-DMARCRecord mmediausa.com
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Result')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain
    )
    process {
        $Domain = $Domain.Trim().ToLower()
        $txts   = Invoke-DnsTxtLookup -Name "_dmarc.$Domain"
        $dmarc  = $txts | Where-Object { $_ -match '^v=DMARC1' } | Select-Object -First 1

        if (-not $dmarc) {
            return New-CheckResult -Name 'DMARC' -Status 'missing' `
                -Issues @('No DMARC record found') `
                -Recommendations @('Add a TXT record at _dmarc.yourdomain.com: v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com')
        }

        $issues  = [System.Collections.Generic.List[string]]::new()
        $recs    = [System.Collections.Generic.List[string]]::new()
        $status  = 'pass'
        $details = @{}

        if ($dmarc -match 'p=([a-z]+)') {
            $policy = $Matches[1]
            $details['policy'] = $policy
            if ($policy -eq 'none') {
                $recs.Add('Policy is "none" (monitor only) - upgrade to quarantine or reject')
                $status = 'warning'
            }
        } else {
            $issues.Add('No policy (p=) tag found in DMARC record')
            $status = 'fail'
        }

        if ($dmarc -match 'sp=([a-z]+)') { $details['subdomain_policy'] = $Matches[1] }

        if ($dmarc -match 'pct=(\d+)') {
            $pct = [int]$Matches[1]
            $details['pct'] = $pct
            if ($pct -lt 100 -and $status -eq 'pass') {
                $recs.Add("pct=$pct - policy applies to only $pct% of messages")
                $status = 'warning'
            }
        }

        if ($dmarc -notmatch 'rua=') {
            $recs.Add('No rua= tag - add a reporting address to receive aggregate DMARC reports')
            if ($status -eq 'pass') { $status = 'warning' }
        }

        New-CheckResult -Name 'DMARC' -Status $status -Record $dmarc `
            -Details $details -Issues $issues.ToArray() -Recommendations $recs.ToArray()
    }
}

function Get-MXRecord {
    <#
    .SYNOPSIS
      Checks MX records for a domain.
    .EXAMPLE
      Get-MXRecord mmediausa.com
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Result')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain
    )
    process {
        $Domain = $Domain.Trim().ToLower()
        $mx     = Invoke-DnsMxLookup -Name $Domain

        if (-not $mx -or @($mx).Count -eq 0) {
            return New-CheckResult -Name 'MX' -Status 'missing' `
                -Issues @('No MX records found') `
                -Recommendations @('Add MX records pointing to your mail server(s)')
        }

        $mxArr  = @($mx)
        $entries = $mxArr | ForEach-Object {
            [PSCustomObject]@{ priority = $_.Preference; host = $_.NameExchange }
        }
        $status = if ($mxArr.Count -eq 1) { 'warning' } else { 'pass' }
        $recs   = if ($mxArr.Count -eq 1) { @('Only one MX record - consider adding a backup MX') } else { @() }

        New-CheckResult -Name 'MX' -Status $status `
            -Details @{ records = $entries; count = $mxArr.Count } `
            -Recommendations $recs
    }
}

function Get-PTRRecord {
    <#
    .SYNOPSIS
      Checks reverse DNS (PTR) records for a domain's mail server IPs.
    .EXAMPLE
      Get-PTRRecord mmediausa.com
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Result')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain
    )
    process {
        $Domain = $Domain.Trim().ToLower()
        $ips    = @(Invoke-DnsALookup -Name $Domain)

        if ($ips.Count -eq 0) {
            return New-CheckResult -Name 'PTR' -Status 'error' `
                -Issues @("Could not resolve any IP addresses for $Domain")
        }

        $issues  = [System.Collections.Generic.List[string]]::new()
        $recs    = [System.Collections.Generic.List[string]]::new()
        $entries = [System.Collections.Generic.List[object]]::new()
        $status  = 'pass'

        foreach ($ip in $ips) {
            $ptr = Invoke-DnsPtrLookup -IP $ip
            $entries.Add([PSCustomObject]@{ ip = $ip; ptr = if ($ptr) { $ptr } else { '' } })
            if (-not $ptr) {
                $issues.Add("No PTR record for $ip")
                $status = 'fail'
            }
        }

        if ($status -eq 'pass') {
            $recs.Add('Ensure PTR hostnames match the HELO/EHLO name used by your mail server')
        }

        New-CheckResult -Name 'PTR' -Status $status `
            -Details @{ entries = $entries.ToArray() } `
            -Issues $issues.ToArray() -Recommendations $recs.ToArray()
    }
}

function Invoke-RBLCheck {
    <#
    .SYNOPSIS
      Sweeps 41 DNS blacklists for a domain's IPs.
    .EXAMPLE
      Invoke-RBLCheck mmediausa.com
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Result')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain
    )
    process {
        $Domain = $Domain.Trim().ToLower()
        $ips    = @(Invoke-DnsALookup -Name $Domain)

        if ($ips.Count -eq 0) {
            return New-CheckResult -Name 'RBL' -Status 'error' `
                -Issues @("Could not resolve any IP addresses for $Domain")
        }

        $listsChecked = 0
        $hitDetails   = [System.Collections.Generic.List[string]]::new()

        foreach ($ip in $ips) {
            $rev = Get-ReversedIP -IP $ip

            if ($PSVersionTable.PSVersion.Major -ge 7) {
                # Parallel sweep on PowerShell 7+
                $rblLists = $Script:RBL_LISTS
                $revCopy  = $rev
                $ipCopy   = $ip
                $hits = $rblLists | ForEach-Object -Parallel {
                    $query = "$using:revCopy.$_"
                    try {
                        $r = Resolve-DnsName -Name $query -Type A -ErrorAction Stop
                        if ($r) { "$_ ($using:ipCopy)" }
                    } catch {}
                } -ThrottleLimit 50
                $listsChecked += $rblLists.Count
                foreach ($h in $hits) { if ($h) { $hitDetails.Add($h) } }
            } else {
                # Sequential fallback for PS 5.1
                foreach ($list in $Script:RBL_LISTS) {
                    $query = "$rev.$list"
                    try {
                        $r = Resolve-DnsName -Name $query -Type A -ErrorAction Stop
                        if ($r) { $hitDetails.Add("$list ($ip)") }
                    } catch {}
                    $listsChecked++
                }
            }
        }

        $hitCount = $hitDetails.Count
        $status   = if ($hitCount -gt 0) { 'fail' } else { 'pass' }
        $issues   = $hitDetails | ForEach-Object { "Listed on $_" }
        $recs     = if ($hitCount -gt 0) { @('Visit each blacklist provider directly to request delisting') } else { @() }

        New-CheckResult -Name 'RBL' -Status $status `
            -Details @{ lists_checked = $listsChecked; hits = $hitCount } `
            -Issues $issues -Recommendations $recs
    }
}

function Invoke-DeliverabilityCheck {
    <#
    .SYNOPSIS
      Runs a full email deliverability check (SPF, DKIM, DMARC, MX, PTR) for a domain.
    .EXAMPLE
      Invoke-DeliverabilityCheck mmediausa.com
    .EXAMPLE
      Invoke-DeliverabilityCheck mmediausa.com -Selector google
    .EXAMPLE
      Invoke-DeliverabilityCheck mmediausa.com | Select-Object -ExpandProperty Results | Format-Table Name,Status,Issues
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Suite')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain,
        [string]$Selector = '',
        [switch]$Json
    )
    process {
        $Domain = $Domain.Trim().ToLower()

        Write-Progress -Activity 'emlcheck' -Status 'Checking SPF...'   -PercentComplete 10
        $spf   = Get-SPFRecord   -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Checking DKIM...'  -PercentComplete 30
        $dkim  = Get-DKIMRecord  -Domain $Domain -Selector $Selector

        Write-Progress -Activity 'emlcheck' -Status 'Checking DMARC...' -PercentComplete 50
        $dmarc = Get-DMARCRecord -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Checking MX...'    -PercentComplete 70
        $mx    = Get-MXRecord    -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Checking PTR...'   -PercentComplete 90
        $ptr   = Get-PTRRecord   -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Completed

        $suite = [PSCustomObject]@{
            PSTypeName = 'MMedia.Deliverability.Suite'
            Domain     = $Domain
            Results    = @($spf, $dkim, $dmarc, $mx, $ptr)
            CheckedAt  = (Get-Date -Format 'o')
        }

        if ($Json) { $suite | ConvertTo-Json -Depth 10 } else { $suite }
    }
}

function Invoke-DeliverabilityCheckFull {
    <#
    .SYNOPSIS
      Runs a full email deliverability check including blacklist sweep (41 lists).
    .EXAMPLE
      Invoke-DeliverabilityCheckFull mmediausa.com
    #>

    [CmdletBinding()]
    [OutputType('MMedia.Deliverability.Suite')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]$Domain,
        [string]$Selector = '',
        [switch]$Json
    )
    process {
        $Domain = $Domain.Trim().ToLower()

        Write-Progress -Activity 'emlcheck' -Status 'Checking SPF...'         -PercentComplete 10
        $spf   = Get-SPFRecord   -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Checking DKIM...'        -PercentComplete 20
        $dkim  = Get-DKIMRecord  -Domain $Domain -Selector $Selector

        Write-Progress -Activity 'emlcheck' -Status 'Checking DMARC...'       -PercentComplete 30
        $dmarc = Get-DMARCRecord -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Checking MX...'          -PercentComplete 45
        $mx    = Get-MXRecord    -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Checking PTR...'         -PercentComplete 55
        $ptr   = Get-PTRRecord   -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Status 'Sweeping blacklists...'  -PercentComplete 65
        $rbl   = Invoke-RBLCheck -Domain $Domain

        Write-Progress -Activity 'emlcheck' -Completed

        $suite = [PSCustomObject]@{
            PSTypeName = 'MMedia.Deliverability.Suite'
            Domain     = $Domain
            Results    = @($spf, $dkim, $dmarc, $mx, $ptr, $rbl)
            CheckedAt  = (Get-Date -Format 'o')
        }

        if ($Json) { $suite | ConvertTo-Json -Depth 10 } else { $suite }
    }
}

#endregion