Added SPF check cli command

This commit is contained in:
Mr Sleeps
2026-07-02 12:39:01 +01:00
parent 5859d1f17f
commit 0f74f6f571
6 changed files with 863 additions and 44 deletions
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('vw_spf_checks', function (Blueprint $table) {
$table->id();
$table->string('domain')->unique()->index();
$table->unsignedBigInteger('domain_id')->nullable()->index();
$table->text('record')->nullable();
$table->string('spf_version')->nullable()->default('v=spf1');
$table->string('policy')->nullable()->index(); // -all, ~all, ?all, +all
$table->integer('lookup_count')->default(0);
$table->json('mechanisms')->nullable(); // Store all mechanisms as JSON
$table->json('includes')->nullable(); // Include mechanisms
$table->json('ip4')->nullable(); // IPv4 addresses
$table->json('ip6')->nullable(); // IPv6 addresses
$table->json('mx_domains')->nullable(); // MX mechanisms
$table->json('a_domains')->nullable(); // A mechanisms
$table->json('modifiers')->nullable(); // Redirect, Exp modifiers
$table->boolean('has_ptr')->default(false);
$table->boolean('has_exists')->default(false);
$table->boolean('valid')->default(false)->index();
$table->text('error_message')->nullable();
$table->json('validation_issues')->nullable(); // Store any validation issues
$table->integer('mechanism_count')->default(0);
$table->timestamp('last_checked_at')->nullable()->index();
$table->timestamp('next_check_at')->nullable()->index();
$table->timestamps();
// Add foreign key constraint if needed
// $table->foreign('domain_id')->references('domain_id')->on('domains')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('vw_spf_checks');
}
};
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace VEximweb\Plugin\DnsTools\Console\Commands;
use Illuminate\Console\Command;
use VEximweb\Plugin\DnsTools\Services\SpfRecordService;
class CheckSpfRecords extends Command
{
protected $signature = 'vw:spf-check
{--domain= : Check a specific domain}
{--all : Check all domains}
{--stats : Show SPF statistics}';
protected $description = 'Check SPF records for domains';
public function handle(SpfRecordService $service): void
{
if ($this->option('stats')) {
$stats = $service->getStats();
$this->info("SPF Statistics:");
$this->line("Total domains checked: {$stats['total']}");
$this->line("Valid SPF records: {$stats['valid']}");
$this->line("No SPF record: {$stats['no_record']}");
$this->line("Invalid records: {$stats['invalid']}");
if (!empty($stats['policies'])) {
$this->line("\nPolicy Breakdown:");
foreach ($stats['policies'] as $policy => $count) {
$label = match($policy) {
'-all' => 'Hard Fail',
'~all' => 'Soft Fail',
'?all' => 'Neutral',
'+all' => 'Pass (DANGEROUS)',
default => $policy,
};
$this->line(" {$label}: {$count}");
}
}
if (!empty($stats['lookup_distribution'])) {
$this->line("\nLookup Count Distribution:");
foreach ($stats['lookup_distribution'] as $count => $domains) {
$this->line(" {$count} lookups: {$domains} domains");
}
}
if ($stats['high_lookup_count'] > 0) {
$this->warn("\n⚠️ Domains with >10 lookups (may cause SPF failures):");
foreach (array_slice($stats['high_lookups'], 0, 10) as $domain) {
$this->line(" - {$domain}");
}
if ($stats['high_lookup_count'] > 10) {
$this->line(" ... and " . ($stats['high_lookup_count'] - 10) . " more");
}
}
return;
}
if ($this->option('domain')) {
$domain = $this->option('domain');
$this->info("Checking SPF for: {$domain}");
$result = $service->checkDomain($domain);
if ($result && $result->valid) {
$this->info("✓ SPF record found!");
$this->line("Policy: {$result->policy}");
$this->line("Record: {$result->record}");
$this->line("Lookups: {$result->lookup_count}");
if ($result->lookup_count > 10) {
$this->warn("⚠️ Warning: {$result->lookup_count} DNS lookups exceeds recommended maximum of 10");
}
// Show mechanisms
if ($result->mechanisms) {
$mechanisms = json_decode($result->mechanisms, true);
$this->line("\nMechanisms:");
foreach ($mechanisms as $mech) {
$this->line(" - {$mech['value']}");
}
}
} else {
$this->error("✗ No valid SPF record found");
if ($result) {
$this->line("Error: {$result->error_message}");
}
}
return;
}
if ($this->option('all')) {
$this->info('Checking SPF for all domains...');
$count = $service->checkAllDomains();
$this->info("Done! Checked {$count} domains.");
return;
}
// Default: check domains that need updating
$this->info('Checking domains that need SPF updates...');
$count = $service->checkDomainsNeedingUpdate();
$this->info("Done! Checked {$count} domains.");
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace VEximweb\Plugin\DnsTools\Models;
use Illuminate\Database\Eloquent\Model;
class SpfCheck extends Model
{
protected $table = 'vw_spf_checks';
protected $fillable = [
'domain',
'domain_id',
'record',
'spf_version',
'policy',
'lookup_count',
'mechanisms',
'includes',
'ip4',
'ip6',
'mx_domains',
'a_domains',
'modifiers',
'has_ptr',
'has_exists',
'valid',
'error_message',
'validation_issues',
'mechanism_count',
'last_checked_at',
'next_check_at',
];
protected $casts = [
'mechanisms' => 'array',
'includes' => 'array',
'ip4' => 'array',
'ip6' => 'array',
'mx_domains' => 'array',
'a_domains' => 'array',
'modifiers' => 'array',
'validation_issues' => 'array',
'valid' => 'boolean',
'has_ptr' => 'boolean',
'has_exists' => 'boolean',
'lookup_count' => 'integer',
'mechanism_count' => 'integer',
'last_checked_at' => 'datetime',
'next_check_at' => 'datetime',
];
/**
* Get the domain relationship
*/
public function domain()
{
return $this->belongsTo(SystemDomains::class, 'domain_id', 'domain_id');
}
/**
* Get human-readable policy label
*/
public function getPolicyLabel(): string
{
return match($this->policy) {
'-all' => 'Hard Fail (Reject)',
'~all' => 'Soft Fail (Mark)',
'?all' => 'Neutral',
'+all' => 'Pass (DANGEROUS)',
default => $this->policy ?? 'Unknown',
};
}
/**
* Check if the SPF record has too many lookups
*/
public function hasTooManyLookups(): bool
{
return $this->lookup_count > 10;
}
/**
* Get warning messages for the SPF record
*/
public function getWarnings(): array
{
$warnings = [];
if ($this->hasTooManyLookups()) {
$warnings[] = "SPF record has {$this->lookup_count} DNS lookups (maximum recommended is 10)";
}
if ($this->has_ptr) {
$warnings[] = "PTR mechanism is deprecated and should be avoided";
}
if ($this->policy === '+all') {
$warnings[] = "WARNING: +all policy allows any server to send email from this domain";
}
if ($this->policy === '?all') {
$warnings[] = "?all policy provides no authentication (neutral)";
}
if (!empty($this->validation_issues)) {
$issues = is_array($this->validation_issues) ? $this->validation_issues : json_decode($this->validation_issues, true);
if (is_array($issues)) {
foreach ($issues as $issue) {
if (isset($issue['level']) && $issue['level'] === 'warning') {
$warnings[] = $issue['message'];
}
}
}
}
return $warnings;
}
/**
* Get error messages for the SPF record
*/
public function getErrors(): array
{
$errors = [];
if (!$this->valid) {
$errors[] = $this->error_message ?? 'SPF record is invalid';
}
if (!empty($this->validation_issues)) {
$issues = is_array($this->validation_issues) ? $this->validation_issues : json_decode($this->validation_issues, true);
if (is_array($issues)) {
foreach ($issues as $issue) {
if (isset($issue['level']) && $issue['level'] === 'error') {
$errors[] = $issue['message'];
}
}
}
}
return $errors;
}
/**
* Scope a query to only include valid SPF records
*/
public function scopeValid($query)
{
return $query->where('valid', true);
}
/**
* Scope a query to only include invalid SPF records
*/
public function scopeInvalid($query)
{
return $query->where('valid', false);
}
/**
* Scope a query to only include records with no SPF record
*/
public function scopeNoRecord($query)
{
return $query->whereNull('record');
}
/**
* Scope a query to only include records with high lookup counts
*/
public function scopeHighLookups($query, int $threshold = 10)
{
return $query->where('lookup_count', '>', $threshold);
}
}
+47
View File
@@ -15,6 +15,14 @@ class SystemDomains extends BaseDomain
return $this->hasOne(DmarcCheck::class, 'domain_id', 'domain_id'); return $this->hasOne(DmarcCheck::class, 'domain_id', 'domain_id');
} }
/**
* Get the SPF check for this domain
*/
public function spfCheck()
{
return $this->hasOne(SpfCheck::class, 'domain_id', 'domain_id');
}
/** /**
* Check if domain has a valid DMARC record * Check if domain has a valid DMARC record
*/ */
@@ -24,6 +32,15 @@ class SystemDomains extends BaseDomain
return $dmarc && $dmarc->valid; return $dmarc && $dmarc->valid;
} }
/**
* Check if domain has a valid SPF record
*/
public function hasValidSpf(): bool
{
$spf = $this->spfCheck()->first();
return $spf && $spf->valid;
}
/** /**
* Get DMARC status * Get DMARC status
*/ */
@@ -35,4 +52,34 @@ class SystemDomains extends BaseDomain
} }
return $dmarc->valid ? 'valid' : 'invalid'; return $dmarc->valid ? 'valid' : 'invalid';
} }
/**
* Get SPF status
*/
public function getSpfStatus(): string
{
$spf = $this->spfCheck()->first();
if (!$spf) {
return 'not_checked';
}
return $spf->valid ? 'valid' : 'invalid';
}
/**
* Get DMARC policy if available
*/
public function getDmarcPolicy(): ?string
{
$dmarc = $this->dmarcCheck()->first();
return $dmarc ? $dmarc->policy : null;
}
/**
* Get SPF policy if available
*/
public function getSpfPolicy(): ?string
{
$spf = $this->spfCheck()->first();
return $spf ? $spf->policy : null;
}
} }
@@ -80,6 +80,7 @@ class DnsToolsServiceProvider extends ServiceProvider
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([ $this->commands([
\VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class, \VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class,
\VEximweb\Plugin\DnsTools\Console\Commands\CheckSpfRecords::class,
]); ]);
} }
} }
+489 -44
View File
@@ -6,15 +6,26 @@ use SPFLib\Record;
use SPFLib\Term\Mechanism; use SPFLib\Term\Mechanism;
use SPFLib\Term\Modifier; use SPFLib\Term\Modifier;
use SPFLib\SemanticValidator; use SPFLib\SemanticValidator;
use SPFLib\Decoder;
use SPFLib\Exception;
use SPFLib\Exception\InvalidTermException; use SPFLib\Exception\InvalidTermException;
use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain; use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain;
use VEximweb\Plugin\DnsTools\Models\SpfCheck;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use IPLib\Factory; use IPLib\Factory;
use IPLib\Address\IPv4; use IPLib\Address\IPv4;
use IPLib\Address\IPv6; use IPLib\Address\IPv6;
class SpfRecordService class SpfRecordService
{ {
protected $decoder;
public function __construct()
{
$this->decoder = new Decoder();
}
/** /**
* Generate an SPF record from form data * Generate an SPF record from form data
* *
@@ -217,14 +228,12 @@ class SpfRecordService
/** /**
* Get the qualifier for the ALL mechanism * Get the qualifier for the ALL mechanism
* Must return an integer constant from Mechanism class
* *
* @param string $policy * @param string $policy
* @return int * @return string
*/ */
protected function getQualifier(string $policy): string protected function getQualifier(string $policy): string
{ {
// Using match expression that returns integer constants
return match($policy) { return match($policy) {
'-all' => Mechanism::QUALIFIER_FAIL, '-all' => Mechanism::QUALIFIER_FAIL,
'~all' => Mechanism::QUALIFIER_SOFTFAIL, '~all' => Mechanism::QUALIFIER_SOFTFAIL,
@@ -304,7 +313,12 @@ class SpfRecordService
public function describe(string $recordString): array public function describe(string $recordString): array
{ {
try { try {
$record = Record::fromString($recordString); $record = $this->decoder->getRecordFromTXT($recordString);
if ($record === null) {
return ['error' => 'Not a valid SPF record'];
}
$terms = $record->getTerms(); $terms = $record->getTerms();
$description = [ $description = [
@@ -316,80 +330,87 @@ class SpfRecordService
foreach ($terms as $term) { foreach ($terms as $term) {
$termString = (string) $term; $termString = (string) $term;
$mechanismName = $term->getName();
if ($term instanceof Mechanism\AllMechanism) { if ($mechanismName === 'all') {
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'all', 'type' => 'all',
'value' => $termString, 'value' => $termString,
'description' => $this->getMechanismDescription($term), 'description' => $this->getMechanismDescription($term),
]; ];
} elseif ($term instanceof Mechanism\IncludeMechanism) { } elseif ($mechanismName === 'include') {
$domain = $this->getDomainFromMechanism($term, $termString);
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'include', 'type' => 'include',
'value' => $termString, 'value' => $termString,
'domain' => $term->getDomain(), 'domain' => $domain,
'description' => 'Include SPF records from ' . $term->getDomain(), 'description' => 'Include SPF records from ' . $domain,
]; ];
} elseif ($term instanceof Mechanism\Ip4Mechanism) { } elseif ($mechanismName === 'ip4') {
$ip = $term->getIp(); $ip = $this->extractDomainFromString($termString);
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'ip4', 'type' => 'ip4',
'value' => $termString, 'value' => $termString,
'ip' => is_object($ip) ? (string) $ip : $ip, 'ip' => $ip,
'description' => 'Allow IP ' . (is_object($ip) ? (string) $ip : $ip), 'description' => 'Allow IP ' . $ip,
]; ];
} elseif ($term instanceof Mechanism\Ip6Mechanism) { } elseif ($mechanismName === 'ip6') {
$ip = $term->getIp(); $ip = $this->extractDomainFromString($termString);
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'ip6', 'type' => 'ip6',
'value' => $termString, 'value' => $termString,
'ip' => is_object($ip) ? (string) $ip : $ip, 'ip' => $ip,
'description' => 'Allow IP ' . (is_object($ip) ? (string) $ip : $ip), 'description' => 'Allow IP ' . $ip,
]; ];
} elseif ($term instanceof Mechanism\MxMechanism) { } elseif ($mechanismName === 'mx') {
$domain = $this->getDomainFromMechanism($term, $termString) ?: 'current domain';
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'mx', 'type' => 'mx',
'value' => $termString, 'value' => $termString,
'domain' => $term->getDomain() ?? 'current domain', 'domain' => $domain,
'description' => 'Allow MX servers for ' . ($term->getDomain() ?? 'this domain'), 'description' => 'Allow MX servers for ' . $domain,
]; ];
} elseif ($term instanceof Mechanism\AMechanism) { } elseif ($mechanismName === 'a') {
$domain = $this->getDomainFromMechanism($term, $termString) ?: 'current domain';
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'a', 'type' => 'a',
'value' => $termString, 'value' => $termString,
'domain' => $term->getDomain() ?? 'current domain', 'domain' => $domain,
'description' => 'Allow A record for ' . ($term->getDomain() ?? 'this domain'), 'description' => 'Allow A record for ' . $domain,
]; ];
} elseif ($term instanceof Mechanism\PtrMechanism) { } elseif ($mechanismName === 'ptr') {
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'ptr', 'type' => 'ptr',
'value' => $termString, 'value' => $termString,
'description' => 'PTR mechanism (deprecated)', 'description' => 'PTR mechanism (deprecated)',
]; ];
} elseif ($term instanceof Mechanism\ExistsMechanism) { } elseif ($mechanismName === 'exists') {
$domain = $this->getDomainFromMechanism($term, $termString);
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'exists', 'type' => 'exists',
'value' => $termString, 'value' => $termString,
'domain' => $term->getDomain(), 'domain' => $domain,
'description' => 'Exists mechanism for ' . $term->getDomain(), 'description' => 'Exists mechanism for ' . $domain,
]; ];
} elseif ($term instanceof Modifier\RedirectModifier) { } elseif ($mechanismName === 'redirect') {
$domain = $this->getDomainFromMechanism($term, $termString);
$description['modifiers'][] = [ $description['modifiers'][] = [
'type' => 'redirect', 'type' => 'redirect',
'value' => $termString, 'value' => $termString,
'domain' => $term->getDomain(), 'domain' => $domain,
'description' => 'Redirect to ' . $term->getDomain(), 'description' => 'Redirect to ' . $domain,
]; ];
} elseif ($term instanceof Modifier\ExpModifier) { } elseif ($mechanismName === 'exp') {
$domain = $this->getDomainFromMechanism($term, $termString);
$description['modifiers'][] = [ $description['modifiers'][] = [
'type' => 'exp', 'type' => 'exp',
'value' => $termString, 'value' => $termString,
'domain' => $term->getDomain(), 'domain' => $domain,
'description' => 'Explanation: ' . $term->getDomain(), 'description' => 'Explanation: ' . $domain,
]; ];
} else { } else {
$description['mechanisms'][] = [ $description['mechanisms'][] = [
'type' => 'unknown', 'type' => $mechanismName,
'value' => $termString, 'value' => $termString,
'description' => 'Unknown mechanism', 'description' => 'Unknown mechanism',
]; ];
@@ -440,7 +461,12 @@ class SpfRecordService
public function flattenRecord(string $recordString): array public function flattenRecord(string $recordString): array
{ {
try { try {
$record = Record::fromString($recordString); $record = $this->decoder->getRecordFromTXT($recordString);
if ($record === null) {
return ['error' => 'Not a valid SPF record'];
}
$terms = $record->getTerms(); $terms = $record->getTerms();
$ip4List = []; $ip4List = [];
@@ -450,18 +476,22 @@ class SpfRecordService
$allQualifier = null; $allQualifier = null;
foreach ($terms as $term) { foreach ($terms as $term) {
if ($term instanceof Mechanism\Ip4Mechanism) { $termString = (string) $term;
$ip = $term->getIp(); $mechanismName = $term->getName();
$ip4List[] = is_object($ip) ? (string) $ip : $ip;
} elseif ($term instanceof Mechanism\Ip6Mechanism) { if ($mechanismName === 'ip4') {
$ip = $term->getIp(); $ip = $this->extractDomainFromString($termString);
$ip6List[] = is_object($ip) ? (string) $ip : $ip; $ip4List[] = $ip;
} elseif ($term instanceof Mechanism\IncludeMechanism) { } elseif ($mechanismName === 'ip6') {
$includes[] = $term->getDomain(); $ip = $this->extractDomainFromString($termString);
} elseif ($term instanceof Mechanism\AllMechanism) { $ip6List[] = $ip;
} elseif ($mechanismName === 'include') {
$domain = $this->getDomainFromMechanism($term, $termString);
$includes[] = $domain;
} elseif ($mechanismName === 'all') {
$allQualifier = $term->getQualifier(); $allQualifier = $term->getQualifier();
} else { } else {
$otherMechanisms[] = (string) $term; $otherMechanisms[] = $termString;
} }
} }
@@ -480,4 +510,419 @@ class SpfRecordService
]; ];
} }
} }
// ============================================
// SPF CHECKING FUNCTIONALITY
// ============================================
/**
* Check SPF record for a specific domain
*
* @param string $domain
* @return SpfCheck|null
*/
public function checkDomain(string $domain): ?SpfCheck
{
try {
// Check if we have a recent cached result
$cacheKey = 'spf_check_' . md5($domain);
if (Cache::has($cacheKey)) {
$cached = Cache::get($cacheKey);
// Verify we got a valid object
if ($cached instanceof SpfCheck) {
return $cached;
}
// If not valid, remove from cache
Cache::forget($cacheKey);
}
// Perform DNS lookup
$spfRecord = $this->getDnsSpfRecord($domain);
// Find or create the check record
$check = SpfCheck::firstOrNew(['domain' => $domain]);
// Try to get domain_id if it exists
$domainModel = Domain::where('domain', $domain)->first();
if ($domainModel) {
$check->domain_id = $domainModel->domain_id;
}
if ($spfRecord) {
// Parse and validate the SPF record
$parsed = $this->parseSpfRecord($spfRecord, $domain);
$check->record = $spfRecord;
$check->valid = $parsed['valid'];
$check->policy = $parsed['policy'];
$check->spf_version = $parsed['version'] ?? 'v=spf1';
$check->lookup_count = $parsed['lookup_count'] ?? 0;
$check->mechanisms = json_encode($parsed['mechanisms'] ?? []);
$check->includes = json_encode($parsed['includes'] ?? []);
$check->ip4 = json_encode($parsed['ip4'] ?? []);
$check->ip6 = json_encode($parsed['ip6'] ?? []);
$check->mx_domains = json_encode($parsed['mx_domains'] ?? []);
$check->a_domains = json_encode($parsed['a_domains'] ?? []);
$check->modifiers = json_encode($parsed['modifiers'] ?? []);
$check->has_ptr = $parsed['has_ptr'] ?? false;
$check->has_exists = $parsed['has_exists'] ?? false;
$check->mechanism_count = $parsed['mechanism_count'] ?? 0;
$check->validation_issues = json_encode($parsed['validation_issues'] ?? []);
$check->error_message = null;
} else {
$check->valid = false;
$check->error_message = 'No SPF record found for domain';
$check->record = null;
$check->validation_issues = null;
}
$check->last_checked_at = now();
$check->next_check_at = now()->addHours(24);
$check->save();
// Reload the model to ensure we have a fresh instance
$check = $check->fresh();
// Cache the result for 1 hour
Cache::put($cacheKey, $check, 3600);
return $check;
} catch (\Exception $e) {
Log::error('SPF check failed for domain ' . $domain . ': ' . $e->getMessage());
try {
$check = SpfCheck::firstOrNew(['domain' => $domain]);
$check->valid = false;
$check->error_message = 'Error checking SPF: ' . $e->getMessage();
$check->last_checked_at = now();
$check->save();
return $check;
} catch (\Exception $e2) {
Log::error('Failed to save SPF error for domain ' . $domain . ': ' . $e2->getMessage());
return null;
}
}
}
/**
* Get DNS SPF record for a domain
*
* @param string $domain
* @return string|null
*/
protected function getDnsSpfRecord(string $domain): ?string
{
$records = dns_get_record($domain, DNS_TXT);
foreach ($records as $record) {
if (isset($record['txt']) && str_starts_with($record['txt'], 'v=spf1')) {
return $record['txt'];
}
}
return null;
}
/**
* Parse and validate an SPF record using the Decoder
*
* @param string $spfString
* @param string $domain
* @return array
*/
protected function parseSpfRecord(string $spfString, string $domain): array
{
$result = [
'valid' => false,
'policy' => null,
'version' => 'v=spf1',
'lookup_count' => 0,
'mechanisms' => [],
'includes' => [],
'ip4' => [],
'ip6' => [],
'mx_domains' => [],
'a_domains' => [],
'modifiers' => [],
'has_ptr' => false,
'has_exists' => false,
'mechanism_count' => 0,
'validation_issues' => [],
];
try {
// Use the decoder to parse the SPF record
$record = $this->decoder->getRecordFromTXT($spfString);
if ($record === null) {
$result['validation_issues'][] = [
'level' => 'error',
'message' => 'Not a valid SPF record',
'term' => null,
];
return $result;
}
$terms = $record->getTerms();
$lookupCount = 0;
$mechanisms = [];
$validationIssues = [];
foreach ($terms as $term) {
$termString = (string) $term;
$result['mechanism_count']++;
// Get the mechanism name
$mechanismName = $term->getName();
// Categorize different mechanism types using getName()
if ($mechanismName === 'all') {
$qualifier = $term->getQualifier();
$result['policy'] = match($qualifier) {
Mechanism::QUALIFIER_FAIL => '-all',
Mechanism::QUALIFIER_SOFTFAIL => '~all',
Mechanism::QUALIFIER_NEUTRAL => '?all',
Mechanism::QUALIFIER_PASS => '+all',
default => null,
};
$mechanisms[] = ['type' => 'all', 'value' => $termString];
} elseif ($mechanismName === 'include') {
$includeDomain = $this->getDomainFromMechanism($term, $termString);
$result['includes'][] = $includeDomain;
$lookupCount++;
$mechanisms[] = ['type' => 'include', 'value' => $termString, 'domain' => $includeDomain];
} elseif ($mechanismName === 'ip4') {
$ipString = $this->extractDomainFromString($termString);
$result['ip4'][] = $ipString;
$mechanisms[] = ['type' => 'ip4', 'value' => $termString, 'ip' => $ipString];
} elseif ($mechanismName === 'ip6') {
$ipString = $this->extractDomainFromString($termString);
$result['ip6'][] = $ipString;
$mechanisms[] = ['type' => 'ip6', 'value' => $termString, 'ip' => $ipString];
} elseif ($mechanismName === 'mx') {
$mxDomain = $this->getDomainFromMechanism($term, $termString) ?: $domain;
$result['mx_domains'][] = $mxDomain;
$lookupCount++;
$mechanisms[] = ['type' => 'mx', 'value' => $termString, 'domain' => $mxDomain];
} elseif ($mechanismName === 'a') {
$aDomain = $this->getDomainFromMechanism($term, $termString) ?: $domain;
$result['a_domains'][] = $aDomain;
$lookupCount++;
$mechanisms[] = ['type' => 'a', 'value' => $termString, 'domain' => $aDomain];
} elseif ($mechanismName === 'ptr') {
$result['has_ptr'] = true;
$lookupCount++;
$mechanisms[] = ['type' => 'ptr', 'value' => $termString];
} elseif ($mechanismName === 'exists') {
$existsDomain = $this->getDomainFromMechanism($term, $termString);
$result['has_exists'] = true;
$lookupCount++;
$mechanisms[] = ['type' => 'exists', 'value' => $termString, 'domain' => $existsDomain];
} elseif ($mechanismName === 'redirect') {
$redirectDomain = $this->getDomainFromMechanism($term, $termString);
$result['modifiers'][] = ['type' => 'redirect', 'value' => $termString, 'domain' => $redirectDomain];
$lookupCount++;
} elseif ($mechanismName === 'exp') {
$expDomain = $this->getDomainFromMechanism($term, $termString);
$result['modifiers'][] = ['type' => 'exp', 'value' => $termString, 'domain' => $expDomain];
} else {
// Unknown mechanism type - add as is
$mechanisms[] = ['type' => $mechanismName, 'value' => $termString];
}
}
$result['lookup_count'] = $lookupCount;
$result['mechanisms'] = $mechanisms;
// Validate the record using SemanticValidator
$validator = new SemanticValidator();
$validationIssues = $validator->validate($record);
foreach ($validationIssues as $issue) {
$result['validation_issues'][] = [
'level' => $issue->getLevel(),
'message' => $issue->getMessage(),
'term' => $issue->getTerm() ? (string) $issue->getTerm() : null,
];
}
// Check if record is valid (has all mechanism and no critical issues)
$result['valid'] = !empty($result['policy']) && empty(array_filter($validationIssues, function($issue) {
return $issue->getLevel() === 'error' || $issue->getLevel() === 'fatal';
}));
} catch (Exception $e) {
$result['validation_issues'][] = [
'level' => 'error',
'message' => 'Failed to parse SPF record: ' . $e->getMessage(),
'term' => null,
];
} catch (\Exception $e) {
$result['validation_issues'][] = [
'level' => 'error',
'message' => 'Unexpected error: ' . $e->getMessage(),
'term' => null,
];
}
return $result;
}
/**
* Extract domain from SPF string
*
* @param string $string
* @return string
*/
protected function extractDomainFromString(string $string): string
{
// Remove the qualifier (+/-/~/?)
$string = ltrim($string, '+~?-');
// Remove the mechanism name prefix if present (include:, a:, mx:, etc.)
$parts = explode(':', $string, 2);
if (count($parts) === 2) {
// Check if the first part is a known mechanism name
$knownMechanisms = ['include', 'a', 'mx', 'exists', 'ptr', 'ip4', 'ip6'];
if (in_array($parts[0], $knownMechanisms)) {
return trim($parts[1]);
}
}
// Check for equals separator (redirect=domain.com, exp=domain.com)
if (strpos($string, '=') !== false) {
$parts = explode('=', $string, 2);
return trim($parts[1]);
}
// If no separator, return the string as is
return trim($string);
}
/**
* Get the domain from a mechanism using the proper methods
*
* @param Mechanism $mechanism
* @param string $termString
* @return string
*/
protected function getDomainFromMechanism(Mechanism $mechanism, string $termString): string
{
// Check if the mechanism implements TermWithDomainSpec
if ($mechanism instanceof \SPFLib\Term\TermWithDomainSpec) {
$domainSpec = $mechanism->getDomainSpec();
if ($domainSpec !== null && !$domainSpec->isEmpty()) {
return (string) $domainSpec;
}
}
// Fallback: extract from string
return $this->extractDomainFromString($termString);
}
/**
* Check all domains
*
* @return int Number of domains checked
*/
public function checkAllDomains(): int
{
$domains = Domain::all();
$count = 0;
foreach ($domains as $domain) {
$this->checkDomain($domain->domain);
$count++;
// Avoid rate limiting
if ($count % 10 === 0) {
usleep(100000); // 0.1 second delay every 10 domains
}
}
return $count;
}
/**
* Check domains that need updating
*
* @return int Number of domains checked
*/
public function checkDomainsNeedingUpdate(): int
{
try {
$domains = Domain::whereDoesntHave('spfCheck', function($query) {
$query->where('next_check_at', '>', now());
})->get();
} catch (\Exception $e) {
// Fallback if relationship doesn't exist
$checkedDomains = SpfCheck::where('next_check_at', '>', now())
->pluck('domain')
->toArray();
$domains = Domain::whereNotIn('domain', $checkedDomains)->get();
}
$count = 0;
foreach ($domains as $domain) {
$result = $this->checkDomain($domain->domain);
if ($result !== null) {
$count++;
}
if ($count % 10 === 0) {
usleep(100000);
}
}
return $count;
}
/**
* Get SPF statistics
*
* @return array
*/
public function getStats(): array
{
$total = SpfCheck::count();
$valid = SpfCheck::where('valid', true)->count();
$noRecord = SpfCheck::whereNull('record')->count();
$invalid = SpfCheck::where('valid', false)->whereNotNull('record')->count();
// Policy breakdown
$policies = SpfCheck::whereNotNull('policy')
->select('policy', \DB::raw('count(*) as count'))
->groupBy('policy')
->pluck('count', 'policy')
->toArray();
// Lookup count distribution
$lookupDistribution = SpfCheck::whereNotNull('lookup_count')
->select('lookup_count', \DB::raw('count(*) as count'))
->groupBy('lookup_count')
->orderBy('lookup_count')
->pluck('count', 'lookup_count')
->toArray();
// High lookup count domains (> 10)
$highLookups = SpfCheck::where('lookup_count', '>', 10)
->where('valid', true)
->pluck('domain')
->toArray();
return [
'total' => $total,
'valid' => $valid,
'no_record' => $noRecord,
'invalid' => $invalid,
'policies' => $policies,
'lookup_distribution' => $lookupDistribution,
'high_lookups' => $highLookups,
'high_lookup_count' => count($highLookups),
];
}
} }