Created
January 28, 2025 15:34
-
-
Save alexanderadam/22d329d92692492779addc7719ebe0de to your computer and use it in GitHub Desktop.
Small script to check the health of a mailserver (IMAP and SMTP ports, SPF, etc)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# frozen_string_literal: true | |
require 'bundler/inline' | |
gemfile do | |
source 'https://rubygems.org' | |
gem 'net-smtp' | |
gem 'net-imap' | |
gem 'resolv' | |
gem 'colorize' | |
gem 'openssl' | |
gem 'optparse' | |
gem 'spf-query' | |
gem 'dkim-query' | |
gem 'dmarc' | |
end | |
SMTP_PORTS = { | |
plain: 25, | |
explicit_tls: 587, | |
# implicit_tls: 465 | |
}.freeze | |
IMAP_PORTS = { | |
plain: 143, | |
implicit_tls: 993 | |
}.freeze | |
BLOCKLISTS = [ | |
'zen.spamhaus.org', | |
'bl.spamcop.net', | |
'dnsbl.sorbs.net' | |
].freeze | |
class MailServerTester | |
def initialize(domain, username = nil, password = nil) | |
@domain = domain | |
@username = username | |
@password = password | |
@results = {} | |
end | |
def run_tests | |
check_dns_records | |
check_smtp_connectivity | |
check_imap_connectivity | |
check_blocklists | |
print_summary | |
end | |
private | |
def check_dns_records | |
puts "\n🔍 Checking DNS records...".blue | |
resolver = Resolv::DNS.new | |
begin | |
mx_records = resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX) | |
@results[:mx] = mx_records.any? | |
prefix = @results[:mx] ? "✓" : "⨯ No" | |
msg = "#{prefix} MX records found: #{mx_records.map(&:exchange).join(', ')}" | |
msg = @results[:mx] ? msg.green : msg.yellow | |
puts msg | |
rescue => e | |
@results[:mx] = false | |
puts "⨯ MX lookup failed: #{e.message}".red | |
end | |
check_spf | |
check_dmarc | |
# check_dkim | |
end | |
def check_spf | |
spf = SPF::Query::Record.query(@domain) | |
if spf | |
@results[:spf] = true | |
puts "✓ SPF record found: #{spf}".green | |
analyze_spf_mechanisms(spf) | |
else | |
@results[:spf] = false | |
puts "⨯ No SPF record found".red | |
puts "ℹ️ Tip: Add a TXT record with 'v=spf1 ...' to specify allowed senders".yellow | |
end | |
rescue => e | |
@results[:spf] = false | |
puts "⨯ SPF lookup failed: #{e.message}".red | |
end | |
def analyze_spf_mechanisms(spf) | |
mechanisms = { | |
ip4: [], | |
ip6: [], | |
include: [], | |
mx: [], | |
a: [] | |
} | |
spf.mechanisms.each do |mechanism| | |
case mechanism.name | |
when :ip4 | |
mechanisms[:ip4] << mechanism.value | |
when :ip6 | |
mechanisms[:ip6] << mechanism.value | |
when :include | |
mechanisms[:include] << mechanism.value | |
when :mx | |
mechanisms[:mx] << (mechanism.value || @domain) | |
when :a | |
mechanisms[:a] << (mechanism.value || @domain) | |
end | |
end | |
puts "\n📋 SPF Analysis:".blue | |
if mechanisms[:ip4].any? || mechanisms[:ip6].any? | |
puts "\n Direct IP Authorizations:".cyan | |
group_and_display_ips(mechanisms[:ip4], mechanisms[:ip6]) | |
end | |
if mechanisms[:include].any? | |
puts "\n Included Domains:".cyan | |
group_and_display_includes(mechanisms[:include]) | |
end | |
if mechanisms[:mx].any? | |
puts "\n MX Records:".cyan | |
group_and_display_mx(mechanisms[:mx]) | |
end | |
if mechanisms[:a].any? | |
puts "\n A Records:".cyan | |
group_and_display_a(mechanisms[:a]) | |
end | |
end | |
def group_and_display_ips(ipv4s, ipv6s) | |
grouped_ips = {} | |
resolver = Resolv::DNS.new | |
def find_hostname_for_ip(ip, resolver) | |
begin | |
hostname = resolver.getname(ip.to_s.split('/').first) | |
return hostname | |
rescue Resolv::ResolvError | |
# If reverse DNS fails, try to find matching A/AAAA records | |
resolver.getresources(ip, Resolv::DNS::Resource::IN::PTR).each do |ptr| | |
begin | |
addr = resolver.getaddress(ptr.name.to_s) | |
return ptr.name.to_s if addr.to_s == ip.to_s.split('/').first | |
rescue Resolv::ResolvError | |
next | |
end | |
end | |
end | |
nil | |
end | |
ipv4s.each do |ip| | |
ip_addr = ip.to_s.split('/').first | |
hostname = find_hostname_for_ip(ip_addr, resolver) | |
key = hostname || 'Unknown' | |
grouped_ips[key] ||= { ipv4: [], ipv6: [] } | |
grouped_ips[key][:ipv4] << ip | |
end | |
ipv6s.each do |ip| | |
ip_addr = ip.to_s.split('/').first | |
hostname = find_hostname_for_ip(ip_addr, resolver) | |
key = hostname || 'Unknown' | |
grouped_ips[key] ||= { ipv4: [], ipv6: [] } | |
grouped_ips[key][:ipv6] << ip | |
end | |
# If we still have "Unknown" entries, try to match them with A/AAAA records | |
if grouped_ips['Unknown'] | |
grouped_ips['Unknown'][:ipv4].dup.each do |ip| | |
ip_addr = ip.to_s.split('/').first | |
@domain_cache ||= {} | |
# Try to find matching A records for all domains we've seen | |
grouped_ips.keys.each do |domain| | |
next if domain == 'Unknown' | |
begin | |
addresses = @domain_cache[domain] ||= resolver.getaddresses(domain) | |
if addresses.map(&:to_s).include?(ip_addr) | |
grouped_ips[domain] ||= { ipv4: [], ipv6: [] } | |
grouped_ips[domain][:ipv4] << ip | |
grouped_ips['Unknown'][:ipv4].delete(ip) | |
end | |
rescue Resolv::ResolvError | |
next | |
end | |
end | |
end | |
end | |
grouped_ips.delete('Unknown') if grouped_ips['Unknown'] && | |
grouped_ips['Unknown'][:ipv4].empty? && | |
grouped_ips['Unknown'][:ipv6].empty? | |
grouped_ips.each do |hostname, ips| | |
puts " #{hostname}:".yellow | |
puts " IPv4: #{ips[:ipv4].join(', ')}".green if ips[:ipv4].any? | |
puts " IPv6: #{ips[:ipv6].join(', ')}".green if ips[:ipv6].any? | |
end | |
end | |
def group_and_display_includes(domains) | |
domains.sort.each do |domain| | |
begin | |
included_spf = SPF::Query::Record.query(domain) | |
status = included_spf ? "✓".green : "⨯".red | |
puts " #{status} #{domain}" | |
if included_spf | |
puts " └─ #{included_spf}".gray | |
end | |
rescue => e | |
puts " ⨯ #{domain} (error: #{e.message})".red | |
end | |
end | |
end | |
def group_and_display_mx(domains) | |
domains.sort.uniq.each do |domain| | |
begin | |
mx_records = Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::MX) | |
if mx_records.any? | |
puts " ✓ #{domain}:".green | |
mx_records.sort_by(&:preference).each do |mx| | |
puts " └─ #{mx.exchange} (preference: #{mx.preference})".gray | |
end | |
else | |
puts " ⨯ #{domain} (no MX records)".red | |
end | |
rescue => e | |
puts " ⨯ #{domain} (error: #{e.message})".red | |
end | |
end | |
end | |
def group_and_display_a(domains) | |
ip_groups = {} | |
domains.sort.uniq.each do |domain| | |
begin | |
addresses = Resolv::DNS.new.getaddresses(domain) | |
if addresses.any? | |
addresses.each do |addr| | |
ip_groups[addr.to_s] ||= [] | |
ip_groups[addr.to_s] << domain | |
end | |
else | |
puts " ⨯ #{domain} (no A records)".red | |
end | |
rescue => e | |
puts " ⨯ #{domain} (error: #{e.message})".red | |
end | |
end | |
ip_groups.sort.each do |ip, related_domains| | |
puts " IP: #{ip}".cyan | |
related_domains.sort.each do |domain| | |
puts " └─ #{domain}".gray | |
end | |
end | |
end | |
def check_dkim | |
puts "\n🔑 Checking DKIM records...".blue | |
begin | |
domain = DKIM::Query::Domain.query(@domain) | |
if domain && !domain.keys.empty? | |
@results[:dkim] = true | |
puts "✓ DKIM records found:".green | |
domain.each do |selector, key| | |
puts " Selector '#{selector}':".green | |
puts " - Key type: #{key.k || 'not specified'}" | |
puts " - Notes: #{key.n}" if key.n | |
if key.p | |
puts " ✓ Public key present (#{key.p.length} bytes)".green | |
else | |
puts " ⨯ No public key found".red | |
end | |
end | |
else | |
@results[:dkim] = false | |
puts "⨯ No DKIM records found".red | |
puts "ℹ️ Tip: Configure DKIM by adding selector._domainkey TXT records".yellow | |
end | |
rescue => e | |
@results[:dkim] = false | |
puts "⨯ DKIM lookup failed: #{e.message}".red | |
puts "ℹ️ Common selectors to try: default, google, mail, dkim".yellow | |
end | |
end | |
def check_dmarc | |
puts "\n📝 Checking DMARC record...".blue | |
begin | |
record = DMARC::Record.query(@domain) | |
if record | |
@results[:dmarc] = true | |
puts "✓ DMARC record found:".green | |
puts " Policy: #{record.p}".green | |
puts " Subdomain Policy: #{record.sp || 'not specified'}" | |
if record.rua&.any? | |
puts " Aggregate Reports:" | |
record.rua.each do |uri| | |
puts " - #{uri.uri}" | |
end | |
end | |
if record.ruf&.any? | |
puts " Forensic Reports:" | |
record.ruf.each do |uri| | |
puts " - #{uri.uri}" | |
end | |
end | |
puts " Additional settings:" | |
puts " - DKIM Alignment: #{record.adkim || 'relaxed'}" | |
puts " - SPF Alignment: #{record.aspf || 'relaxed'}" | |
puts " - Failure Options: #{record.fo&.join(', ') || 'not specified'}" | |
puts " - Report Interval: #{record.ri || '86400'} seconds" | |
puts " - Percent: #{record.pct || '100'}%" | |
else | |
@results[:dmarc] = false | |
puts "⨯ No DMARC record found".red | |
puts "ℹ️ Tip: Add a TXT record at _dmarc.#{@domain} with 'v=DMARC1;'".yellow | |
end | |
rescue => e | |
@results[:dmarc] = false | |
puts "⨯ DMARC lookup failed: #{e.message}".red | |
end | |
end | |
def check_smtp_connectivity | |
puts "\n📧 Testing SMTP connectivity...".blue | |
SMTP_PORTS.each do |type, port| | |
begin | |
smtp = Net::SMTP.new(@domain, port) | |
smtp.enable_starttls if type == :explicit_tls | |
smtp.start(@domain) | |
@results[:"smtp_#{type}"] = true | |
puts "✓ SMTP #{type} (#{port}) connection successful".green | |
smtp.finish | |
rescue => e | |
@results[:"smtp_#{type}"] = false | |
puts "⨯ SMTP #{type} (#{port}) failed: #{e.message}".red | |
end | |
end | |
end | |
def check_imap_connectivity | |
puts "\n📬 Testing IMAP connectivity...".blue | |
IMAP_PORTS.each do |type, port| | |
begin | |
imap = Net::IMAP.new(@domain, port, type == :implicit_tls) | |
if @username && @password | |
imap.authenticate('LOGIN', @username, @password) | |
puts "✓ IMAP authentication successful".green | |
end | |
@results[:"imap_#{type}"] = true | |
puts "✓ IMAP #{type} (#{port}) connection successful".green | |
imap.disconnect | |
rescue => e | |
@results[:"imap_#{type}"] = false | |
puts "⨯ IMAP #{type} (#{port}) failed: #{e.message}".red | |
end | |
end | |
end | |
def check_blocklists | |
puts "\n⚠️ Checking blocklists...".blue | |
ip = Resolv.getaddress(@domain) | |
BLOCKLISTS.each do |blocklist| | |
reversed_ip = ip.split('.').reverse.join('.') | |
begin | |
Resolv.getaddress("#{reversed_ip}.#{blocklist}") | |
@results[:"blocklist_#{blocklist}"] = false | |
puts "⨯ Listed in #{blocklist}".red | |
rescue Resolv::ResolvError | |
@results[:"blocklist_#{blocklist}"] = true | |
puts "✓ Not listed in #{blocklist}".green | |
end | |
end | |
end | |
def print_summary | |
puts "\n📊 Summary Report".blue | |
success_count = @results.count { |_, v| v } | |
total_count = @results.except(:mx).size | |
puts "Total checks: #{total_count}" | |
if success_count == total_count | |
puts "\n✨ All checks passed! Mail server appears to be properly configured.".green | |
else | |
puts "Passing: #{success_count}".green | |
puts "Failing: #{total_count - success_count}".red | |
puts "\n⚠️ Some checks failed. Review the logs above for details.".yellow | |
end | |
end | |
end | |
options = {} | |
OptionParser.new do |opts| | |
opts.banner = "Usage: #{$0} DOMAIN [options]" | |
opts.on('-u', '--username USERNAME', 'IMAP/SMTP username') { |v| options[:username] = v } | |
opts.on('-p', '--password PASSWORD', 'IMAP/SMTP password') { |v| options[:password] = v } | |
end.parse! | |
domain = ARGV[0] | |
abort("Please provide a domain name") unless domain | |
tester = MailServerTester.new(domain, options[:username], options[:password]) | |
tester.run_tests |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment