Get-MailRecords.psm1

<#
.SYNOPSIS
Performs DNS lookups (A, MX, NS, SPF, DMARC, DKIM) for a domain, email, or URL.
 
.DESCRIPTION
Checks for common mail-related DNS records on a given domain, email address, or URL.
Supports record types TXT, CNAME, or BOTH for SPF, DMARC, and DKIM.
If a DKIM selector is not provided, common selectors are tried automatically.
Function alias: GMR. Parameter aliases: -d (Domain), -s (Sub), -js (JustSub), -sel (Selector), -r (RecordType), -srv (Server), -e (Export).
 
.PARAMETER Domain
The full domain name, email address, or URL to query. Mandatory. Alias: -d
 
.PARAMETER Sub
Query both the subdomain and the base domain. For example, mail.facebook.com will return results for mail.facebook.com AND facebook.com. Alias: -s
 
.PARAMETER JustSub
Query only the subdomain — skips the base domain lookup. For example, mail.facebook.com returns results for mail.facebook.com only. Alias: -js
 
.PARAMETER Selector
The DKIM selector to use. If not provided, common selectors are tried automatically. Alias: -sel
 
.PARAMETER RecordType
Record type(s) to query for SPF, DMARC, and DKIM.
Valid options: 'TXT', 'CNAME', 'BOTH'. Default: 'TXT'. Alias: -r
 
.PARAMETER Server
DNS server to query. Default: 8.8.8.8. Alias: -srv
 
.PARAMETER Export
Export results to file. Provide a filename (e.g., 'results.csv', 'output.json') or just the format ('CSV', 'JSON') for auto-generated timestamped filename. Alias: -e
 
.EXAMPLE
# Get basic mail records for facebook.com
Get-MailRecords -Domain facebook.com
GMR -Domain facebook.com
GMR -d facebook.com
 
.EXAMPLE
# Query both the subdomain and the base domain
Get-MailRecords -Domain mail.facebook.com -Sub
GMR -d mail.facebook.com -s
 
.EXAMPLE
# Query only the subdomain, skip the base domain
Get-MailRecords -Domain mail.facebook.com -JustSub
GMR -d mail.facebook.com -js
 
.EXAMPLE
# Get DKIM record with explicit selector
Get-MailRecords -Domain cnn.facebook.com -Selector face
GMR -d cnn.facebook.com -sel face
 
.EXAMPLE
# Use a custom DNS server
Get-MailRecords -Domain cnn.com -Server 1.1.1.1
GMR -d cnn.com -srv 1.1.1.1
 
.EXAMPLE
# Get CNAME records for SPF/DMARC/DKIM
Get-MailRecords -Domain cnn.com -RecordType CNAME
GMR -d cnn.com -r CNAME
 
.EXAMPLE
# Get only subdomain records (exclude parent domain)
Get-MailRecords -Domain mail.facebook.com -JustSub
GMR -d mail.facebook.com -js
 
.EXAMPLE
# Export results to a specific CSV file
Get-MailRecords -Domain example.com -Export results.csv
GMR -d example.com -e results.csv
 
.EXAMPLE
# Export with auto-generated timestamped filename
Get-MailRecords -Domain example.com -Export CSV
GMR -d example.com -e CSV
 
.EXAMPLE
# Check multiple domains via pipeline and export to JSON
"google.com", "microsoft.com", "amazon.com" | Get-MailRecords -Export output.json
 
.EXAMPLE
# Bulk check from CSV file and export results
Import-Csv domains.csv | Get-MailRecords -Export results.csv
 
.EXAMPLE
# Prompt for domain interactively
GMR
 
.LINK
https://github.com/dcazman/Get-MailRecords
 
