Skip to content

Instantly share code, notes, and snippets.

@alexanderadam
Created January 28, 2025 15:34
Show Gist options
  • Save alexanderadam/22d329d92692492779addc7719ebe0de to your computer and use it in GitHub Desktop.
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)
#!/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