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), -dkim (DkimSelectors), -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 Explicit DKIM selector to query. If not provided, selectors in -DkimSelectors are tried automatically. Alias: -sel .PARAMETER DkimSelectors List of DKIM selectors to try when no -Selector is given. Defaults to a common set. Override to test custom selectors: -DkimSelectors @('mysel','selector1'). Alias: -dkim .PARAMETER RecordType Record type 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 # Override the default DKIM selector list with custom selectors Get-MailRecords -Domain example.com -DkimSelectors @('acmecorp', 'mail2024') GMR -d example.com -dkim @('acmecorp', 'mail2024') .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), -dkim (DkimSelectors), -r (RecordType), -srv (Server), -e (Export). To override DKIM auto-discovery selectors, use -DkimSelectors @('sel1','sel2') or alias -dkim. 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. Try it now — no install required gmr.thecasmas.com Enter a domain, get your mail DNS records instantly. Works in any browser, on any device. No information saved Portions of code adapted from Jordan W. #> function Get-MailRecords { [Alias("GMR")] [CmdletBinding()] param ( # Accepts a bare domain (example.com), an email address (user@example.com), # or a full URL (https://example.com). Parsed into a clean hostname before querying. # Accepts pipeline input by value or by property name for bulk lookups. [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, # When set, queries the subdomain supplied AND its base domain. # e.g. -Domain mail.example.com -Sub returns results for both # mail.example.com and example.com. [parameter(Mandatory = $false)] [alias ('s')] [switch]$Sub, # When set, queries ONLY the domain exactly as supplied — no base domain lookup. # Useful for landing page or tracking subdomains that are not mail senders. [parameter(Mandatory = $false)] [alias ('js')] [switch]$JustSub, # Explicit DKIM selector to query (e.g. 'selector1', 'google'). # If omitted, DKIM auto-discovery runs through the $DkimSelectors list. # Internal sentinel value 'unprovided' indicates no selector was passed. [parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [alias ('sel')] [string]$Selector = 'unprovided', # List of DKIM selectors to try during auto-discovery when no explicit # -Selector is given. Covers common selectors used by major ESPs and platforms. # Can be overridden at runtime to test a custom set without editing the module. [parameter(Mandatory = $false)] [alias ('dkim')] [string[]]$DkimSelectors = @( "default", "s", "s1", "s2", "selector1", "selector2", "pps1", "google", "everlytickey1", "everlytickey2", "eversrv", "k1", "mxvault", "dkim", "mail", "s1024", "s2048", "s4096" ), # Controls which DNS record type is queried for SPF, DMARC, and DKIM. # TXT — standard lookup (default). # CNAME — follows CNAMEs to their TXT target (used by some DNS providers # such as Proofpoint and Microsoft 365 for flattened SPF). # BOTH — runs TXT and CNAME passes and returns one output object per pass. [parameter(Mandatory = $false)] [ValidateSet('TXT', 'CNAME', 'BOTH')] [ValidateNotNullOrEmpty()] [alias ('r')] [string]$RecordType = 'TXT', # DNS server to query. Defaults to Google Public DNS (8.8.8.8). # Can be overridden to test against an authoritative or internal resolver. [parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [alias ('srv')] [string]$Server = '8.8.8.8', # Optional export path or format string. # Pass a filename with .csv or .json extension to write to that path, # or pass 'CSV' / 'JSON' to auto-generate a timestamped file in the # current directory. Collects all pipeline results before writing. [parameter(Mandatory = $false)] [alias ('e')] [string]$Export ) # ── BEGIN ───────────────────────────────────────────────────────────────── # Runs once before pipeline input is processed. # Determines which DNS resolution method is available and validates the # Export parameter so any format errors fail fast before DNS queries start. begin { # Prefer Resolve-DnsName (Windows / PowerShell 7 on Windows). # Fall back to dig (Linux / macOS). Fail clearly if neither is present. 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." } $ExportFormat = $null $OutputPath = $null if ($Export) { # Initialise the collection that accumulates results across all # pipeline inputs when exporting. Written in the end block. $script:AllResults = @() if ($Export -match '\.(csv|json)$') { # Caller provided an explicit filename — use it as-is. $OutputPath = $Export $ExportFormat = ($Export -split '\.')[-1].ToUpper() } elseif ($Export -match '^(csv|json)$') { # Caller provided just a format keyword — auto-generate a # timestamped filename in the current working directory. $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 ─────────────────────────────────────────────────────────────── # Runs once per pipeline input object (or once for a direct call). # All DNS queries, helper functions, and output object construction live here. process { if ($script:DnsMethod -eq 'none') { return $null } # ── Helper: Invoke-DnsQuery ─────────────────────────────────────────── # Thin abstraction over Resolve-DnsName (Windows) and dig (Linux/macOS). # Returns a consistent array of PSCustomObjects regardless of platform, # each with Name, Type, TTL, and a type-specific property (IPAddress, # NameExchange, NameHost, or Strings). 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 path (Linux / macOS) ────────────────────────────────────── # +noall +answer suppresses everything except the answer section. $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) { # Skip blank lines and comment lines (begin with ;). if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*;') { continue } # Parse standard DNS answer line: name TTL IN 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 } # Add a type-appropriate property to match Resolve-DnsName output shape. 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' { # dig wraps TXT strings in quotes; extract the content. # Fall back to the raw data string if no quoted parts found. $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() } # ── Helper: Get-NS ──────────────────────────────────────────────────── # Returns a formatted string of the first two NS records with TTLs, # or $false if no NS records are found. 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 } # Format as "ns1.example.com [TTL 3600] | ns2.example.com [TTL 3600]" [string]$resultsNS = ($OutNS | Select-Object -First 2 | ForEach-Object { "$($_.NameHost) [TTL $($_.TTL)]" }) -join " | " return $resultsNS } # ── Helper: Get-SPF ─────────────────────────────────────────────────── # Looks up the SPF record for the domain using the specified record type. # TXT mode: queries TXT records directly and filters for v=spf1. # CNAME mode: follows a CNAME to its target, then queries TXT there. # Returns the SPF string, a "CNAME -> target : record" string, or $false. 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') { # Some providers (e.g. Proofpoint) publish SPF as a CNAME that # points to a TXT record rather than publishing TXT directly. $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 } } # ── Domain normalisation ────────────────────────────────────────────── # Normalize the -Selector value to lowercase for consistent DNS lookups. if ($Selector -ne 'unprovided') { $Selector = $Selector.ToLowerInvariant() } # Parse the input into a clean hostname. # Try casting as a URI first (handles https://... and bare domains), # then as a MailAddress (handles user@domain), then fall back to raw input. $TestDomain = try { ([System.Uri]$Domain).Host.TrimStart('www.') } catch { try { ([Net.Mail.MailAddress]$Domain).Host } catch { $Domain } } if ($TestDomain) { try { # Strip any stray @ symbols, trim whitespace, and lowercase. $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 } # Unless -Sub or -JustSub is set, strip the subdomain and query only # the base domain (e.g. mail.example.com -> example.com). # Handles country-code second-level domains (e.g. co.uk, com.au) by # preserving three labels when the penultimate label is 2 characters. if (-not $Sub -and -not $JustSub) { $parts = $TestDomain.Split(".") 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 "." } } # Initialise DKIM result as not-found; overwritten if a record is discovered. $resultdkim = $false # Build the list of record types to iterate over. # Always an array so ForEach-Object receives consistent typed strings. # BOTH expands to two passes; TXT/CNAME produce a single-element array. $RecordTypeTest = @() if ($RecordType -eq 'BOTH') { $RecordTypeTest = @('TXT', 'CNAME') } else { $RecordTypeTest = @($RecordType.ToUpper()) } # ── A record ────────────────────────────────────────────────────────── # Boolean: $true if at least one A record resolves for the domain. $resultA = $null -ne (Invoke-DnsQuery -Name $TestDomain -Type 'A' -Server $Server | Where-Object { $_.Type -eq 'A' }) # ── MX records ──────────────────────────────────────────────────────── # Sorted by preference (lowest = highest priority). # Non-mail subdomains legitimately have no MX; emit Verbose, not Warning. try { $mxRecords = Invoke-DnsQuery -Name $TestDomain -Type 'MX' -Server $Server | Sort-Object -Property Preference } catch { Write-Error "An error occurred while resolving DNS: $_" $mxRecords = $null } 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 } } # Format as "mx1.example.com [pref 10, TTL 300] | mx2.example.com [pref 20, TTL 300]" $resultmx = ($formattedRecords | ForEach-Object { "$($_.Name) [pref $($_.Preference), TTL $($_.TTL)]" }) -join " | " } else { Write-Verbose "No MX records found for domain: $Domain" $resultmx = $false } # Track whether -DkimSelectors was explicitly passed so the SELECTOR # field can reflect the custom list when no match is found. $DkimExplicit = $PSBoundParameters.ContainsKey('DkimSelectors') # Snapshot the original selector value; $Selector may be mutated during # auto-discovery and needs to be restored between record-type passes. $SelectorHold = $Selector # ── Per-record-type pass ────────────────────────────────────────────── # For TXT or CNAME: one iteration. For BOTH: two iterations. # Each pass produces one output object with its own RECORDTYPE property. $Output = $RecordTypeTest | ForEach-Object { $TempType = $_ $resultsNS = Get-NS -Domain $TestDomain -Server $Server $resultspf = Get-SPF -Domain $TestDomain -Server $Server -Type $TempType # ── DMARC ───────────────────────────────────────────────────────── # DMARC is published at the _dmarc subdomain of the base domain. $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') { # Follow the CNAME to its target and look for DMARC TXT there. $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 } } } # ── DKIM — explicit selector ─────────────────────────────────────── # If a selector was provided via -Selector, query that specific # _domainkey record and skip auto-discovery. if ($Selector -ne 'unprovided') { $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 = ($Item.Strings -join "") 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 : $(($dkimRecord.Strings -join ''))" } else { $resultdkim = "CNAME -> $targetDomain (no DKIM found)" } } else { $resultdkim = $false } } } } # ── DKIM — auto-discovery ────────────────────────────────────────── # Walk the $DkimSelectors list and stop at the first match. # Sets $Selector to the matched value so it appears in the output. if ($Selector -eq 'unprovided') { $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) { $resultdkim = ($Item.Strings -join "") $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 : $(($dkimRecord.Strings -join ''))" $Selector = $line $BreakFlag = $true } } } if ($BreakFlag) { break } } } # If DKIM was not found, set the SELECTOR field to reflect what was # tried: the custom list (if -DkimSelectors was explicit) or the # original sentinel / caller-supplied value. if ($resultdkim -eq $false) { $Selector = if ($DkimExplicit) { $DkimSelectors -join ', ' } else { $SelectorHold } } # ── Output object ────────────────────────────────────────────────── # One object per record-type pass. Property names for SPF, DMARC, # and DKIM include the type suffix (e.g. SPF_TXT, DMARC_CNAME) # so BOTH mode returns two distinguishable objects. [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 } } # ── Output / accumulation ───────────────────────────────────────────── # JustSub: emit output for this domain only, never recurse to base domain. if ($JustSub) { if ($Export) { $script:AllResults += $Output } else { return $Output } } else { if ($Export) { $script:AllResults += $Output } else { $Output } # Sub: after emitting the subdomain result, recurse once to query # the base domain. Only recurses when the input was actually a # subdomain (more than 2 labels). Handles ccSLD domains (co.uk etc.) # by preserving three labels when the penultimate is a 2-char ccSLD. if ($Sub -eq $true -and ($TestDomain.Split('.').count -gt 2)) { $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 '.' } if ($parentDomain -ne $TestDomain) { $subOutput = Get-MailRecords -Domain $parentDomain -Server $Server -RecordType $RecordType -Selector $SelectorHold if ($Export) { $script:AllResults += $subOutput } else { $subOutput } } } } } # ── END ─────────────────────────────────────────────────────────────────── # Runs once after all pipeline input has been processed. # Writes the accumulated results to CSV or JSON if -Export was specified. end { 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: $_" } } } } |