Skip to content

Instantly share code, notes, and snippets.

@azurekid
Created January 22, 2026 11:22
Show Gist options
  • Select an option

  • Save azurekid/bc1b9f2b03688bc4396ca57524ca1e1b to your computer and use it in GitHub Desktop.

Select an option

Save azurekid/bc1b9f2b03688bc4396ca57524ca1e1b to your computer and use it in GitHub Desktop.
<#
.SYNOPSIS
Performs comprehensive email security reconnaissance on one or more domains.
.DESCRIPTION
Invoke-EmailRecon performs parallel DNS lookups and HTTP requests to gather
email security configuration data for specified domains. It collects information
about MX records, SPF, DKIM, DMARC, BIMI, MTA-STS, TLS-RPT, DANE/TLSA, DNSSEC,
CAA records, Microsoft 365/Entra ID tenant details, ADFS federation, and
DNS blocklist status.
The script uses PowerShell 7's ForEach-Object -Parallel for domain-level
parallelism and runspace pools for concurrent DNS resolution within each domain.
.PARAMETER Domain
One or more email domains to analyze. Accepts pipeline input.
Alias: EmailDomain
.PARAMETER ThrottleLimit
Maximum number of domains to process concurrently. Default is 10.
Higher values improve speed but increase resource usage.
.PARAMETER SkipBlocklistCheck
Skip DNS blocklist (DNSBL) lookups. This speeds up execution but
omits spam blocklist status from results.
.INPUTS
System.String
You can pipe domain names to this function.
.OUTPUTS
PSCustomObject
Returns objects with comprehensive email security configuration for each domain.
.EXAMPLE
Invoke-EmailRecon -Domain 'contoso.com'
Analyzes email security configuration for contoso.com.
.EXAMPLE
Invoke-EmailRecon -Domain 'contoso.com', 'fabrikam.com' -ThrottleLimit 20
Analyzes multiple domains with increased parallelism.
.EXAMPLE
Get-Content -Path 'C:\Temp\domains.txt' | Invoke-EmailRecon | Export-Csv -Path 'results.csv' -NoTypeInformation
Reads domains from a file, analyzes each, and exports results to CSV.
.EXAMPLE
Invoke-EmailRecon -Domain 'contoso.com' -SkipBlocklistCheck -Verbose
Analyzes a domain without blocklist checks, with verbose output.
.NOTES
Name: Invoke-EmailRecon
Author: BlackCat Security Tools
Version: 2.1.0
Requires: PowerShell 7.0 or later
Platform: Windows (uses Resolve-DnsName), macOS/Linux (uses dig command)
.LINK
https://github.com/azurekid/blackcat
#>
#Requires -Version 7.0
function Invoke-EmailRecon {
[CmdletBinding(SupportsShouldProcess = $true)]
[OutputType([PSCustomObject])]
param (
[Parameter(
Mandatory = $true,
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Position = 0,
HelpMessage = 'One or more email domains to analyze.'
)]
[Alias('EmailDomain', 'Name')]
[ValidateNotNullOrEmpty()]
[string[]]$Domain,
[Parameter(
Mandatory = $false,
HelpMessage = 'Maximum number of parallel operations (1-50).'
)]
[ValidateRange(1, 50)]
[int]$ThrottleLimit = 10,
[Parameter(
Mandatory = $false,
HelpMessage = 'Skip DNS blocklist lookups for faster execution.'
)]
[switch]$SkipBlocklistCheck
)
begin {
Write-Verbose -Message "Initializing Invoke-EmailRecon"
# Detect operating system and available DNS tools
$script:RunningOnWindows = $IsWindows -or ($PSVersionTable.Platform -eq 'Win32NT') -or (-not $PSVersionTable.Platform -and $env:OS -eq 'Windows_NT')
$script:UseResolveDnsName = $script:RunningOnWindows -and (Get-Command -Name 'Resolve-DnsName' -ErrorAction SilentlyContinue)
$script:UseDigCommand = -not $script:RunningOnWindows -and (Get-Command -Name 'dig' -ErrorAction SilentlyContinue)
if (-not $script:UseResolveDnsName -and -not $script:UseDigCommand) {
$errorMessage = if ($script:RunningOnWindows) {
'Resolve-DnsName cmdlet not found. This cmdlet is required for DNS lookups on Windows.'
} else {
'dig command not found. Please install dnsutils/bind-utils package for DNS lookups on macOS/Linux.'
}
$PSCmdlet.ThrowTerminatingError(
[System.Management.Automation.ErrorRecord]::new(
[System.InvalidOperationException]::new($errorMessage),
'DnsToolNotFound',
[System.Management.Automation.ErrorCategory]::ObjectNotFound,
$null
)
)
}
Write-Verbose -Message "Using DNS resolver: $(if ($script:UseResolveDnsName) { 'Resolve-DnsName' } else { 'dig' })"
#region Cross-Platform DNS Resolution Function
$script:DnsResolverScriptBlock = @'
function Resolve-DnsRecord {
<#
.SYNOPSIS
Cross-platform DNS resolution wrapper.
.DESCRIPTION
Uses Resolve-DnsName on Windows and dig on macOS/Linux.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
[ValidateSet('A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'PTR', 'DNSKEY', 'CAA', 'TLSA', 'ANY')]
[string]$Type = 'A',
[Parameter(Mandatory = $false)]
[switch]$DnsOnly
)
$runningOnWindows = $IsWindows -or ($PSVersionTable.Platform -eq 'Win32NT') -or (-not $PSVersionTable.Platform -and $env:OS -eq 'Windows_NT')
if ($runningOnWindows) {
# Use native Resolve-DnsName on Windows
$params = @{
Name = $Name
Type = $Type
ErrorAction = 'SilentlyContinue'
}
if ($DnsOnly) { $params['DnsOnly'] = $true }
return Resolve-DnsName @params
}
else {
# Use dig on macOS/Linux
return Invoke-DigQuery -Name $Name -Type $Type
}
}
function Invoke-DigQuery {
<#
.SYNOPSIS
Executes dig command and parses output into PowerShell objects.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
[string]$Type = 'A'
)
try {
$digOutput = & dig +short +noall +answer $Name $Type 2>$null
if ([string]::IsNullOrWhiteSpace($digOutput)) {
return $null
}
$results = @()
$lines = $digOutput -split "`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($line in $lines) {
$line = $line.Trim()
switch ($Type.ToUpper()) {
'A' {
$results += [PSCustomObject]@{
Type = 'A'
Name = $Name
IPAddress = $line
}
}
'AAAA' {
$results += [PSCustomObject]@{
Type = 'AAAA'
Name = $Name
IPAddress = $line
}
}
'MX' {
$parts = $line -split '\s+'
if ($parts.Count -ge 2) {
$results += [PSCustomObject]@{
Type = 'MX'
Name = $Name
Preference = [int]$parts[0]
NameExchange = $parts[1].TrimEnd('.')
}
}
}
'TXT' {
# Remove surrounding quotes
$txtValue = $line -replace '^"', '' -replace '"$', ''
$results += [PSCustomObject]@{
Type = 'TXT'
Name = $Name
Strings = @($txtValue)
}
}
'CNAME' {
$results += [PSCustomObject]@{
Type = 'CNAME'
Name = $Name
NameHost = $line.TrimEnd('.')
}
}
'NS' {
$results += [PSCustomObject]@{
Type = 'NS'
Name = $Name
NameHost = $line.TrimEnd('.')
}
}
'SOA' {
# SOA format: mname rname serial refresh retry expire minimum
$soaOutput = & dig +short $Name SOA 2>$null
if ($soaOutput) {
$parts = ($soaOutput -split '\s+')
if ($parts.Count -ge 2) {
$results += [PSCustomObject]@{
Type = 'SOA'
Name = $Name
PrimaryServer = $parts[0].TrimEnd('.')
NameAdministrator = $parts[1].TrimEnd('.') -replace '\.', '@'
}
}
}
}
'PTR' {
$results += [PSCustomObject]@{
Type = 'PTR'
Name = $Name
NameHost = $line.TrimEnd('.')
}
}
'DNSKEY' {
if (-not [string]::IsNullOrWhiteSpace($line)) {
$results += [PSCustomObject]@{
Type = 'DNSKEY'
Name = $Name
}
}
}
'CAA' {
# CAA format: flags tag value
$parts = $line -split '\s+', 3
if ($parts.Count -ge 3) {
$results += [PSCustomObject]@{
Type = 'CAA'
Name = $Name
Flags = [int]$parts[0]
Tag = $parts[1]
Value = $parts[2] -replace '^"', '' -replace '"$', ''
}
}
}
'TLSA' {
if (-not [string]::IsNullOrWhiteSpace($line)) {
$results += [PSCustomObject]@{
Type = 'TLSA'
Name = $Name
}
}
}
default {
$results += [PSCustomObject]@{
Type = $Type
Name = $Name
Data = $line
}
}
}
}
return $results
}
catch {
Write-Verbose -Message "dig query failed for $Name ($Type): $_"
return $null
}
}
'@
#endregion
#region Helper Functions Script Block
# These functions run inside parallel runspaces
$script:HelperFunctionsScriptBlock = @'
function Get-DomainFederationData {
<#
.SYNOPSIS
Retrieves Microsoft 365 federation data for a domain.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param (
[Parameter(Mandatory = $true)]
[string]$DomainName
)
$uri = "https://login.microsoftonline.com/common/userrealm/?user=testuser@$DomainName&api-version=2.1&checkForMicrosoftAccount=true"
try {
$response = Invoke-RestMethod -Uri $uri -TimeoutSec 10 -ErrorAction Stop
return $response
}
catch {
Write-Verbose -Message "Failed to retrieve federation data for $DomainName`: $_"
return $null
}
}
function Get-MtaStsPolicy {
<#
.SYNOPSIS
Retrieves MTA-STS policy details for a domain.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param (
[Parameter(Mandatory = $true)]
[string]$DomainName
)
$mtaStsDns = Resolve-DnsRecord -Name "_mta-sts.$DomainName" -Type TXT
if (-not $mtaStsDns) {
return $null
}
try {
$policyUri = "https://mta-sts.$DomainName/.well-known/mta-sts.txt"
$policyContent = (Invoke-WebRequest -Uri $policyUri -TimeoutSec 10 -ErrorAction Stop).Content
$modeMatch = $policyContent | Select-String -Pattern 'mode:.*(enforce|testing|none)'
$mxMatches = $policyContent | Select-String -Pattern 'mx:(.*)' -AllMatches
$maxAgeMatch = $policyContent | Select-String -Pattern 'max_age:(\d+)'
$allowedMx = ($mxMatches.Matches.Groups |
Where-Object { $_.Value -notlike 'mx:*' } |
Select-Object -ExpandProperty Value) -replace '\s' -join ','
return [PSCustomObject]@{
DnsRecord = $mtaStsDns.Strings -join ''
Mode = if ($modeMatch.Matches.Count -gt 0) { $modeMatch.Matches[0].Groups[1].Value.ToUpper() } else { $null }
MaxAge = if ($maxAgeMatch.Matches.Count -gt 0) { [int]$maxAgeMatch.Matches[0].Groups[1].Value } else { $null }
AllowedMx = $allowedMx
}
}
catch {
Write-Verbose -Message "Failed to retrieve MTA-STS policy for $DomainName`: $_"
return $null
}
}
function Get-Microsoft365Domains {
<#
.SYNOPSIS
Retrieves all domains associated with a Microsoft 365 tenant.
#>
[CmdletBinding()]
[OutputType([string[]])]
param (
[Parameter(Mandatory = $true)]
[string]$DomainName
)
$uri = 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc'
$soapBody = @"
<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'
xmlns:a='http://www.w3.org/2005/08/addressing'
xmlns:autodiscover='http://schemas.microsoft.com/exchange/2010/Autodiscover'>
<soap:Header>
<a:Action soap:mustUnderstand='1'>http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation</a:Action>
<a:To soap:mustUnderstand='1'>$uri</a:To>
<a:ReplyTo><a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address></a:ReplyTo>
</soap:Header>
<soap:Body>
<autodiscover:GetFederationInformationRequestMessage>
<autodiscover:Request><autodiscover:Domain>$DomainName</autodiscover:Domain></autodiscover:Request>
</autodiscover:GetFederationInformationRequestMessage>
</soap:Body>
</soap:Envelope>
"@
$headers = @{
'Content-Type' = 'text/xml; charset=utf-8'
'SOAPAction' = '"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation"'
}
try {
$response = Invoke-RestMethod -Method Post -Uri $uri -Body $soapBody -Headers $headers -TimeoutSec 15 -ErrorAction Stop
$domains = $response.Envelope.Body.GetFederationInformationResponseMessage.Response.Domains.Domain
return $domains | Sort-Object
}
catch {
Write-Verbose -Message "Failed to retrieve M365 domains for $DomainName`: $_"
return @()
}
}
function Get-EntraIdDirectoryId {
<#
.SYNOPSIS
Retrieves the Entra ID (Azure AD) directory ID for a domain.
#>
[CmdletBinding()]
[OutputType([string])]
param (
[Parameter(Mandatory = $true)]
[string]$DomainName
)
$uri = "https://login.windows.net/$DomainName/.well-known/openid-configuration"
try {
$response = Invoke-RestMethod -Uri $uri -TimeoutSec 10 -ErrorAction Stop
if ($response.token_endpoint) {
return $response.token_endpoint.Split('/')[3]
}
return $null
}
catch {
Write-Verbose -Message "Failed to retrieve directory ID for $DomainName`: $_"
return $null
}
}
function Get-AdfsFederationEndpoint {
<#
.SYNOPSIS
Discovers ADFS federation endpoints for a domain.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param (
[Parameter(Mandatory = $true)]
[string]$DomainName
)
$prefixes = @('adfs', 'sso', 'sts', 'fs', 'auth', 'idf', 'fed', 'login', 'idp')
foreach ($prefix in $prefixes) {
$federationHost = "$prefix.$DomainName"
$resolved = Resolve-DnsRecord -Name $federationHost -Type A
if (-not $resolved) {
continue
}
$metadataUrl = "https://$federationHost/federationmetadata/2007-06/federationmetadata.xml"
try {
$xmlData = Invoke-RestMethod -Method Get -Uri $metadataUrl -TimeoutSec 5 -ErrorAction Stop
if ($xmlData.EntityDescriptor.entityID) {
return @{
Hostname = $federationHost
MetadataUrl = $metadataUrl
EntityId = $xmlData.EntityDescriptor.entityID
}
}
}
catch {
# Continue to next prefix
}
}
return $null
}
'@
#endregion
# Collect domains from pipeline
$script:DomainCollection = [System.Collections.Generic.List[string]]::new()
}
process {
foreach ($domainItem in $Domain) {
if (-not [string]::IsNullOrWhiteSpace($domainItem)) {
$script:DomainCollection.Add($domainItem.Trim().ToLower())
}
}
}
end {
if ($script:DomainCollection.Count -eq 0) {
Write-Warning -Message 'No valid domains provided.'
return
}
# Remove duplicates
$uniqueDomains = $script:DomainCollection | Select-Object -Unique
Write-Verbose -Message "Processing $($uniqueDomains.Count) domain(s) with ThrottleLimit: $ThrottleLimit"
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
# Process domains in parallel
$results = $uniqueDomains | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
$currentDomain = $_
$helperScriptBlock = $using:HelperFunctionsScriptBlock
$skipBlocklist = $using:SkipBlocklistCheck
# Import helper functions
. ([scriptblock]::Create($helperScriptBlock))
# Import DNS resolver function
$dnsResolverBlock = $using:DnsResolverScriptBlock
. ([scriptblock]::Create($dnsResolverBlock))
# Validate domain exists
$soaCheck = Resolve-DnsRecord -Name $currentDomain -Type SOA
if (-not $soaCheck) {
Write-Warning -Message "Failed to resolve SOA record for $currentDomain. Skipping."
return
}
#region DNS Queries Definition
$dnsQueries = @{
DMARC = { param($d) Resolve-DnsRecord -Name "_dmarc.$d" -Type TXT }
MX = { param($d) Resolve-DnsRecord -Name $d -Type MX }
TXT = { param($d) Resolve-DnsRecord -Name $d -Type TXT }
SOA = { param($d) Resolve-DnsRecord -Name $d -Type SOA }
NS = { param($d) Resolve-DnsRecord -Name $d -Type NS }
A = { param($d) Resolve-DnsRecord -Name $d -Type A }
AAAA = { param($d) Resolve-DnsRecord -Name $d -Type AAAA }
DNSKEY = { param($d) Resolve-DnsRecord -Name $d -Type DNSKEY }
CAA = { param($d) Resolve-DnsRecord -Name $d -Type CAA }
MSOID = { param($d) Resolve-DnsRecord -Name "msoid.$d" -Type A }
ENTERPRISEREGISTRATION = { param($d) Resolve-DnsRecord -Name "enterpriseregistration.$d" -Type CNAME }
AUTODISCOVER = { param($d) Resolve-DnsRecord -Name "autodiscover.$d" -Type CNAME }
WILDCARDTXT = { param($d) Resolve-DnsRecord -Name "$([guid]::NewGuid().ToString('N')).$d" -Type TXT }
BIMI = { param($d) Resolve-DnsRecord -Name "default._bimi.$d" -Type TXT }
TLSRPT = { param($d) Resolve-DnsRecord -Name "_smtp._tls.$d" -Type TXT }
MTASTS_DNS = { param($d) Resolve-DnsRecord -Name "_mta-sts.$d" -Type TXT }
DKIM_SELECTOR1 = { param($d) Resolve-DnsRecord -Name "selector1._domainkey.$d" -Type CNAME }
DKIM_SELECTOR2 = { param($d) Resolve-DnsRecord -Name "selector2._domainkey.$d" -Type CNAME }
DKIM_GOOGLE = { param($d) Resolve-DnsRecord -Name "google._domainkey.$d" -Type TXT }
DKIM_DEFAULT = { param($d) Resolve-DnsRecord -Name "default._domainkey.$d" -Type TXT }
DKIM_K1 = { param($d) Resolve-DnsRecord -Name "k1._domainkey.$d" -Type TXT }
DKIM_S1 = { param($d) Resolve-DnsRecord -Name "s1._domainkey.$d" -Type TXT }
DKIM_S2 = { param($d) Resolve-DnsRecord -Name "s2._domainkey.$d" -Type TXT }
}
$httpQueries = @{
FEDERATION = { param($d) Get-DomainFederationData -DomainName $d }
MTASTS = { param($d) Get-MtaStsPolicy -DomainName $d }
M365DOMAINS = { param($d) Get-Microsoft365Domains -DomainName $d }
DIRECTORYID = { param($d) Get-EntraIdDirectoryId -DomainName $d }
ADFS = { param($d) Get-AdfsFederationEndpoint -DomainName $d }
}
#endregion
#region Execute Queries in Parallel
$runspacePool = [runspacefactory]::CreateRunspacePool(1, 20)
$runspacePool.Open()
$jobs = @{}
$allQueries = $dnsQueries + $httpQueries
foreach ($queryName in $allQueries.Keys) {
$powerShell = [powershell]::Create()
$powerShell.RunspacePool = $runspacePool
# Add DNS resolver functions for all queries
$null = $powerShell.AddScript($dnsResolverBlock)
# Add helper functions for HTTP queries
if ($httpQueries.ContainsKey($queryName)) {
$null = $powerShell.AddScript($helperScriptBlock)
}
$null = $powerShell.AddScript($allQueries[$queryName]).AddArgument($currentDomain)
$jobs[$queryName] = @{
PowerShell = $powerShell
Handle = $powerShell.BeginInvoke()
}
}
# Collect results
$queryResults = @{}
foreach ($queryName in $jobs.Keys) {
try {
$queryResults[$queryName] = $jobs[$queryName].PowerShell.EndInvoke($jobs[$queryName].Handle)
}
catch {
$queryResults[$queryName] = $null
}
finally {
$jobs[$queryName].PowerShell.Dispose()
}
}
$runspacePool.Close()
$runspacePool.Dispose()
#endregion
#region Process MX Server Information
$mxServerInfo = @{ IPv4 = $null; IPv6 = $null; PtrRecord = $null }
if ($queryResults.MX) {
$lowestMx = $queryResults.MX | Sort-Object -Property Preference | Select-Object -First 1 -ExpandProperty NameExchange
if ($lowestMx) {
$mxA = Resolve-DnsRecord -Name $lowestMx -Type A |
Select-Object -First 1 -ExpandProperty IPAddress
$mxAAAA = Resolve-DnsRecord -Name $lowestMx -Type AAAA |
Select-Object -First 1 -ExpandProperty IPAddress
$mxServerInfo.IPv4 = $mxA
$mxServerInfo.IPv6 = $mxAAAA
if ($mxA) {
$ptrResult = Resolve-DnsRecord -Name $mxA -Type PTR
$mxServerInfo.PtrRecord = $ptrResult | Select-Object -First 1 -ExpandProperty NameHost
}
}
}
#endregion
#region DANE/TLSA Check
$daneInfo = @{ Exists = $false }
if ($queryResults.MX) {
$lowestMx = $queryResults.MX | Sort-Object -Property Preference | Select-Object -First 1 -ExpandProperty NameExchange
if ($lowestMx) {
$tlsaRecord = Resolve-DnsRecord -Name "_25._tcp.$lowestMx" -Type TLSA
$daneInfo.Exists = $null -ne $tlsaRecord
}
}
#endregion
#region Blocklist Check
$blocklistInfo = @{ Listed = 'Skipped'; Blocklists = @() }
if (-not $skipBlocklist -and $mxServerInfo.IPv4) {
$dnsBls = @('zen.spamhaus.org', 'bl.spamcop.net', 'b.barracudacentral.org', 'dnsbl.sorbs.net')
$reversedIp = ($mxServerInfo.IPv4.Split('.')[3..0]) -join '.'
$listedOn = @()
foreach ($dnsbl in $dnsBls) {
$blResult = Resolve-DnsRecord -Name "$reversedIp.$dnsbl" -Type A -DnsOnly
if ($blResult) {
$listedOn += $dnsbl
}
}
$blocklistInfo.Listed = $listedOn.Count -gt 0
$blocklistInfo.Blocklists = $listedOn
}
#endregion
#region MOERA DMARC Check
$moeraDmarc = $null
$moeraDomain = $queryResults.M365DOMAINS |
Where-Object { $_ -like '*.onmicrosoft.com' -and $_ -notlike '*.mail.onmicrosoft.com' } |
Select-Object -First 1
if ($moeraDomain) {
$moeraDmarcRecord = Resolve-DnsRecord -Name "_dmarc.$moeraDomain" -Type TXT
$moeraDmarc = $moeraDmarcRecord |
Where-Object { $_.Strings -like '*v=DMARC1*' } |
Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue
}
#endregion
#region Parse SPF Record
$spfInfo = @{ Exists = $false; Record = $null; Mode = $null }
$spfRecord = $queryResults.TXT | Where-Object { $_.Strings -like '*v=spf1*' }
if ($spfRecord) {
if (@($spfRecord).Count -gt 1) {
$spfInfo.Exists = 'ERROR: Multiple SPF records'
}
else {
$spfText = if ($spfRecord.Strings.Count -gt 1) { $spfRecord.Strings -join '' } else { $spfRecord.Strings[0] }
$spfInfo.Exists = $true
$spfInfo.Record = $spfText
$spfInfo.Mode = switch -Wildcard ($spfText) {
'*-all' { 'Fail' }
'*+all' { 'Pass' }
'*~all' { 'SoftFail' }
'*?all' { 'Neutral' }
default { 'Unknown' }
}
}
}
#endregion
#region Parse Wildcard SPF
$wildcardSpfInfo = @{ Exists = $false; Record = $null }
$wildcardSpf = $queryResults.WILDCARDTXT | Where-Object { $_.Strings -like '*v=spf1*' }
if ($wildcardSpf) {
$wildcardSpfInfo.Exists = $true
$wildcardSpfInfo.Record = if ($wildcardSpf.Strings.Count -gt 1) { $wildcardSpf.Strings -join '' } else { $wildcardSpf.Strings[0] }
}
#endregion
#region Parse DMARC Record
$dmarcInfo = @{ Exists = $false; Record = $null; Policy = $null; SubdomainPolicy = $null }
$dmarcRecord = $queryResults.DMARC | Where-Object { $_.Strings -like '*v=DMARC1*' } |
Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue
if ($dmarcRecord) {
$dmarcText = if ($dmarcRecord -is [array]) { $dmarcRecord -join '' } else { $dmarcRecord }
$dmarcInfo.Exists = $true
$dmarcInfo.Record = $dmarcText
$policyMatch = $dmarcText.Split(';') | Where-Object { $_ -match '^\s*p=' }
$subPolicyMatch = $dmarcText.Split(';') | Where-Object { $_ -match 'sp=' }
$dmarcInfo.Policy = if ($policyMatch) { ($policyMatch -replace '\s' -replace 'p=').ToUpper() } else { $null }
$dmarcInfo.SubdomainPolicy = if ($subPolicyMatch) { ($subPolicyMatch -replace '\s' -replace 'sp=').ToUpper() } else { $null }
}
#endregion
#region Parse MX Records
$mxInfo = @{ Exists = $false; LowestPreference = $null; Provider = 'No MX record' }
if ($queryResults.MX) {
$lowestMx = $queryResults.MX | Sort-Object -Property Preference | Select-Object -First 1 -ExpandProperty NameExchange
$mxInfo.Exists = $true
$mxInfo.LowestPreference = $lowestMx
$mxInfo.Provider = switch -Wildcard ($lowestMx) {
'inbound-smtp.*.amazonaws.com' { 'Amazon SES' }
'aspmx*google.com' { 'Google Workspace' }
'*mimecast*' { 'Mimecast' }
'*barracudanetworks.com' { 'Barracuda ESS' }
'*fireeyecloud.com' { 'FireEye Email Security' }
'*.eo.outlook.com' { 'Microsoft Exchange Online' }
'*sophos.com' { 'Sophos' }
'*pphosted*' { 'Proofpoint' }
'*ppe-hosted*' { 'Proofpoint' }
'*mail.protection.outlook.com*' { 'Microsoft Exchange Online' }
'*.mx.microsoft' { 'Microsoft Exchange Online' }
'*messagelabs*' { 'Symantec.cloud' }
'*trendmicro*' { 'Trend Micro' }
'*.sendgrid.net' { 'SendGrid' }
'*.mailgun.org' { 'Mailgun' }
'*.emailsrvr.com' { 'Rackspace' }
default { 'Other' }
}
}
#endregion
#region Determine Exchange Online Status
$exchangeOnlineStatus = 'No'
if ($queryResults.MSOID | Where-Object { $_.NameHost -like '*clientconfig.microsoftonline*' }) { $exchangeOnlineStatus = 'Possibly' }
if ($queryResults.TXT | Where-Object { $_.Strings -like 'MS=ms*' }) { $exchangeOnlineStatus = 'Possibly' }
if ($queryResults.ENTERPRISEREGISTRATION | Where-Object { $_.NameHost -eq 'enterpriseregistration.windows.net' }) { $exchangeOnlineStatus = 'Likely' }
if ($queryResults.AUTODISCOVER | Where-Object { $_.NameHost -eq 'autodiscover.outlook.com' }) { $exchangeOnlineStatus = 'Yes' }
if ($queryResults.TXT | Where-Object { $_.Strings -like '*spf.protection.outlook.com*' }) { $exchangeOnlineStatus = 'Yes' }
if ($queryResults.MX | Where-Object { $_.NameExchange -like '*mail.protection.outlook.com*' -or $_.NameExchange -like '*eo.outlook.com' }) { $exchangeOnlineStatus = 'Yes' }
#endregion
#region Parse M365 Tenant Name
$m365TenantName = $null
$mxForTenant = $queryResults.MX |
Where-Object { $_.NameExchange -like '*.mail.protection.outlook.com' } |
Sort-Object -Property Preference |
Select-Object -First 1 -ExpandProperty NameExchange
if ($mxForTenant) {
$m365TenantName = $mxForTenant -replace '\.mail\.protection\.outlook\.com$'
}
#endregion
#region Parse DKIM Selectors
$dkimSelectors = @()
if ($queryResults.DKIM_SELECTOR1) { $dkimSelectors += 'selector1' }
if ($queryResults.DKIM_SELECTOR2) { $dkimSelectors += 'selector2' }
if ($queryResults.DKIM_GOOGLE) { $dkimSelectors += 'google' }
if ($queryResults.DKIM_DEFAULT) { $dkimSelectors += 'default' }
if ($queryResults.DKIM_K1) { $dkimSelectors += 'k1' }
if ($queryResults.DKIM_S1) { $dkimSelectors += 's1' }
if ($queryResults.DKIM_S2) { $dkimSelectors += 's2' }
$m365DkimEnabled = ($null -ne $queryResults.DKIM_SELECTOR1) -and ($null -ne $queryResults.DKIM_SELECTOR2)
#endregion
#region Parse BIMI Record
$bimiInfo = @{ Exists = $false; LogoUrl = $null; VmcUrl = $null }
$bimiRecord = $queryResults.BIMI | Where-Object { $_.Strings -like '*v=BIMI1*' } |
Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue
if ($bimiRecord) {
$bimiText = if ($bimiRecord -is [array]) { $bimiRecord -join '' } else { $bimiRecord }
$bimiInfo.Exists = $true
$logoMatch = [regex]::Match($bimiText, 'l=([^;]+)')
$vmcMatch = [regex]::Match($bimiText, 'a=([^;]+)')
$bimiInfo.LogoUrl = if ($logoMatch.Success) { $logoMatch.Groups[1].Value.Trim() } else { $null }
$bimiInfo.VmcUrl = if ($vmcMatch.Success) { $vmcMatch.Groups[1].Value.Trim() } else { $null }
}
#endregion
#region Parse TLS-RPT Record
$tlsRptInfo = @{ Exists = $false; ReportUri = $null }
$tlsRptRecord = $queryResults.TLSRPT | Where-Object { $_.Strings -like '*v=TLSRPTv1*' } |
Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue
if ($tlsRptRecord) {
$tlsRptText = if ($tlsRptRecord -is [array]) { $tlsRptRecord -join '' } else { $tlsRptRecord }
$tlsRptInfo.Exists = $true
$ruaMatch = [regex]::Match($tlsRptText, 'rua=([^;]+)')
$tlsRptInfo.ReportUri = if ($ruaMatch.Success) { $ruaMatch.Groups[1].Value.Trim() } else { $null }
}
#endregion
#region Parse CAA Records
$caaInfo = @{ Exists = $false; Issuers = $null; WildcardIssuers = $null; IncidentReport = $null }
if ($queryResults.CAA) {
$caaInfo.Exists = $true
$caaInfo.Issuers = ($queryResults.CAA | Where-Object { $_.Tag -eq 'issue' } | Select-Object -ExpandProperty Value) -join ', '
$caaInfo.WildcardIssuers = ($queryResults.CAA | Where-Object { $_.Tag -eq 'issuewild' } | Select-Object -ExpandProperty Value) -join ', '
$caaInfo.IncidentReport = ($queryResults.CAA | Where-Object { $_.Tag -eq 'iodef' } | Select-Object -ExpandProperty Value) -join ', '
}
#endregion
#region Parse Federation Information
$federationInfo = @{ IsFederated = $false; Provider = $null; Hostname = $null; BrandName = $null }
if ($queryResults.FEDERATION) {
$federationInfo.IsFederated = $queryResults.FEDERATION.NameSpaceType -eq 'Federated'
$federationInfo.BrandName = $queryResults.FEDERATION.FederationBrandName
if ($queryResults.FEDERATION.AuthURL) {
$federationInfo.Hostname = ($queryResults.FEDERATION.AuthURL -replace 'https?://').Split('/')[0]
$federationInfo.Provider = switch -Wildcard ($federationInfo.Hostname) {
'*.okta.com' { 'Okta' }
'*.onelogin.com' { 'OneLogin' }
'*.pingidentity.com' { 'Ping Identity' }
'*.duosecurity.com' { 'Duo Security' }
default { 'Other' }
}
}
}
#endregion
#region Build Output Object
[PSCustomObject][ordered]@{
# Domain
Domain = $currentDomain
# MX Records
MxRecordsExist = $mxInfo.Exists
MxProvider = $mxInfo.Provider
MxLowestPreference = $mxInfo.LowestPreference
MxPrimaryIPv4 = $mxServerInfo.IPv4
MxPrimaryIPv6 = $mxServerInfo.IPv6
MxPtrRecord = $mxServerInfo.PtrRecord
# SPF
SpfRecordExists = $spfInfo.Exists
SpfRecord = $spfInfo.Record
SpfMode = $spfInfo.Mode
WildcardSpfExists = $wildcardSpfInfo.Exists
WildcardSpfRecord = $wildcardSpfInfo.Record
# DKIM
DkimSelectorsFound = $dkimSelectors -join ', '
M365DkimEnabled = $m365DkimEnabled
# DMARC
DmarcRecordExists = $dmarcInfo.Exists
DmarcRecord = $dmarcInfo.Record
DmarcPolicy = $dmarcInfo.Policy
DmarcSubdomainPolicy = $dmarcInfo.SubdomainPolicy
# BIMI
BimiRecordExists = $bimiInfo.Exists
BimiLogoUrl = $bimiInfo.LogoUrl
BimiVmcUrl = $bimiInfo.VmcUrl
# MTA-STS
MtaStsRecordExists = $null -ne $queryResults.MTASTS
MtaStsPolicyMode = if ($queryResults.MTASTS) { $queryResults.MTASTS.Mode } else { $null }
MtaStsMaxAge = if ($queryResults.MTASTS) { $queryResults.MTASTS.MaxAge } else { $null }
MtaStsAllowedMxHosts = if ($queryResults.MTASTS) { $queryResults.MTASTS.AllowedMx } else { $null }
# TLS-RPT
TlsRptRecordExists = $tlsRptInfo.Exists
TlsRptReportUri = $tlsRptInfo.ReportUri
# DANE/TLSA
DaneTlsaExists = $daneInfo.Exists
# DNSSEC
DnssecEnabled = ($null -ne $queryResults.DNSKEY -and @($queryResults.DNSKEY).Count -gt 0)
# CAA
CaaRecordExists = $caaInfo.Exists
CaaAllowedIssuers = $caaInfo.Issuers
CaaWildcardIssuers = $caaInfo.WildcardIssuers
CaaIncidentReport = $caaInfo.IncidentReport
# Microsoft 365 / Entra ID
M365ExchangeOnline = $exchangeOnlineStatus
M365TenantName = $m365TenantName
M365MoeraDomain = $moeraDomain
M365MoeraDmarcRecord = $moeraDmarc
M365Domains = ($queryResults.M365DOMAINS -join ',')
M365IsFederated = $federationInfo.IsFederated
M365FederationProvider = $federationInfo.Provider
M365FederationHostname = $federationInfo.Hostname
M365FederationBrandName = $federationInfo.BrandName
EntraIdDirectoryId = $queryResults.DIRECTORYID
EntraIdIsUnmanaged = if ($queryResults.FEDERATION) { $queryResults.FEDERATION.IsViral } else { $null }
# ADFS
AdfsServerDetected = $null -ne $queryResults.ADFS
AdfsHostname = if ($queryResults.ADFS) { $queryResults.ADFS.Hostname } else { $null }
AdfsMetadataUrl = if ($queryResults.ADFS) { $queryResults.ADFS.MetadataUrl } else { $null }
AdfsEntityId = if ($queryResults.ADFS) { $queryResults.ADFS.EntityId } else { $null }
# Blocklist
OnBlocklist = $blocklistInfo.Listed
Blocklists = $blocklistInfo.Blocklists -join ', '
# DNS Infrastructure
DomainIPv4 = ($queryResults.A | Select-Object -ExpandProperty IPAddress -ErrorAction SilentlyContinue) -join ', '
DomainIPv6 = ($queryResults.AAAA | Select-Object -ExpandProperty IPAddress -ErrorAction SilentlyContinue) -join ', '
DnsRegistrar = $queryResults.SOA | Select-Object -First 1 -ExpandProperty NameAdministrator -ErrorAction SilentlyContinue
DnsNameservers = ($queryResults.NS | Where-Object { $_.NameHost } | Select-Object -ExpandProperty NameHost) -join ', '
}
#endregion
}
$stopwatch.Stop()
Write-Verbose -Message "Completed processing in $($stopwatch.Elapsed.TotalSeconds.ToString('F2')) seconds"
# Return results
return $results
}
}
# Export function if running as module
if ($MyInvocation.MyCommand.ScriptBlock.Module) {
Export-ModuleMember -Function Invoke-EmailRecon
}
# If running as script, execute with provided parameters
if ($MyInvocation.InvocationName -ne '.') {
# Script was executed directly
$scriptParams = @{}
if ($args.Count -gt 0) {
# Parse command line arguments
for ($i = 0; $i -lt $args.Count; $i++) {
switch -Regex ($args[$i]) {
'^-Domain$|^-EmailDomain$' {
$i++
$scriptParams['Domain'] = $args[$i] -split ','
}
'^-ThrottleLimit$' {
$i++
$scriptParams['ThrottleLimit'] = [int]$args[$i]
}
'^-SkipBlocklistCheck$' {
$scriptParams['SkipBlocklistCheck'] = $true
}
'^-Verbose$' {
$VerbosePreference = 'Continue'
}
}
}
if ($scriptParams.ContainsKey('Domain')) {
Invoke-EmailRecon @scriptParams
}
else {
Get-Help -Name $MyInvocation.MyCommand.Path -Detailed
}
}
}
@azurekid
Copy link
Author

Invoke-EmailRecon

Synopsis

Performs comprehensive email security reconnaissance on one or more domains.

Description

Invoke-EmailRecon performs parallel DNS lookups and HTTP requests to gather email security configuration data for specified domains. It provides a complete view of email security posture including authentication mechanisms, encryption policies, and infrastructure details.

The script is designed for:

  • Security assessments - Evaluate email security configurations
  • Penetration testing - Discover attack surface and misconfigurations
  • Compliance audits - Verify DMARC, SPF, DKIM policies
  • M365 tenant enumeration - Discover Microsoft 365 configuration details

Features

Category Checks Performed
Mail Exchange MX records, provider detection, IP resolution, PTR records
Authentication SPF, DKIM (multiple selectors), DMARC policies
Encryption MTA-STS, TLS-RPT, DANE/TLSA
Brand Protection BIMI logo and VMC certificate
DNS Security DNSSEC, CAA records
Microsoft 365 Exchange Online detection, tenant name, federated domains
Entra ID Directory ID, federation status, brand name
ADFS Federation endpoint discovery
Blocklists Spamhaus, SpamCop, Barracuda, SORBS

Requirements

  • PowerShell 7.0 or later (required for ForEach-Object -Parallel)
  • Windows: Uses native Resolve-DnsName cmdlet
  • macOS/Linux: Requires dig command (install via dnsutils or bind-utils)

Installation

The script can be used in multiple ways:

Dot-source the script

. ./Examples/Invoke-EmailRecon.ps1
Invoke-EmailRecon -Domain 'contoso.com'

Run directly

./Examples/Invoke-EmailRecon.ps1 -Domain 'contoso.com'

Import as part of BlackCat module

Import-Module ./BlackCat.psd1
Invoke-EmailRecon -Domain 'contoso.com'

Syntax

Invoke-EmailRecon
    -Domain <String[]>
    [-ThrottleLimit <Int32>]
    [-SkipBlocklistCheck]
    [-Verbose]
    [<CommonParameters>]

Parameters

-Domain

One or more email domains to analyze.

Property Value
Type String[]
Aliases EmailDomain, Name
Position 0
Required Yes
Pipeline Input Yes (ByValue, ByPropertyName)

-ThrottleLimit

Maximum number of domains to process concurrently.

Property Value
Type Int32
Default 10
Valid Range 1-50
Required No

-SkipBlocklistCheck

Skip DNS blocklist (DNSBL) lookups. This speeds up execution but omits spam blocklist status from results.

Property Value
Type Switch
Required No

Output Properties

The function returns a PSCustomObject with the following properties:

Domain Information

Property Type Description
Domain String The analyzed domain name

MX Records