.NOTES
Author: Dan Casmas (07/2023)
Tested on Windows PowerShell 5.1 and PowerShell 7 (Windows, Linux, macOS).
Minimum required version: 5.1.
Requires Resolve-DnsName (Windows built-in) or dig (Linux/macOS: install bind-utils or dnsutils).
Function alias: GMR.
Parameter aliases: -d (Domain), -s (Sub), -js (JustSub), -sel (Selector), -r (RecordType), -srv (Server), -e (Export).
To add more DKIM selectors, edit $DkimSelectors near the top of the script.
Only the first two NS results are returned.
CNAME record types will follow the CNAME chain to retrieve the final TXT record value.
Note: Multi-part TLDs (e.g., .co.uk, .com.au) are handled for common cases, but use -Sub for complex domains.
Portions of code adapted from Jordan W.
#>

function Get-MailRecords {
    [Alias("GMR")]
    [CmdletBinding()]
    param (
        [parameter(Mandatory = $true, HelpMessage = "Enter the full domain name, email address, or URL.", Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({
                if ($_ -like "*.*") {
                    return $true
                }
                else {
                    throw [System.Management.Automation.ValidationMetadataException] "Enter the full domain name, email address, or URL."
                }
            })]
        [alias ('d')]
        [string]$Domain,

        [parameter(Mandatory = $false, HelpMessage = "Query both the subdomain and the base domain. Example: mail.facebook.com returns results for mail.facebook.com AND facebook.com.")]
        [alias ('s')]
        [switch]$Sub,

        [parameter(Mandatory = $false, HelpMessage = "DKIM selector. DKIM won't be checked without this string.")]
        [ValidateNotNullOrEmpty()]
        [alias ('sel')]
        [string]$Selector = 'unprovided',

        [parameter(Mandatory = $false, HelpMessage = "Looks for record type TXT or CNAME or BOTH for SPF, DMARC, and DKIM if -Selector is used. The default record type is TXT.")]
        [ValidateSet('TXT', 'CNAME', 'BOTH')]
        [ValidateNotNullOrEmpty()]
        [alias ('r')]
        [string]$RecordType = 'TXT',

        [parameter(Mandatory = $false, HelpMessage = "Server to query. The default is 8.8.8.8")]
        [ValidateNotNullOrEmpty()]
        [alias ('srv')]
        [string]$Server = '8.8.8.8',

        [parameter(Mandatory = $false, HelpMessage = "Query only the subdomain, skip the base domain. Example: mail.facebook.com returns results for mail.facebook.com only.")]
        [alias ('js')]
        [switch]$JustSub,

        [parameter(Mandatory = $false, HelpMessage = "Export results to file. Provide a filename (e.g., 'results.csv', 'output.json') or just the format ('CSV', 'JSON') for auto-generated timestamped filename.")]
        [alias ('e')]
        [string]$Export
    )

    begin {
        # Determine DNS resolution method first, before any early returns.
        # Resolve-DnsName is Windows built-in; dig is used as a fallback on Linux/macOS.
        if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) {
            $script:DnsMethod = 'ResolveDnsName'
        }
        elseif (Get-Command -Name dig -ErrorAction SilentlyContinue) {
            $script:DnsMethod = 'dig'
        }
        else {
            $script:DnsMethod = 'none'
            Write-Error "Neither Resolve-DnsName nor dig is available. On Linux/macOS, install bind-utils (RHEL/CentOS) or dnsutils (Debian/Ubuntu) to get dig."
        }

        # Initialize collection for export functionality
        $ExportFormat = $null
        $OutputPath = $null

        if ($Export) {
            $script:AllResults = @()

            # Determine if Export is a filename or just a format (case-insensitive)
            if ($Export -match '\.(csv|json)$') {
                # It's a filename with extension
                $OutputPath = $Export
                $ExportFormat = ($Export -split '\.')[-1].ToUpper()
            }
            elseif ($Export -match '^(csv|json)$') {
                # It's just a format, generate timestamped filename
                $ExportFormat = $Export.ToUpper()
                $timestamp = Get-Date -Format "yyyyMMdd_HHmm"
                $extension = $ExportFormat.ToLower()
                $OutputPath = Join-Path (Get-Location).Path "MailRecords_$timestamp.$extension"
            }
            else {
                Write-Error "Export parameter must be either a filename with .csv or .json extension, or 'CSV'/'JSON' for auto-generated filename."
                return
            }
        }
    }

    process {
        # Abort early if no DNS tool is available
        if ($script:DnsMethod -eq 'none') {
            return $null
        }

        # Initialize DKIM selectors
        $DkimSelectors = @(
            "default", "s", "s1", "s2", "selector1", "selector2", "pps1", "google", "everlytickey1", "everlytickey2", "eversrv", "k1", "mxvault", "dkim", "mail", "s1024", "s2048", "s4096"
        )

        # Cross-platform DNS query wrapper.
        # Uses Resolve-DnsName on Windows; falls back to dig on Linux/macOS.
        # Returns objects with consistent key properties: Type, TTL, Strings, NameHost, NameExchange, Preference, IPAddress.
        function Invoke-DnsQuery {
            param(
                [Parameter(Mandatory = $true)]
                [string]$Name,

                [Parameter(Mandatory = $true)]
                [string]$Type,

                [Parameter(Mandatory = $true)]
                [string]$Server
            )

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

            # dig fallback for Linux/macOS
            $digArgs = "@$Server", "+noall", "+answer", "-t", $Type.ToUpper(), $Name
            $digOutput = & dig @digArgs 2>$null
            if (-not $digOutput) { return $null }

            $results = [System.Collections.Generic.List[object]]::new()
            foreach ($line in $digOutput) {
                if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*;') { continue }

                # Parse dig answer line: NAME TTL CLASS TYPE DATA
                if ($line -match '^(\S+)\s+(\d+)\s+IN\s+(\S+)\s+(.+)$') {
                    $recordName = $Matches[1].TrimEnd('.')
                    $ttl = [int]$Matches[2]
                    $recordType = $Matches[3].ToUpper()
                    $data = $Matches[4].Trim()

                    $obj = [PSCustomObject]@{
                        Name = $recordName
                        Type = $recordType
                        TTL  = $ttl
                    }

                    switch ($recordType) {
                        'A' {
                            $obj | Add-Member -NotePropertyName 'IPAddress' -NotePropertyValue $data
                        }
                        'MX' {
                            if ($data -match '^(\d+)\s+(\S+)$') {
                                $obj | Add-Member -NotePropertyName 'Preference' -NotePropertyValue ([int]$Matches[1])
                                $obj | Add-Member -NotePropertyName 'NameExchange' -NotePropertyValue $Matches[2].TrimEnd('.')
                            }
                        }
                        'NS' {
                            $obj | Add-Member -NotePropertyName 'NameHost' -NotePropertyValue $data.TrimEnd('.')
                        }
                        'CNAME' {
                            $obj | Add-Member -NotePropertyName 'NameHost' -NotePropertyValue $data.TrimEnd('.')
                        }
                        'TXT' {
                            # Extract each quoted string segment (handles multi-part TXT records like long DKIM keys)
                            $parts = [regex]::Matches($data, '"([^"]*)"') | ForEach-Object { $_.Groups[1].Value }
                            if (-not $parts) { $parts = @($data) }
                            $obj | Add-Member -NotePropertyName 'Strings' -NotePropertyValue @($parts)
                        }
                    }

                    $results.Add($obj)
                }
            }

            return $results.ToArray()
        }

        # NameServer lookup
        function Get-NS {
            param (
                [Parameter(Mandatory = $true)]
                [string]$Domain,

                [Parameter(Mandatory = $true)]
                [string]$Server
            )

            $NS = Invoke-DnsQuery -Name $Domain -Type 'NS' -Server $Server

            if ([string]::IsNullOrWhiteSpace($NS.NameHost)) {
                return $false
            }

            $OutNS = foreach ($Item in $NS) {
                $Item | Select-Object NameHost, TTL
            }

            [string]$resultsNS = ($OutNS | Select-Object -First 2 | Out-String).TrimEnd("`r`n").Trim()
            return $resultsNS
        }

        # SPF record lookup
        function Get-SPF {
            param (
                [Parameter(Mandatory = $true)]
                [string]$Domain,

                [Parameter(Mandatory = $true)]
                [string]$Server,

                [Parameter(Mandatory = $true)]
                [string]$Type
            )

            $SPF = Invoke-DnsQuery -Name $Domain -Type $Type -Server $Server

            if ($Type -eq 'TXT') {
                $spfRecord = $SPF.Strings | Where-Object { $_ -like "v=spf1*" }
                if ([string]::IsNullOrWhiteSpace($spfRecord)) {
                    return $false
                }
                return $spfRecord
            }
            elseif ($Type -eq 'CNAME') {
                $cnameRecord = $SPF | Where-Object { $_.Type -eq 'CNAME' }
                if ($cnameRecord) {
                    $targetDomain = $cnameRecord.NameHost
                    $targetSPF = Invoke-DnsQuery -Name $targetDomain -Type 'TXT' -Server $Server
                    $spfRecord = $targetSPF.Strings | Where-Object { $_ -like "v=spf1*" }
                    if ($spfRecord) {
                        return "CNAME -> $targetDomain : $spfRecord"
                    }
                    return "CNAME -> $targetDomain (no SPF found)"
                }
                return $false
            }
        }

        # Normalize selector to lowercase for case-insensitive matching
        if ($Selector -ne 'unprovided') {
            $Selector = $Selector.ToLowerInvariant()
        }

        # Validate and parse the input domain
        $TestDomain = try {
            ([System.Uri]$Domain).Host.TrimStart('www.')
        }
        catch {
            try {
                ([Net.Mail.MailAddress]$Domain).Host
            }
            catch {
                $Domain
            }
        }

        # Final cleanup and lowercase normalization
        if ($TestDomain) {
            try {
                $TestDomain = $TestDomain.Replace('@', '').Trim().ToLowerInvariant()
            }
            catch {
                Write-Error "Problem with $Domain as entered. Please read the command help."
                return $null
            }
        }
        else {
            Write-Error "Problem with $Domain as entered. Please read the command help."
            return $null
        }

        # Extract the base domain (handles most cases, but not multi-part TLDs like .co.uk)
        # For complex TLDs, use the -Sub parameter to preserve the full domain
        if (-not $Sub -and -not $JustSub) {
            $parts = $TestDomain.Split(".")
            # Basic handling: if more than 3 parts and second-to-last is short (2 chars), keep 3 parts
            # This catches common cases like example.co.uk, but not all scenarios
            if ($parts.Count -gt 2 -and $parts[-2].Length -eq 2 -and $parts[-1].Length -le 3) {
                $TestDomain = $parts[-3..-1] -join "."
            }
            else {
                $TestDomain = $parts[-2, -1] -join "."
            }
        }

        # Initialize DKIM result
        $resultdkim = $false

        # Normalize record type to uppercase
        $RecordTypeTest = @()
        if ($RecordType -eq 'BOTH') {
            $RecordTypeTest = @('TXT', 'CNAME')
        }
        else {
            $RecordTypeTest = $RecordType.ToUpper()
        }

        # Check if A record exists
        $resultA = $null -ne (Invoke-DnsQuery -Name $TestDomain -Type 'A' -Server $Server | Where-Object { $_.Type -eq 'A' })

        try {
            # Query the DNS server for MX records
            $mxRecords = Invoke-DnsQuery -Name $TestDomain -Type 'MX' -Server $Server |
            Sort-Object -Property Preference
        }
        catch {
            # Handle errors during the query
            Write-Error "An error occurred while resolving DNS: $_"
            $mxRecords = $null
        }

        # Validate and format MX results outside the try/catch so that
        # Write-Warning is not silently swallowed when -WarningAction Stop is used.
        if ($mxRecords -and $mxRecords.Type -contains 'MX') {
            $formattedRecords = $mxRecords |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_.NameExchange) } |
            Select-Object @{n = "Name"; e = { $_.NameExchange } },
            @{n = "Preference"; e = { $_.Preference } },
            @{n = "TTL"; e = { $_.TTL } }
            $resultmx = ($formattedRecords | Out-String).TrimEnd("`r`n").Trim()
        }
        else {
            Write-Warning "No MX records found for domain: $Domain"
            $resultmx = $false
        }

        # Hold the original selector value
        $SelectorHold = $Selector

        # Loop through specified record types
        $Output = $RecordTypeTest | ForEach-Object {
            $TempType = $_

            # Get NS records
            $resultsNS = Get-NS -Domain $TestDomain -Server $Server

            # Get SPF record
            $resultspf = Get-SPF -Domain $TestDomain -Server $Server -Type $TempType

            # Get DMARC record
            $DMARC = Invoke-DnsQuery -Name "_dmarc.$TestDomain" -Type $TempType -Server $Server

            if (-not $DMARC) {
                $resultdmarc = $false
            }
            else {
                if ($TempType -eq 'TXT') {
                    $resultdmarc = ($DMARC.Strings -like "v=DMARC1*") -join ' '
                    if ([string]::IsNullOrWhiteSpace($resultdmarc)) {
                        $resultdmarc = $false
                    }
                }
                elseif ($TempType -eq 'CNAME') {
                    $cnameRecord = $DMARC | Where-Object { $_.Type -eq 'CNAME' }
                    if ($cnameRecord) {
                        $targetDomain = $cnameRecord.NameHost
                        $targetDMARC = Invoke-DnsQuery -Name $targetDomain -Type 'TXT' -Server $Server
                        $dmarcRecord = ($targetDMARC.Strings -like "v=DMARC1*") -join ' '
                        if ($dmarcRecord) {
                            $resultdmarc = "CNAME -> $targetDomain : $dmarcRecord"
                        }
                        else {
                            $resultdmarc = "CNAME -> $targetDomain (no DMARC found)"
                        }
                    }
                    else {
                        $resultdmarc = $false
                    }
                }
            }

            # Start of DKIM checking
            if ($Selector -ne 'unprovided') {
                # Get DKIM record if it exists
                $DKIM = Invoke-DnsQuery -Name "$($Selector)._domainkey.$($TestDomain)" -Type $TempType -Server $Server |
                Where-Object { $_.Type -eq $TempType }

                if (-not $DKIM) {
                    $resultdkim = $false
                }
                else {
                    if ($TempType -eq 'TXT') {
                        foreach ($Item in $DKIM) {
                            if ($Item.Type -eq 'TXT' -and $Item.Strings -match "v=DKIM1") {
                                $resultdkim = [string]$Item.Strings
                                break
                            }
                        }
                    }
                    elseif ($TempType -eq 'CNAME') {
                        $cnameRecord = $DKIM | Where-Object { $_.Type -eq 'CNAME' }
                        if ($cnameRecord) {
                            $targetDomain = $cnameRecord.NameHost
                            $targetDKIM = Invoke-DnsQuery -Name $targetDomain -Type 'TXT' -Server $Server
                            $dkimRecord = $targetDKIM | Where-Object { $_.Strings -match "v=DKIM1" }
                            if ($dkimRecord) {
                                $resultdkim = "CNAME -> $targetDomain : $([string]$dkimRecord.Strings)"
                            }
                            else {
                                $resultdkim = "CNAME -> $targetDomain (no DKIM found)"
                            }
                        }
                        else {
                            $resultdkim = $false
                        }
                    }
                }
            }

            # Auto-discover DKIM selector if not provided
            if ($Selector -eq 'unprovided') {
                # Break the loop if DKIM is found
                $BreakFlag = $false
                foreach ($line in $DkimSelectors) {
                    $DKIM = $null
                    $DKIM = Invoke-DnsQuery -Name "$($line)._domainkey.$($TestDomain)" -Type $TempType -Server $Server |
                    Where-Object { $_.Type -eq $TempType }

                    if ($TempType -eq 'TXT') {
                        $DKIM = $DKIM | Where-Object { $_.Strings -match "v=DKIM1" }
                        foreach ($Item in $DKIM) {
                            [string]$resultdkim = $Item.Strings
                            $Selector = $line
                            $BreakFlag = $true
                            break
                        }
                    }
                    elseif ($TempType -eq 'CNAME') {
                        $cnameRecord = $DKIM | Where-Object { $_.Type -eq 'CNAME' }
                        if ($cnameRecord) {
                            $targetDomain = $cnameRecord.NameHost
                            $targetDKIM = Invoke-DnsQuery -Name $targetDomain -Type 'TXT' -Server $Server
                            $dkimRecord = $targetDKIM | Where-Object { $_.Strings -match "v=DKIM1" }
                            if ($dkimRecord) {
                                $resultdkim = "CNAME -> $targetDomain : $([string]$dkimRecord.Strings)"
                                $Selector = $line
                                $BreakFlag = $true
                            }
                        }
                    }

                    if ($BreakFlag) {
                        break
                    }
                }
            }

            # Reset selector if DKIM not found
            if ($resultdkim -eq $false) {
                $Selector = $SelectorHold
            }

            [PSCustomObject]@{
                A                 = $resultA
                MX                = $resultmx
                "SPF_$TempType"   = $resultspf
                "DMARC_$TempType" = $resultdmarc
                "DKIM_$TempType"  = $resultdkim
                SELECTOR          = $Selector
                DOMAIN            = $TestDomain
                RECORDTYPE        = $TempType
                SERVER            = $Server
                NS_First2         = $resultsNS
            }
        }

        if ($JustSub) {
            if ($Export) {
                $script:AllResults += $Output
            }
            else {
                return $Output
            }
        }
        else {
            if ($Export) {
                $script:AllResults += $Output
            }
            else {
                $Output
            }

            # If Sub is true, also query the base/parent domain
            if ($Sub -eq $true -and ($TestDomain.Split('.').count -gt 2)) {
                # Derive the parent domain explicitly from the already-parsed domain
                $tParts = $TestDomain.Split('.')
                $parentDomain = if ($tParts.Count -gt 2 -and $tParts[-2].Length -eq 2 -and $tParts[-1].Length -le 3) {
                    $tParts[-3..-1] -join '.'
                }
                else {
                    $tParts[-2, -1] -join '.'
                }
                # Skip if stripping produced the same domain (e.g. multi-part TLDs like .co.uk)
                if ($parentDomain -ne $TestDomain) {
                    $subOutput = Get-MailRecords -Domain $parentDomain -Server $Server -RecordType $RecordType -Selector $SelectorHold
                    if ($Export) {
                        $script:AllResults += $subOutput
                    }
                    else {
                        $subOutput
                    }
                }
            }
        }
    }

    end {
        # Export results if requested
        if ($ExportFormat -and $script:AllResults.Count -gt 0) {
            try {
                switch ($ExportFormat) {
                    'CSV' {
                        $script:AllResults | Export-Csv -Path $OutputPath -NoTypeInformation -Force
                        Write-Host "Results exported to: $OutputPath" -ForegroundColor Green
                    }
                    'JSON' {
                        $script:AllResults | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Force
                        Write-Host "Results exported to: $OutputPath" -ForegroundColor Green
                    }
                }
            }
            catch {
                Write-Error "Failed to export results: $_"
            }
        }
    }
}