From 0f74f6f5719f7dc4ecf346da57071819ee0a6145 Mon Sep 17 00:00:00 2001 From: Mr Sleeps Date: Thu, 2 Jul 2026 12:39:01 +0100 Subject: [PATCH] Added SPF check cli command --- .../2026_07_02_141648_add_spf_checks.php | 45 ++ src/Console/Commands/CheckSpfRecords.php | 105 ++++ src/Models/SpfCheck.php | 176 ++++++ src/Models/SystemDomains.php | 47 ++ src/Providers/DnsToolsServiceProvider.php | 1 + src/Services/SpfRecordService.php | 533 ++++++++++++++++-- 6 files changed, 863 insertions(+), 44 deletions(-) create mode 100644 database/migrations/2026_07_02_141648_add_spf_checks.php create mode 100644 src/Console/Commands/CheckSpfRecords.php create mode 100644 src/Models/SpfCheck.php diff --git a/database/migrations/2026_07_02_141648_add_spf_checks.php b/database/migrations/2026_07_02_141648_add_spf_checks.php new file mode 100644 index 00000000..9154b7a6 --- /dev/null +++ b/database/migrations/2026_07_02_141648_add_spf_checks.php @@ -0,0 +1,45 @@ +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'); + } +}; diff --git a/src/Console/Commands/CheckSpfRecords.php b/src/Console/Commands/CheckSpfRecords.php new file mode 100644 index 00000000..37b82e21 --- /dev/null +++ b/src/Console/Commands/CheckSpfRecords.php @@ -0,0 +1,105 @@ +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."); + } +} \ No newline at end of file diff --git a/src/Models/SpfCheck.php b/src/Models/SpfCheck.php new file mode 100644 index 00000000..f8da9e97 --- /dev/null +++ b/src/Models/SpfCheck.php @@ -0,0 +1,176 @@ + '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); + } +} \ No newline at end of file diff --git a/src/Models/SystemDomains.php b/src/Models/SystemDomains.php index 2c435b0a..e3f75720 100644 --- a/src/Models/SystemDomains.php +++ b/src/Models/SystemDomains.php @@ -15,6 +15,14 @@ class SystemDomains extends BaseDomain 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 */ @@ -24,6 +32,15 @@ class SystemDomains extends BaseDomain 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 */ @@ -35,4 +52,34 @@ class SystemDomains extends BaseDomain } 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; + } } \ No newline at end of file diff --git a/src/Providers/DnsToolsServiceProvider.php b/src/Providers/DnsToolsServiceProvider.php index 241953e2..5bf199e6 100644 --- a/src/Providers/DnsToolsServiceProvider.php +++ b/src/Providers/DnsToolsServiceProvider.php @@ -80,6 +80,7 @@ class DnsToolsServiceProvider extends ServiceProvider if ($this->app->runningInConsole()) { $this->commands([ \VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class, + \VEximweb\Plugin\DnsTools\Console\Commands\CheckSpfRecords::class, ]); } } diff --git a/src/Services/SpfRecordService.php b/src/Services/SpfRecordService.php index 8b2c2f7e..152a526b 100644 --- a/src/Services/SpfRecordService.php +++ b/src/Services/SpfRecordService.php @@ -6,15 +6,26 @@ use SPFLib\Record; use SPFLib\Term\Mechanism; use SPFLib\Term\Modifier; use SPFLib\SemanticValidator; +use SPFLib\Decoder; +use SPFLib\Exception; use SPFLib\Exception\InvalidTermException; use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain; +use VEximweb\Plugin\DnsTools\Models\SpfCheck; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Cache; use IPLib\Factory; use IPLib\Address\IPv4; use IPLib\Address\IPv6; class SpfRecordService { + protected $decoder; + + public function __construct() + { + $this->decoder = new Decoder(); + } + /** * Generate an SPF record from form data * @@ -217,14 +228,12 @@ class SpfRecordService /** * Get the qualifier for the ALL mechanism - * Must return an integer constant from Mechanism class * * @param string $policy - * @return int + * @return string */ protected function getQualifier(string $policy): string { - // Using match expression that returns integer constants return match($policy) { '-all' => Mechanism::QUALIFIER_FAIL, '~all' => Mechanism::QUALIFIER_SOFTFAIL, @@ -304,7 +313,12 @@ class SpfRecordService public function describe(string $recordString): array { try { - $record = Record::fromString($recordString); + $record = $this->decoder->getRecordFromTXT($recordString); + + if ($record === null) { + return ['error' => 'Not a valid SPF record']; + } + $terms = $record->getTerms(); $description = [ @@ -316,80 +330,87 @@ class SpfRecordService foreach ($terms as $term) { $termString = (string) $term; + $mechanismName = $term->getName(); - if ($term instanceof Mechanism\AllMechanism) { + if ($mechanismName === 'all') { $description['mechanisms'][] = [ 'type' => 'all', 'value' => $termString, 'description' => $this->getMechanismDescription($term), ]; - } elseif ($term instanceof Mechanism\IncludeMechanism) { + } elseif ($mechanismName === 'include') { + $domain = $this->getDomainFromMechanism($term, $termString); $description['mechanisms'][] = [ 'type' => 'include', 'value' => $termString, - 'domain' => $term->getDomain(), - 'description' => 'Include SPF records from ' . $term->getDomain(), + 'domain' => $domain, + 'description' => 'Include SPF records from ' . $domain, ]; - } elseif ($term instanceof Mechanism\Ip4Mechanism) { - $ip = $term->getIp(); + } elseif ($mechanismName === 'ip4') { + $ip = $this->extractDomainFromString($termString); $description['mechanisms'][] = [ 'type' => 'ip4', 'value' => $termString, - 'ip' => is_object($ip) ? (string) $ip : $ip, - 'description' => 'Allow IP ' . (is_object($ip) ? (string) $ip : $ip), + 'ip' => $ip, + 'description' => 'Allow IP ' . $ip, ]; - } elseif ($term instanceof Mechanism\Ip6Mechanism) { - $ip = $term->getIp(); + } elseif ($mechanismName === 'ip6') { + $ip = $this->extractDomainFromString($termString); $description['mechanisms'][] = [ 'type' => 'ip6', 'value' => $termString, - 'ip' => is_object($ip) ? (string) $ip : $ip, - 'description' => 'Allow IP ' . (is_object($ip) ? (string) $ip : $ip), + 'ip' => $ip, + 'description' => 'Allow IP ' . $ip, ]; - } elseif ($term instanceof Mechanism\MxMechanism) { + } elseif ($mechanismName === 'mx') { + $domain = $this->getDomainFromMechanism($term, $termString) ?: 'current domain'; $description['mechanisms'][] = [ 'type' => 'mx', 'value' => $termString, - 'domain' => $term->getDomain() ?? 'current domain', - 'description' => 'Allow MX servers for ' . ($term->getDomain() ?? 'this domain'), + 'domain' => $domain, + 'description' => 'Allow MX servers for ' . $domain, ]; - } elseif ($term instanceof Mechanism\AMechanism) { + } elseif ($mechanismName === 'a') { + $domain = $this->getDomainFromMechanism($term, $termString) ?: 'current domain'; $description['mechanisms'][] = [ 'type' => 'a', 'value' => $termString, - 'domain' => $term->getDomain() ?? 'current domain', - 'description' => 'Allow A record for ' . ($term->getDomain() ?? 'this domain'), + 'domain' => $domain, + 'description' => 'Allow A record for ' . $domain, ]; - } elseif ($term instanceof Mechanism\PtrMechanism) { + } elseif ($mechanismName === 'ptr') { $description['mechanisms'][] = [ 'type' => 'ptr', 'value' => $termString, 'description' => 'PTR mechanism (deprecated)', ]; - } elseif ($term instanceof Mechanism\ExistsMechanism) { + } elseif ($mechanismName === 'exists') { + $domain = $this->getDomainFromMechanism($term, $termString); $description['mechanisms'][] = [ 'type' => 'exists', 'value' => $termString, - 'domain' => $term->getDomain(), - 'description' => 'Exists mechanism for ' . $term->getDomain(), + 'domain' => $domain, + 'description' => 'Exists mechanism for ' . $domain, ]; - } elseif ($term instanceof Modifier\RedirectModifier) { + } elseif ($mechanismName === 'redirect') { + $domain = $this->getDomainFromMechanism($term, $termString); $description['modifiers'][] = [ 'type' => 'redirect', 'value' => $termString, - 'domain' => $term->getDomain(), - 'description' => 'Redirect to ' . $term->getDomain(), + 'domain' => $domain, + 'description' => 'Redirect to ' . $domain, ]; - } elseif ($term instanceof Modifier\ExpModifier) { + } elseif ($mechanismName === 'exp') { + $domain = $this->getDomainFromMechanism($term, $termString); $description['modifiers'][] = [ 'type' => 'exp', 'value' => $termString, - 'domain' => $term->getDomain(), - 'description' => 'Explanation: ' . $term->getDomain(), + 'domain' => $domain, + 'description' => 'Explanation: ' . $domain, ]; } else { $description['mechanisms'][] = [ - 'type' => 'unknown', + 'type' => $mechanismName, 'value' => $termString, 'description' => 'Unknown mechanism', ]; @@ -440,7 +461,12 @@ class SpfRecordService public function flattenRecord(string $recordString): array { try { - $record = Record::fromString($recordString); + $record = $this->decoder->getRecordFromTXT($recordString); + + if ($record === null) { + return ['error' => 'Not a valid SPF record']; + } + $terms = $record->getTerms(); $ip4List = []; @@ -450,18 +476,22 @@ class SpfRecordService $allQualifier = null; foreach ($terms as $term) { - if ($term instanceof Mechanism\Ip4Mechanism) { - $ip = $term->getIp(); - $ip4List[] = is_object($ip) ? (string) $ip : $ip; - } elseif ($term instanceof Mechanism\Ip6Mechanism) { - $ip = $term->getIp(); - $ip6List[] = is_object($ip) ? (string) $ip : $ip; - } elseif ($term instanceof Mechanism\IncludeMechanism) { - $includes[] = $term->getDomain(); - } elseif ($term instanceof Mechanism\AllMechanism) { + $termString = (string) $term; + $mechanismName = $term->getName(); + + if ($mechanismName === 'ip4') { + $ip = $this->extractDomainFromString($termString); + $ip4List[] = $ip; + } elseif ($mechanismName === 'ip6') { + $ip = $this->extractDomainFromString($termString); + $ip6List[] = $ip; + } elseif ($mechanismName === 'include') { + $domain = $this->getDomainFromMechanism($term, $termString); + $includes[] = $domain; + } elseif ($mechanismName === 'all') { $allQualifier = $term->getQualifier(); } 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), + ]; + } } \ No newline at end of file