Property Type Description
MxRecordsExist Boolean Whether MX records exist
MxProvider String Detected email provider (Microsoft, Google, Proofpoint, etc.)
MxLowestPreference String Primary MX hostname (lowest preference value)
MxPrimaryIPv4 String IPv4 address of primary MX server
MxPrimaryIPv6 String IPv6 address of primary MX server
MxPtrRecord String PTR record for primary MX IP

SPF (Sender Policy Framework)

Property Type Description
SpfRecordExists Boolean/String True, False, or "ERROR: Multiple SPF records"
SpfRecord String Full SPF record content
SpfMode String Enforcement mode: Fail, SoftFail, Neutral, Pass, Unknown
WildcardSpfExists Boolean Whether wildcard subdomains have SPF
WildcardSpfRecord String Wildcard SPF record content

DKIM (DomainKeys Identified Mail)

Property Type Description
DkimSelectorsFound String Comma-separated list of discovered selectors
M365DkimEnabled Boolean Whether Microsoft 365 DKIM is configured

Checked Selectors: selector1, selector2 (M365), google, default, k1, s1, s2

DMARC (Domain-based Message Authentication)

Property Type Description
DmarcRecordExists Boolean Whether DMARC record exists
DmarcRecord String Full DMARC record content
DmarcPolicy String Policy: NONE, QUARANTINE, REJECT
DmarcSubdomainPolicy String Subdomain policy (sp= tag)

