Get-MailRecords.psm1

<#
.SYNOPSIS
v2.0.0 — Queries mail DNS records (MX, SPF, DKIM, DMARC, BIMI, NS, MTA and TLS) for a domain or email.

.DESCRIPTION
Performs a comprehensive audit of mail-related DNS records. Includes FCrDNS (Forward-Confirmed
Reverse DNS) validation and DKIM selector auto-discovery.

By default, subdomains are stripped to the base domain (mail.example.com → example.com).
Use -Sub to check both, or -JustSub to check only the subdomain as given.

.PARAMETER Domain
The target domain, email address, or URL. Alias: -d

.PARAMETER Sub
Check both the provided subdomain and the base domain. Alias: -s

.PARAMETER JustSub
Check only the domain exactly as provided; skips base domain extraction. Alias: -js

.PARAMETER Selector
Explicit DKIM selector. If omitted, common selectors are tried automatically. Alias: -sel

.PARAMETER DkimSelectors
List of DKIM selectors to try when no explicit -Selector is provided. Alias: -dkim

.PARAMETER RecordType
Type to query (TXT, CNAME, or BOTH). Default: TXT. Alias: -rt, -r

.PARAMETER Server
DNS server to query. Default: 8.8.8.8. Alias: -srv

.PARAMETER Export
Export to 'CSV' or 'JSON'. Provide a filename or just the format. Alias: -e

.EXAMPLE
Get-MailRecords -Domain example.com
Basic lookup for a single domain.

.EXAMPLE
GMR -d mail.example.com -Sub -Export results.csv
Queries subdomain + base domain and saves to a CSV file.

.EXAMPLE
"google.com", "microsoft.com" | Get-MailRecords -r BOTH
Pipes multiple domains and checks both TXT and CNAME records.

.LINK
https://github.com/dcazman/Get-MailRecords

.NOTES
Author: Dan Casmas | gmr.thecasmas.com
Requires Resolve-DnsName (Windows) or dig (Linux/macOS).
PTR performs an FCrDNS check: '===' (Match) or '=/=' (Mismatch).
#>

