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 |