Check every customer domain's registration expiry and mail records (MX, SPF, DMARC) on a schedule, with no device needed
Check a list of domains' registration expiry and mail records from public DNS and RDAP, in the cloud sandbox, and run it on a schedule so an expiring domain or a broken SPF record is posted to your team before mail stops. For MSPs and IT teams that manage customers' domains and email.
| Runs in | Cloud sandbox (PowerShell) |
| Uses | None. Optionally the IT Alerts Channel webhook credential, to post problems |
| Suggested approval | None (engineers and admins run it without approval), because it only reads public information |
| Category |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and it hasn't been tested end to end. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, and adjust it together. Then test it, as in Test it. Test in the action editor isn't a dry run: it runs the script for real.
Why use an action instead of asking SuperIT
If a device is selected in the chat, SuperIT can already look up one domain's MX, SPF or DMARC record with Resolve-DnsName, so for a quick check on one domain, just ask. Build this action when:
- there's no device to run lookups from, because SuperIT has no DNS tool of its own;
- you need a domain's registration expiry, which needs an RDAP web request that SuperIT can't make in chat;
- you want every customer domain checked on a schedule, with problems posted to your channel.
When to use it
- "When does fabrikam.co.uk expire, and who are its name servers?"
- "Check all our customers' domains for missing mail records every month."
- A customer has no managed device online, and mail to them is bouncing with an SPF failure.
What the action does
For each domain:
- Looks up MX, NS and TXT records, and the TXT record at
_dmarc.<domain>, through a public DNS-over-HTTPS service.Resolve-DnsNameis Windows-only and isn't available in the Linux cloud sandbox, so the script asks over HTTPS withInvoke-RestMethodinstead. - Finds the SPF record and flags none, more than one, or one ending in
+all. - Finds the DMARC record and flags none or
p=none. - Looks up the registration over RDAP (the successor to WHOIS) and reports the expiry date and registrar. Some country-code registries don't publish expiry dates over RDAP; the report then says "not published" or "unknown".
- Prints a short report per domain with any problems first.
- If the
IT Alerts Channelwebhook credential is linked and any domain has a problem, posts the problems to that channel.
What it doesn't do:
- It doesn't change DNS. Changing records happens at your DNS host, ideally through a separate action with an approval.
- It doesn't check DKIM unless you give it the selectors to check (see Variations).
- It doesn't fully validate SPF, for example the 10-lookup limit. It flags the common mistakes only.
- It doesn't look up internal DNS zones. The sandbox only sees public DNS.
- It doesn't keep the domain list private from the lookup services. Each domain name is sent to Google's public DNS-over-HTTPS service (
dns.google) and to therdap.orgredirector, which passes the query on to the registry. If a customer doesn't want its domains shared with these services, changeGet-DnsandGet-RegistrationExpiryto services you're allowed to use.
Before you begin
- No credential is needed for the lookups, which use public services. To post problems to a channel, link the
IT Alerts Channelcredential from Post a message to a Teams or Slack channel. The script reads it asCREDENTIAL_IT_ALERTS_CHANNEL_WEBHOOK_URL. - No authorization policy is needed. See Approvals.
- Your role. Creating an action in chat needs the Admin role. Engineers can build it by hand under Workflows → Actions.
Build it with SuperIT
Create an action called "Check domain and DNS".
Purpose: for each domain, report MX, NS, SPF and DMARC from public DNS (DNS-over-HTTPS JSON at https://dns.google/resolve) and registration expiry and registrar from RDAP (https://rdap.org/domain/<domain>).
Runtime: PowerShell in the cloud sandbox, using Invoke-RestMethod (Resolve-DnsName isn't available on Linux).
Inputs: domains (string[], required), expiry_warn_days (string, optional, default 60).
Flag: no MX, no SPF, multiple SPF, SPF with +all, no DMARC, DMARC p=none, expiry within the warning window.
Optional credential "IT Alerts Channel" (webhook URL in CREDENTIAL_IT_ALERTS_CHANNEL_WEBHOOK_URL): if linked and any domain has a problem, post the problem lines to it (Slack text or Teams Adaptive Card). Never print the webhook URL.
Read-only apart from the post. No authorization policy.
SuperIT drafts the script, validates it and creates the action as a draft. You review it, test it and publish it under Workflows → Actions.
Or build it yourself
| Name | Type | Required | Example |
|---|---|---|---|
domains |
string[] | Yes | contoso.com, fabrikam.co.uk |
expiry_warn_days |
string | No | 60 |
Set Runtime to PowerShell:
# Goal: for each domain, report MX, NS, SPF, DMARC and registration expiry from public services, problems first,
# and post the problems to the alerts channel when its webhook credential is linked. Changes nothing.
$webhookUrl = "$env:CREDENTIAL_IT_ALERTS_CHANNEL_WEBHOOK_URL"
function Invoke-DomainDnsCheck {
param([string]$Domains, [string]$ExpiryWarnDays)
$domainList = @(ConvertTo-DomainList -Domains $Domains)
$warnDays = ConvertTo-WarnDays -Value $ExpiryWarnDays
$report = @($domainList | ForEach-Object { Get-DomainReport -Domain $_ -WarnDays $warnDays })
$sorted = @($report | Sort-Object -Property { $_.problems.Count } -Descending)
Write-DomainReport -Report $sorted
$alertLines = @($sorted | Where-Object { $_.problems.Count -gt 0 } | ForEach-Object { "$($_.domain): $($_.problems -join '; ')" })
$posted = $false
if ($alertLines.Count -gt 0) { $posted = Send-ChannelAlert -Title "Domain check: $($alertLines.Count) domain(s) need attention" -Lines $alertLines }
if ($posted) { Write-Output "Posted the problems to the alerts channel." }
# $script: so the runner can still read $result after this function returns.
$script:result = @{ domains = $sorted; alert_posted = $posted }
}
function ConvertTo-DomainList {
param([string]$Domains)
$list = @($Domains -split ',' | ForEach-Object { $_.Trim().ToLower().TrimEnd('.') } | Where-Object { $_ })
if ($list.Count -eq 0) { throw "Input 'domains' needs at least one domain." }
$bad = @($list | Where-Object { $_ -notmatch '^[a-z0-9.-]+\.[a-z]{2,}$' })
if ($bad.Count -gt 0) { throw "Not valid domain names: $($bad -join ', ')" }
return $list
}
function ConvertTo-WarnDays {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return 60 }
$days = 0
if (-not [int]::TryParse($Value.Trim(), [ref]$days)) { throw "Input 'expiry_warn_days' must be a whole number." }
return $days
}
function Get-DomainReport {
param([string]$Domain, [int]$WarnDays)
try {
$mx = @(Get-Dns $Domain "MX")
$ns = @(Get-Dns $Domain "NS")
$spf = @(Get-Dns $Domain "TXT" | Where-Object { $_ -like 'v=spf1*' })
$dmarc = @(Get-Dns "_dmarc.$Domain" "TXT" | Where-Object { $_ -like 'v=dmarc1*' })
} catch {
return [ordered]@{ domain = $Domain; problems = @("DNS lookup failed for '$Domain': $($_.Exception.Message)") }
}
$problems = [System.Collections.Generic.List[string]]::new()
foreach ($p in (Find-MailDnsProblem -Mx $mx -Spf $spf -Dmarc $dmarc)) { $problems.Add($p) }
$registration = Get-RegistrationExpiry -Domain $Domain
if ($null -ne $registration.days -and $registration.days -le $WarnDays) {
$problems.Add("registration expires in $($registration.days) days")
}
return [ordered]@{
domain = $Domain; problems = $problems.ToArray()
mx = $mx; ns = $ns; spf = $spf; dmarc = $dmarc; expires = $registration.expires
}
}
function Find-MailDnsProblem {
param([string[]]$Mx, [string[]]$Spf, [string[]]$Dmarc)
if ($Mx.Count -eq 0) { "no MX records" }
if ($Spf.Count -eq 0) { "no SPF record" }
if ($Spf.Count -gt 1) { "more than one SPF record (invalid)" }
if (@($Spf | Where-Object { $_.TrimEnd().EndsWith('+all') }).Count -gt 0) { "SPF ends in +all (allows anyone)" }
if ($Dmarc.Count -eq 0) { "no DMARC record" }
elseif ($Dmarc[0] -match '(^|;)\s*p=none') { "DMARC policy is p=none (monitor only)" }
}
function Get-RegistrationExpiry {
param([string]$Domain)
try {
$rdap = Get-Json "https://rdap.org/domain/$Domain" "application/rdap+json"
$raw = @($rdap.events | Where-Object { $_.eventAction -eq 'expiration' })[0].eventDate
if (-not $raw) { return @{ expires = "not published"; days = $null } }
# ConvertFrom-Json already turns ISO timestamps into [datetime].
$expiry = if ($raw -is [datetime]) { $raw.ToUniversalTime() } else { ([datetimeoffset]::Parse("$raw", [cultureinfo]::InvariantCulture)).UtcDateTime }
$days = [int][math]::Floor(($expiry - [datetime]::UtcNow).TotalDays)
return @{ expires = $expiry.ToString('yyyy-MM-dd'); days = $days }
} catch {
return @{ expires = "unknown ($($_.Exception.Message))"; days = $null }
}
}
function Get-Dns {
param([string]$Name, [string]$Type)
$typeCodes = @{ NS = 2; MX = 15; TXT = 16 }
$query = "name=$([uri]::EscapeDataString($Name))&type=$Type"
$answer = (Get-Json "https://dns.google/resolve?$query" "application/json").Answer
return @($answer | Where-Object { $_.type -eq $typeCodes[$Type] } |
ForEach-Object { "$($_.data)".Trim('"').Replace('" "', '') })
}
function Get-Json {
param([string]$Uri, [string]$Accept)
$response = Invoke-RestMethod -Uri $Uri -Headers @{ Accept = $Accept } -UserAgent "SuperIT-check" -TimeoutSec 20
# RDAP servers answer with application/rdap+json, which may come back as unparsed text.
if ($response -is [string]) { $response = $response | ConvertFrom-Json }
return $response
}
function Write-DomainReport {
param([object[]]$Report)
Write-Output "=== DOMAIN AND DNS CHECK ==="
foreach ($r in $Report) {
$summary = if ($r.problems.Count -gt 0) { $r.problems -join '; ' } else { 'no problems found' }
Write-Output "== $($r.domain): $summary"
foreach ($key in 'mx', 'ns', 'spf', 'dmarc') {
if ($r.Contains($key)) {
$values = if (@($r[$key]).Count -gt 0) { @($r[$key]) -join ', ' } else { '(none)' }
Write-Output " $($key.ToUpper()): $values"
}
}
if ($r.Contains('expires')) { Write-Output " Expires: $($r.expires)" }
}
}
function Send-ChannelAlert {
param([string]$Title, [string[]]$Lines)
if (-not $webhookUrl) { return $false }
$text = "$Title`n" + ($Lines -join "`n")
if ($webhookUrl -like "*hooks.slack.com*") {
# Escaped so Slack shows <, > and & as typed instead of reading them as mentions or links.
$payload = @{ text = $text.Replace('&', '&').Replace('<', '<').Replace('>', '>') }
} else {
$card = @{ type = "AdaptiveCard"; version = "1.4"; body = @(@{ type = "TextBlock"; text = ($text -replace "`n", "`n`n"); wrap = $true }) }
$payload = @{ type = "message"; attachments = @(@{ contentType = "application/vnd.microsoft.card.adaptive"; content = $card }) }
}
$json = $payload | ConvertTo-Json -Depth 10
try {
Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json; charset=utf-8" `
-Body ([System.Text.Encoding]::UTF8.GetBytes($json)) -TimeoutSec 30 | Out-Null
} catch {
# Never include $webhookUrl in a message: anyone holding it can post to the channel.
throw "Failed to post the domain check alert to the channel: $($_.Exception.Message)"
}
# Only the Boolean is output: the caller captures it as alert_posted, so a message here would turn it into an array.
return $true
}
Invoke-DomainDnsCheck -Domains $ctx.inputs.domains -ExpiryWarnDays $ctx.inputs.expiry_warn_days
Test it
- Save the draft and select Test.
- Test is not a dry run: it skips authorization and approvals and uses real network access. This action only reads public DNS, so testing is safe. Test runs aren't billed.
- Test with your own domain and compare with your DNS host's records.
- Test with a domain you know lacks DMARC and confirm it's flagged.
- Link the
IT Alerts Channelcredential, test again with that domain, and confirm the problem appears in the channel. - Select Publish.
Use it
- Monthly sweep: ask SuperIT "Run Check domain and DNS every 30 days for contoso.com, fabrikam.co.uk and northwind.com.au, with expiry_warn_days 60". A scheduled run's output isn't sent anywhere, so link the
IT Alerts Channelcredential: the script posts only when a domain has a problem. One schedule can check every domain in its list. - In chat, on an email ticket: "Mail to this customer bounces with an SPF failure. Check their domain." SuperIT runs the action and explains what's wrong in plain language, with the record to add.
- Managed teams: publish it with Publish to teams so each customer team can check its own domains. If the action has no linked credential, the copies work straight away. If you linked the
IT Alerts Channelcredential, each copy gets a blank credential, and its runs are refused until that team's admin fills it in.
Variations
- DKIM: add a
dkim_selectorsinput (for Microsoft 365,selector1, selector2) and look up<selector>._domainkey.<domain>asCNAMEandTXT. - Typo-squatting check: generate common misspellings of a domain and report which ones resolve, as a look-alike domain warning.
- Microsoft 365 view: compare these records with the domains in the customer's Microsoft 365 tenant, which SuperIT can read through its Microsoft 365 connection.