function Get-MailRecords {
    [Alias("GMR")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, HelpMessage = "Enter the full domain name, email address, or URL.", ValueFromPipeline = $true, Position = 0)]
        [Alias('d')]
        [string]$Domain,

        [Parameter(HelpMessage = "Also check the base domain when a subdomain is provided.")]
        [Alias('s')]
        [switch]$Sub,

        [Parameter(HelpMessage = "Check only the domain exactly as provided; skip base domain extraction.")]
        [Alias('js')]
        [switch]$JustSub,

        [Parameter(HelpMessage = "Explicit DKIM selector to query. If omitted, common selectors are tried automatically.")]
        [Alias('sel')]
        [string]$Selector,

        [Parameter(HelpMessage = "List of DKIM selectors to try when no explicit -Selector is provided.")]
        [Alias('dkim')]
        [string[]]$DkimSelectors = @(
            "default", "s", "s1", "s2", "selector1", "selector2", "pps1", "google",
            "everlytickey1", "everlytickey2", "eversrv", "k1", "mxvault", "dkim",
            "mail", "s1024", "s2048", "s4096"
        ),

        [Parameter(HelpMessage = "DNS record type to query: TXT, CNAME, or BOTH. Default: TXT.")]
        [ValidateSet('TXT', 'CNAME', 'BOTH')]
        [Alias('rt', 'r')]
        [string]$RecordType = 'TXT',

        [Parameter(HelpMessage = "DNS server to query. Default: 8.8.8.8.")]
        [Alias('srv')]
        [string]$Server = '8.8.8.8',

        [Parameter(HelpMessage = "Export results to CSV or JSON. Provide a filename (e.g. results.csv) or just the format (csv or json).")]
        [Alias('e')]
        [string]$Export
    )

    begin {
        # DNS method detection
        if (Get-Command Resolve-DnsName -ErrorAction SilentlyContinue) {
            $script:DnsMethod = 'ResolveDnsName'
        }
        elseif (Get-Command dig -ErrorAction SilentlyContinue) {
            $script:DnsMethod = 'dig'
        }
        else {
            throw "Neither Resolve-DnsName nor dig found."
        }

        $script:AllResults = [System.Collections.Generic.List[object]]::new()

        function Get-TxtValue($name, $pattern) {
            $raw = Invoke-DnsQuery $name 'TXT' $Server
            if (-not $raw) { return 'None' }

            $val = $raw | ForEach-Object { $_.Strings } | Where-Object { $_ -like $pattern } | Select-Object -First 1
            return $val ?? 'None'
        }

        # Export handling
        $ExportFormat = $null
        $OutputPath = $null

        if ($Export) {
            if ($Export -match '\.(csv|json)$') {
                $OutputPath = $Export
                $ExportFormat = ($Export -split '\.')[-1].ToUpper()
            }
            elseif ($Export -match '^(csv|json)$') {
                $ExportFormat = $Export.ToUpper()
                $OutputPath = Join-Path (Get-Location).Path ("MailRecords_{0}.{1}" -f (Get-Date -Format 'yyyyMMdd_HHmm'), $Export.ToLower())
            }
            else {
                Write-Warning "Invalid -Export value '$Export'."
            }
        }

        function Invoke-DnsQuery {
            param(
                [string]$Name,
                [string]$Type,
                [string]$Server
            )

            if ($Type -eq 'BOTH') { $Type = 'TXT' }

            if ($script:DnsMethod -eq 'ResolveDnsName') {
                return Resolve-DnsName -Name $Name -Type $Type -Server $Server -DnsOnly -ErrorAction SilentlyContinue
            }

            $output = & dig "@$Server" "+noall" "+answer" "+time=2" "+tries=1" "-t" $Type.ToUpper() $Name 2>$null
            $results = [System.Collections.Generic.List[object]]::new()

            foreach ($line in $output) {
                if ($line -match '^(\S+)\s+(\d+)\s+IN\s+(\S+)\s+(.+)$') {
                    $obj = [PSCustomObject]@{
                        Name = $Matches[1].TrimEnd('.')
                        Type = $Matches[3].ToUpper()
                        TTL  = [int]$Matches[2]
                    }

                    switch ($obj.Type) {
                        'A' {
                            $obj | Add-Member -NotePropertyName IPAddress -NotePropertyValue $Matches[4].Trim()
                        }
                        'MX' {
                            if ($Matches[4] -match '^(\d+)\s+(\S+)$') {
                                $obj | Add-Member Preference ([int]$Matches[1])
                                $obj | Add-Member NameExchange $Matches[2].TrimEnd('.')
                            }
                        }
                        'CNAME' {
                            $obj | Add-Member NameHost $Matches[4].Trim().TrimEnd('.')
                        }
                        'NS' {
                            $obj | Add-Member NameHost $Matches[4].Trim().TrimEnd('.')
                        }
                        'TXT' {
                            $parts = [regex]::Matches($Matches[4], '"([^"]*)"|(\S+)') |
                            ForEach-Object {
                                if ($_.Groups[1].Success) { $_.Groups[1].Value }
                                else { $_.Groups[2].Value }
                            }
                            $obj | Add-Member Strings @($parts)
                        }
                    }

                    $results.Add($obj)
                }
            }

            return $results.ToArray()
        }

        function Get-PTR {
            param($IP, $Server)

            if ($IP -eq 'None') {
                return @{ Host = 'None'; Status = 'None' }
            }

            $thehost = $null

            if ($script:DnsMethod -eq 'ResolveDnsName') {
                $ptr = Resolve-DnsName $IP -Type PTR -Server $Server -ErrorAction SilentlyContinue | Select-Object -First 1
                $thehost = $ptr.NameHost
            }
            else {
                $thehost = (& dig "@$Server" "+short" "-x" $IP)[0]
                if ($thehost) { $thehost = $thehost.TrimEnd('.') }
            }

            if (-not $thehost) {
                return @{ Host = 'None'; Status = 'None' }
            }

            $fwd = Invoke-DnsQuery -Name $thehost -Type 'A' -Server $Server
            $ips = $fwd | Select-Object -ExpandProperty IPAddress -ErrorAction SilentlyContinue

            $status = if ($ips -contains $IP) { '===' } else { '=/=' }

            return @{ Host = $thehost; Status = $status }
        }
    }

    process {
        # Normalize input
        $CleanDomain = $Domain -replace '^https?://', '' -replace '^www\.', '' -replace '^.*@', ''
        $CleanDomain = $CleanDomain.Split('/')[0].ToLowerInvariant().Trim()

        # Base domain heuristic
        $parts = $CleanDomain.Split('.')
        $baseDomain = if ($parts.Count -gt 2 -and $parts[-2].Length -eq 2 -and $parts[-1].Length -le 3) {
            $parts[-3..-1] -join '.'
        }
        else {
            $parts[-2, -1] -join '.'
        }

        $isSub = $baseDomain -ne $CleanDomain

        # Target queue
        if ($JustSub) {
            $targets = @($CleanDomain)
        }
        elseif ($Sub -and $isSub) {
            $targets = @($CleanDomain, $baseDomain)
        }
        else {
            $targets = @($baseDomain)
        }

        foreach ($Target in $targets) {

            $RecordTypes = if ($RecordType -ieq 'BOTH') { @('TXT', 'CNAME') } else { @($RecordType.ToUpper()) }

            # Core DNS
            $mx = Invoke-DnsQuery -Name $Target -Type 'MX' -Server $Server | Where-Object { $_.Type -eq 'MX' } | Sort-Object Preference

            $mxA = if ($mx -and $mx[0].NameExchange) {
                (Invoke-DnsQuery -Name $mx[0].NameExchange -Type 'A' -Server $Server |
                Select-Object -ExpandProperty IPAddress -First 1) ?? 'None'
            }
            else { 'None' }

            $ptr = Get-PTR -IP $mxA -Server $Server
            $ptrDisplay = if ($mxA -ne 'None') { "$($ptr.Host) $($ptr.Status) $mxA" } else { 'None' }

            $nsItems = Invoke-DnsQuery -Name $Target -Type 'NS' -Server $Server | Where-Object { $_.Type -eq 'NS' } | Select-Object -First 2
            $ns = if ($nsItems) {
                ($nsItems | ForEach-Object { "$($_.NameHost) [TTL $($_.TTL)]" }) -join " | "
            }
            else { 'None' }

            $bimi = Get-TxtValue "default._bimi.$Target" "v=BIMI1*"
            $mtaSts = Get-TxtValue "_mta-sts.$Target"   "v=STSv1*"
            $tlsRpt = Get-TxtValue "_smtp._tls.$Target" "v=TLSRPTv1*"

            foreach ($RT in $RecordTypes) {

                $spfRaw = Invoke-DnsQuery -Name $Target -Type $RT -Server $Server | Where-Object { $_.Type -eq $RT }
                $dmRaw = Invoke-DnsQuery -Name "_dmarc.$Target" -Type $RT -Server $Server | Where-Object { $_.Type -eq $RT }

                # SPF
                if ($RT -eq 'TXT') {
                    $spf = ($spfRaw | ForEach-Object { $_.Strings } | Where-Object { $_ -like "v=spf1*" } | Select-Object -First 1) ?? 'None'
                }
                else {
                    $c = $spfRaw | Where-Object { $_.Type -eq 'CNAME' } | Select-Object -First 1
                    $spf = if ($c) {
                        (Invoke-DnsQuery -Name $c.NameHost -Type 'TXT' -Server $Server |
                        ForEach-Object { $_.Strings } |
                        Where-Object { $_ -like "v=spf1*" } |
                        Select-Object -First 1) ?? 'None'
                    }
                    else { 'None' }
                }

                # DMARC
                if ($RT -eq 'TXT') {
                    $dmarc = ($dmRaw | ForEach-Object { $_.Strings } | Where-Object { $_ -like "v=DMARC1*" } | Select-Object -First 1) ?? 'None'
                }
                else {
                    $c = $dmRaw | Where-Object { $_.Type -eq 'CNAME' } | Select-Object -First 1
                    $dmarc = if ($c) {
                        (Invoke-DnsQuery -Name $c.NameHost -Type 'TXT' -Server $Server |
                        ForEach-Object { $_.Strings } |
                        Where-Object { $_ -like "v=DMARC1*" } |
                        Select-Object -First 1) ?? 'None'
                    }
                    else { 'None' }
                }

                # DKIM
                $dkimResult = 'None'
                $finalSel = if ($Selector) { $Selector } else { 'unprovided' }

                $selectors = if ($Selector) { @($Selector) } else { $DkimSelectors }

                foreach ($s in $selectors) {
                    $dk = Invoke-DnsQuery -Name "$s._domainkey.$Target" -Type $RT -Server $Server
                    if (-not $dk) { continue }

                    if ($RT -eq 'TXT') {
                        $rec = $dk | Where-Object { ($_.Strings -join '') -match "v=DKIM1" } | Select-Object -First 1
                    }
                    else {
                        $c = $dk | Where-Object { $_.Type -eq 'CNAME' } | Select-Object -First 1
                        if ($c) {
                            $rec = Invoke-DnsQuery -Name $c.NameHost -Type 'TXT' -Server $Server |
                            Where-Object { ($_.Strings -join '') -match "v=DKIM1" } |
                            Select-Object -First 1
                        }
                    }

                    if ($rec) {
                        $dkimResult = ($rec.Strings -join '')
                        $finalSel = $s
                        break
                    }
                }

                $result = [PSCustomObject]@{
                    DOMAIN      = $Target
                    SERVER      = $Server
                    RECORDTYPE  = $RT
                    MX_A        = $mxA
                    PTR         = $ptrDisplay
                    MX          = if ($mx) { ($mx | ForEach-Object { "$($_.NameExchange) [pref $($_.Preference)]" }) -join " | " } else { 'None' }
                    "SPF_$RT"   = $spf
                    "DMARC_$RT" = $dmarc
                    "DKIM_$RT"  = $dkimResult
                    SELECTOR    = $finalSel
                    BIMI        = $bimi
                    NS_First2   = $ns
                    MTA_STS     = $mtaSts
                    TLS_RPT     = $tlsRpt
                }

                if ($ExportFormat) {
                    $script:AllResults.Add($result)
                }
                else {
                    $result
                }
            }
        }
    }

    end {
        if ($ExportFormat -and $script:AllResults.Count -gt 0) {
            if ($ExportFormat -eq 'CSV') {
                $script:AllResults | Export-Csv -Path $OutputPath -NoTypeInformation -Force
            }
            else {
                $script:AllResults | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Force
            }
            Write-Host "Results exported to: $OutputPath" -ForegroundColor Green
        }
    }
}