BIMI (Brand Indicators for Message Identification)

Property Type Description
BimiRecordExists Boolean Whether BIMI record exists
BimiLogoUrl String URL to brand logo (l= tag)
BimiVmcUrl String URL to VMC certificate (a= tag)

MTA-STS (Mail Transfer Agent Strict Transport Security)

Property Type Description
MtaStsRecordExists Boolean Whether MTA-STS DNS record exists
MtaStsPolicyMode String Policy mode: enforce, testing, none
MtaStsMaxAge Int32 Policy max age in seconds
MtaStsAllowedMxHosts String Allowed MX hosts from policy

TLS-RPT (TLS Reporting)

Property Type Description
TlsRptRecordExists Boolean Whether TLS-RPT record exists
TlsRptReportUri String Report destination URI(s)

DANE/TLSA

Property Type Description
DaneTlsaExists Boolean Whether TLSA records exist for MX

DNSSEC

Property Type Description
DnssecEnabled Boolean Whether DNSSEC is enabled

CAA (Certificate Authority Authorization)

Property Type Description
CaaRecordExists Boolean Whether CAA records exist
CaaAllowedIssuers String Allowed certificate authorities
CaaWildcardIssuers String Allowed wildcard certificate CAs
CaaIncidentReport String Incident reporting URI (iodef)

Microsoft 365 / Entra ID

Property Type Description
M365ExchangeOnline String Detection confidence: No, Possibly, Likely, Yes
M365TenantName String Tenant name from MX record
M365MoeraDomain String Microsoft Online Email Routing Address domain
M365MoeraDmarcRecord String DMARC record for MOERA domain
M365Domains String All domains in the M365 tenant
M365IsFederated Boolean Whether authentication is federated
M365FederationProvider String Identity provider: Okta, OneLogin, Ping, Duo, Other
M365FederationHostname String Federation endpoint hostname
M365FederationBrandName String Organization brand name
EntraIdDirectoryId String Entra ID (Azure AD) tenant ID
EntraIdIsUnmanaged Boolean Whether the directory is unmanaged/viral

ADFS Discovery

Property Type Description
AdfsServerDetected Boolean Whether ADFS endpoint was found
AdfsHostname String ADFS server hostname
AdfsMetadataUrl String Federation metadata URL
AdfsEntityId String ADFS entity ID

Checked Prefixes: adfs, sso, sts, fs, auth, idf, fed, login, idp

Blocklist Status

Property Type Description
OnBlocklist Boolean/String True, False, or "Skipped"
Blocklists String Comma-separated list of blocklists

Checked DNSBLs: Spamhaus ZEN, SpamCop, Barracuda, SORBS

DNS Infrastructure

Property Type Description
DomainIPv4 String Domain A record(s)
DomainIPv6 String Domain AAAA record(s)
DnsRegistrar String SOA administrator email
DnsNameservers String Authoritative nameservers

Examples

Example 1: Single Domain Analysis

Invoke-EmailRecon -Domain 'contoso.com'

Example 2: Multiple Domains with Verbose Output

Invoke-EmailRecon -Domain 'contoso.com', 'fabrikam.com', 'tailspintoys.com' -Verbose

Example 3: High-Speed Parallel Processing

Invoke-EmailRecon -Domain $domains -ThrottleLimit 25 -SkipBlocklistCheck

Example 4: Pipeline Input from File

Get-Content -Path 'C:\Temp\domains.txt' | Invoke-EmailRecon | Export-Csv -Path 'results.csv' -NoTypeInformation

Example 5: Filter for Security Issues

Invoke-EmailRecon -Domain $domains | Where-Object {
    $_.DmarcPolicy -ne 'REJECT' -or
    $_.SpfRecordExists -eq $false -or
    $_.DnssecEnabled -eq $false
}

Example 6: M365 Tenant Enumeration

Invoke-EmailRecon -Domain 'target.com' | Select-Object Domain, M365*, EntraId*

Sample Output:

Domain                  : target.com
M365ExchangeOnline      : Yes
M365TenantName          : target-com
M365MoeraDomain         : target.onmicrosoft.com
M365Domains             : target.com,target.nl,target.de,target.onmicrosoft.com
M365IsFederated         : True
M365FederationProvider  : Okta
M365FederationHostname  : sso.target.com
M365FederationBrandName : Target Corporation
EntraIdDirectoryId      : a1b2c3d4-e5f6-7890-abcd-ef1234567890
EntraIdIsUnmanaged      : False

Cross-Platform Support

The script automatically detects the operating system and uses the appropriate DNS resolver:

Platform DNS Tool Detection Method
Windows Resolve-DnsName Native cmdlet
macOS dig Parses dig +short output
Linux dig Parses dig +short output

Installing dig on Linux

Debian/Ubuntu:

sudo apt-get install dnsutils

RHEL/CentOS/Fedora:

sudo dnf install bind-utils

Alpine:

apk add bind-tools

Installing dig on macOS

dig is included with macOS by default. If missing:

brew install bind

Performance

The script uses multiple parallelization strategies:

  1. Domain-level parallelism: ForEach-Object -Parallel processes multiple domains concurrently
  2. Query-level parallelism: Runspace pools (20 threads) execute DNS/HTTP queries concurrently within each domain
  3. Optimized DNS resolution: Queries are batched and executed in parallel

Benchmark Results:

Domains ThrottleLimit SkipBlocklist Time
1 10 Yes ~1.5s
10 10 Yes ~3s
100 25 Yes ~15s
100 25 No ~45s

Security Considerations

  • All queries are passive reconnaissance (no authentication required)
  • No credentials or tokens are transmitted
  • HTTP requests use TLS where available
  • Consider rate limiting when scanning many domains
  • Results may contain sensitive organizational information

Related Links

Version History

Version Date Changes
2.1.0 2026-01-22 Cross-platform support (macOS/Linux via dig)
2.0.0 2026-01-21 Rewritten with PowerShell best practices, runspace pools
1.0.0 2026-01-20 Initial parallel optimization

Author

BlackCat Security Tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment