Initial commit, DMARC checking support added

This commit is contained in:
Mr Sleeps
2026-06-29 17:57:01 +01:00
commit d81fdbfda8
30 changed files with 4019 additions and 0 deletions
@@ -0,0 +1,73 @@
<?php
namespace VEximweb\Plugin\DnsTools\Console\Commands;
use Illuminate\Console\Command;
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
class CheckDmarcRecords extends Command
{
protected $signature = 'dmarc:check
{--domain= : Check a specific domain}
{--all : Check all domains}
{--stats : Show DMARC statistics}';
protected $description = 'Check DMARC records for domains';
public function handle(DmarcCheckService $service): void
{
if ($this->option('stats')) {
$stats = $service->getStats();
$this->info("DMARC Statistics:");
$this->line("Total domains checked: {$stats['total']}");
$this->line("Valid DMARC records: {$stats['valid']}");
$this->line("No DMARC 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) {
'none' => 'Monitor',
'quarantine' => 'Quarantine',
'reject' => 'Reject',
default => $policy,
};
$this->line(" {$label}: {$count}");
}
}
return;
}
if ($this->option('domain')) {
$domain = $this->option('domain');
$this->info("Checking DMARC for: {$domain}");
$result = $service->checkDomain($domain);
if ($result && $result->valid) {
$this->info("✓ DMARC record found!");
$this->line("Policy: {$result->getPolicyLabel()}");
$this->line("Record: {$result->record}");
} else {
$this->error("✗ No valid DMARC record found");
if ($result) {
$this->line("Error: {$result->error_message}");
}
}
return;
}
if ($this->option('all')) {
$this->info('Checking DMARC for all domains...');
$service->checkAllDomains();
$this->info('Done!');
return;
}
// Default: check domains that need updating
$this->info('Checking domains that need DMARC updates...');
$service->checkDomainsNeedingUpdate();
$this->info('Done!');
}
}
+714
View File
@@ -0,0 +1,714 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Dmarc;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcPolicy;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcAlignment;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcReporting;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcTesting;
use VEximweb\Plugin\DnsTools\Dmarc\Exceptions\InvalidDmarcRecordException;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Arr;
use JsonSerializable;
/**
* Responsible for building an object representation of a DMARC
* compliant string value
*
* @link https://mxtoolbox.com/dmarc/details/dmarc-tags
* @link https://datatracker.ietf.org/doc/html/rfc7489
*/
class DmarcRecord implements Arrayable, Jsonable, JsonSerializable
{
protected ?string $version = null;
protected ?DmarcPolicy $policy = null;
protected ?DmarcPolicy $subdomainPolicy = null;
protected ?string $rua = null;
protected ?string $ruf = null;
protected ?DmarcAlignment $adkim = null;
protected ?DmarcAlignment $aspf = null;
protected array $reporting = [];
protected ?int $percentage = null;
protected ?int $reportInterval = null;
protected ?DmarcPolicy $nonExistentSubdomainPolicy = null;
protected ?string $publicSuffixDomainPolicy = null;
protected ?DmarcTesting $testingMode = null;
/**
* Create a new DMARC record instance
*/
public function __construct(
?string $version = 'DMARC1',
DmarcPolicy|string|null $policy = DmarcPolicy::NONE,
DmarcPolicy|string|null $subdomainPolicy = null,
string|array|null $rua = null,
string|array|null $ruf = null,
DmarcAlignment|string|null $adkim = DmarcAlignment::RELAXED,
DmarcAlignment|string|null $aspf = DmarcAlignment::RELAXED,
array $reporting = [],
?int $percentage = null,
?int $reportInterval = null,
DmarcPolicy|string|null $nonExistentSubdomainPolicy = null,
?string $publicSuffixDomainPolicy = null,
DmarcTesting|string|null $testingMode = null
) {
$this->version = $version;
$this->setPolicy($policy);
$this->setSubdomainPolicy($subdomainPolicy);
$this->rua($rua);
$this->ruf($ruf);
$this->setAdkim($adkim);
$this->setAspf($aspf);
$this->reporting($reporting);
$this->percentage($percentage);
$this->reportInterval($reportInterval);
$this->setNonExistentSubdomainPolicy($nonExistentSubdomainPolicy);
$this->publicSuffixDomainPolicy($publicSuffixDomainPolicy);
$this->setTestingMode($testingMode);
}
/**
* Create a new DMARC record from an array
*/
public static function fromArray(array $data): static
{
return new static(
version: Arr::get($data, 'version', 'DMARC1'),
policy: Arr::get($data, 'policy'),
subdomainPolicy: Arr::get($data, 'subdomain_policy'),
rua: Arr::get($data, 'rua'),
ruf: Arr::get($data, 'ruf'),
adkim: Arr::get($data, 'adkim'),
aspf: Arr::get($data, 'aspf'),
reporting: Arr::get($data, 'reporting', []),
percentage: Arr::get($data, 'percentage'),
reportInterval: Arr::get($data, 'report_interval'),
nonExistentSubdomainPolicy: Arr::get($data, 'np'),
publicSuffixDomainPolicy: Arr::get($data, 'psd'),
testingMode: Arr::get($data, 't'),
);
}
/**
* Create from a DNS record string
*/
public static function fromString(string $record): static
{
$builder = new static;
$properties = [];
// Parse the DMARC record
foreach (explode(';', $record) as $part) {
$property = explode('=', trim($part));
if (count($property) !== 2) {
continue;
}
$key = trim($property[0]);
$value = trim($property[1]);
// Clean common issues:
// 1. Remove trailing periods (common in DNS zone files)
// 2. Remove surrounding quotes
// 3. Trim whitespace
$value = rtrim($value, '.');
$value = trim($value, '"\'');
$value = trim($value);
$properties[$key] = $value;
}
// Validate required fields
if (!isset($properties['v'])) {
throw InvalidDmarcRecordException::missingKey('v', 'DMARC version is required');
}
if (!isset($properties['p'])) {
throw InvalidDmarcRecordException::missingKey('p', 'DMARC policy is required');
}
// Parse each property
foreach ($properties as $key => $value) {
match ($key) {
'v' => $builder->version($value),
'p' => $builder->policy($value),
'sp' => $builder->subdomainPolicy($value),
'rua' => $builder->rua($value),
'ruf' => $builder->ruf($value),
'adkim' => $builder->adkim($value),
'aspf' => $builder->aspf($value),
'fo' => $builder->reportingFromString($value),
'pct' => $builder->percentage((int) $value),
'ri' => $builder->reportInterval((int) $value),
'np' => $builder->nonExistentSubdomainPolicy($value),
'psd' => $builder->publicSuffixDomainPolicy($value),
't' => $builder->testingMode($value),
default => null,
};
}
return $builder;
}
/**
* Get the version
*/
public function version(?string $version): static
{
$this->version = $version;
return $this;
}
/**
* Get/set the policy
*/
public function policy(DmarcPolicy|string|null $policy): static
{
$this->setPolicy($policy);
return $this;
}
/**
* Get/set the subdomain policy
*/
public function subdomainPolicy(DmarcPolicy|string|null $policy): static
{
$this->setSubdomainPolicy($policy);
return $this;
}
/**
* Get/set RUA (aggregate report) URIs
*/
public function rua(string|array|null $mailto): static
{
$this->rua = $this->normalizeReportUris($mailto);
return $this;
}
/**
* Get/set RUF (forensic report) URIs
*/
public function ruf(string|array|null $mailto): static
{
$this->ruf = $this->normalizeReportUris($mailto);
return $this;
}
/**
* Normalize report URIs
*/
protected function normalizeReportUris(string|array|null $value): ?string
{
if (is_null($value)) {
return null;
}
$items = is_array($value) ? $value : explode(',', $value);
$items = array_values(array_filter(
array_map('trim', $items),
fn (string $item): bool => $item !== ''
));
foreach ($items as $item) {
if (!str_starts_with($item, 'mailto:')) {
throw InvalidDmarcRecordException::invalidFormat(
'mailto address should start with "mailto:"'
);
}
}
return $items === [] ? null : implode(',', $items);
}
/**
* Get/set ADKIM (DKIM alignment)
*/
public function adkim(DmarcAlignment|string|null $value): static
{
$this->setAdkim($value);
return $this;
}
/**
* Get/set ASPF (SPF alignment)
*/
public function aspf(DmarcAlignment|string|null $value): static
{
$this->setAspf($value);
return $this;
}
/**
* Get/set reporting options (fo)
*/
public function reporting(array $values = []): static
{
$values = array_values(array_unique($values));
$processedValues = [];
foreach ($values as $value) {
if ($value instanceof DmarcReporting) {
$processedValues[] = $value;
continue;
}
// Convert to string and trim
$value = trim((string)$value);
// Check if it's a short code (0, 1, d, s)
if (in_array($value, ['0', '1', 'd', 's'], true)) {
try {
$processedValues[] = DmarcReporting::fromShortCode($value);
continue;
} catch (\InvalidArgumentException $e) {
// Fall through to try from() method
}
}
// Try to create from the full value (all, any, dkim, spf)
try {
$processedValues[] = DmarcReporting::from($value);
} catch (\ValueError $e) {
throw InvalidDmarcRecordException::invalidValue(
"Invalid reporting option: {$value}. Must be 0, 1, d, s, all, any, dkim, or spf"
);
}
}
// Check for mutually exclusive options (0 and 1 cannot be combined)
$hasAll = in_array(DmarcReporting::ALL, $processedValues, true);
$hasAny = in_array(DmarcReporting::ANY, $processedValues, true);
if ($hasAll && $hasAny) {
throw InvalidDmarcRecordException::conflict(
'Reporting options "all" (0) and "any" (1) are mutually exclusive.'
);
}
$this->reporting = $processedValues;
return $this;
}
/**
* Parse reporting options from string
*/
public function reportingFromString(string $value): static
{
// Remove whitespace and trim
$value = trim($value);
// Clean the value - remove trailing periods and quotes
$value = rtrim($value, '.');
$value = trim($value, '"\'');
$value = trim($value);
// Check if it's a colon-separated list (e.g., "0:1:d:s")
if (str_contains($value, ':')) {
$options = array_map(function($item) {
$item = trim($item);
$item = rtrim($item, '.');
$item = trim($item, '"\'');
return trim($item);
}, explode(':', $value));
} else {
// Single value (e.g., "0", "1", "d", "s")
$options = [trim($value)];
}
// Filter out empty values
$options = array_filter($options, fn($v) => $v !== '');
// Convert numeric values to their string representations
$options = array_map(function($value) {
if (is_numeric($value)) {
return match((int)$value) {
0 => '0',
1 => '1',
default => (string)$value,
};
}
return $value;
}, $options);
$this->reporting($options);
return $this;
}
/**
* Get/set percentage (pct)
*/
public function percentage(?int $percentage): static
{
if (!is_null($percentage) && ($percentage < 0 || $percentage > 100)) {
throw InvalidDmarcRecordException::invalidValue(
'Percentage must be between 0 and 100'
);
}
$this->percentage = $percentage;
return $this;
}
/**
* Get/set reporting interval (ri)
*/
public function reportInterval(?int $interval): static
{
if (!is_null($interval) && $interval < 0) {
throw InvalidDmarcRecordException::invalidValue(
'Reporting interval must be a positive integer'
);
}
$this->reportInterval = $interval;
return $this;
}
/**
* Get/set non-existent subdomain policy (np)
*/
public function nonExistentSubdomainPolicy(DmarcPolicy|string|null $policy): static
{
$this->setNonExistentSubdomainPolicy($policy);
return $this;
}
/**
* Get/set public suffix domain policy (psd)
*/
public function publicSuffixDomainPolicy(?string $policy): static
{
if (!is_null($policy) && !in_array($policy, ['y', 'n', 'u', null], true)) {
throw InvalidDmarcRecordException::invalidValue(
'PSD must be "y", "n", or "u"'
);
}
$this->publicSuffixDomainPolicy = $policy;
return $this;
}
/**
* Get/set testing mode (t)
*/
public function testingMode(DmarcTesting|string|null $testingMode): static
{
$this->setTestingMode($testingMode);
return $this;
}
/**
* Convert to a DNS record string
*/
public function toDnsRecord(): string
{
$parts = [];
if ($this->version) {
$parts[] = "v={$this->version}";
}
if ($this->policy) {
$parts[] = "p={$this->policy->value}";
}
if ($this->subdomainPolicy) {
$parts[] = "sp={$this->subdomainPolicy->value}";
}
if ($this->rua) {
$parts[] = "rua={$this->rua}";
}
if ($this->ruf) {
$parts[] = "ruf={$this->ruf}";
}
if ($this->adkim) {
$parts[] = "adkim={$this->adkim->getShortCode()}";
}
if ($this->aspf) {
$parts[] = "aspf={$this->aspf->getShortCode()}";
}
if (!empty($this->reporting)) {
$foParts = array_map(
fn(DmarcReporting $option) => $option->getShortCode(),
$this->reporting
);
$parts[] = "fo=" . implode(':', $foParts);
}
if (!is_null($this->percentage)) {
$parts[] = "pct={$this->percentage}";
}
if (!is_null($this->reportInterval)) {
$parts[] = "ri={$this->reportInterval}";
}
if ($this->nonExistentSubdomainPolicy) {
$parts[] = "np={$this->nonExistentSubdomainPolicy->value}";
}
if ($this->publicSuffixDomainPolicy) {
$parts[] = "psd={$this->publicSuffixDomainPolicy}";
}
if ($this->testingMode) {
$parts[] = "t={$this->testingMode->value}";
}
return implode('; ', $parts);
}
/**
* Get all validation rules for Filament forms
*/
public static function getValidationRules(): array
{
return [
'policy' => ['required', 'string', 'in:none,quarantine,reject'],
'subdomain_policy' => ['nullable', 'string', 'in:none,quarantine,reject'],
'rua' => ['nullable', 'array'],
'rua.*' => ['string', 'starts_with:mailto:'],
'ruf' => ['nullable', 'array'],
'ruf.*' => ['string', 'starts_with:mailto:'],
'adkim' => ['nullable', 'string', 'in:relaxed,strict'],
'aspf' => ['nullable', 'string', 'in:relaxed,strict'],
'reporting' => ['nullable', 'array'],
'reporting.*' => ['string', 'in:all,any,dkim,spf'],
'percentage' => ['nullable', 'integer', 'min:0', 'max:100'],
'report_interval' => ['nullable', 'integer', 'min:0'],
'np' => ['nullable', 'string', 'in:none,quarantine,reject'],
'psd' => ['nullable', 'string', 'in:y,n,u'],
't' => ['nullable', 'string', 'in:y,n'],
];
}
/**
* Convert to array
*/
public function toArray(): array
{
return [
'version' => $this->version,
'policy' => $this->policy?->value,
'subdomain_policy' => $this->subdomainPolicy?->value,
'rua' => $this->rua ? explode(',', $this->rua) : null,
'ruf' => $this->ruf ? explode(',', $this->ruf) : null,
'adkim' => $this->adkim?->value,
'aspf' => $this->aspf?->value,
'reporting' => array_map(fn($opt) => $opt->value, $this->reporting),
'percentage' => $this->percentage,
'report_interval' => $this->reportInterval,
'np' => $this->nonExistentSubdomainPolicy?->value,
'psd' => $this->publicSuffixDomainPolicy,
't' => $this->testingMode?->value,
'dns_record' => $this->toDnsRecord(),
];
}
/**
* Convert to JSON
*/
public function toJson($options = 0): string
{
return json_encode($this->toArray(), $options);
}
/**
* JSON serialize
*/
public function jsonSerialize(): mixed
{
return $this->toArray();
}
/**
* Magic method for string conversion
*/
public function __toString(): string
{
return $this->toDnsRecord();
}
// Private setter methods with validation
private function setPolicy(DmarcPolicy|string|null $policy): void
{
if (is_null($policy)) {
$this->policy = null;
return;
}
$this->policy = $policy instanceof DmarcPolicy
? $policy
: DmarcPolicy::from($policy);
}
private function setSubdomainPolicy(DmarcPolicy|string|null $policy): void
{
if (is_null($policy)) {
$this->subdomainPolicy = null;
return;
}
$this->subdomainPolicy = $policy instanceof DmarcPolicy
? $policy
: DmarcPolicy::from($policy);
}
private function setAdkim(DmarcAlignment|string|null $value): void
{
if (is_null($value)) {
$this->adkim = null;
return;
}
if ($value instanceof DmarcAlignment) {
$this->adkim = $value;
return;
}
// Check if it's a short code (r or s)
if (in_array($value, ['r', 's'], true)) {
$this->adkim = DmarcAlignment::fromShortCode($value);
return;
}
// Try to create from the string value directly
try {
$this->adkim = DmarcAlignment::from($value);
} catch (\ValueError $e) {
throw InvalidDmarcRecordException::invalidValue(
"Invalid ADKIM value: '{$value}'. Must be 'relaxed', 'strict', 'r', or 's'"
);
}
}
private function setAspf(DmarcAlignment|string|null $value): void
{
if (is_null($value)) {
$this->aspf = null;
return;
}
if ($value instanceof DmarcAlignment) {
$this->aspf = $value;
return;
}
// Check if it's a short code (r or s)
if (in_array($value, ['r', 's'], true)) {
$this->aspf = DmarcAlignment::fromShortCode($value);
return;
}
// Try to create from the string value directly
try {
$this->aspf = DmarcAlignment::from($value);
} catch (\ValueError $e) {
throw InvalidDmarcRecordException::invalidValue(
"Invalid ASPF value: '{$value}'. Must be 'relaxed', 'strict', 'r', or 's'"
);
}
}
private function setNonExistentSubdomainPolicy(DmarcPolicy|string|null $policy): void
{
if (is_null($policy)) {
$this->nonExistentSubdomainPolicy = null;
return;
}
$this->nonExistentSubdomainPolicy = $policy instanceof DmarcPolicy
? $policy
: DmarcPolicy::from($policy);
}
private function setTestingMode(DmarcTesting|string|null $testingMode): void
{
if (is_null($testingMode)) {
$this->testingMode = null;
return;
}
$this->testingMode = $testingMode instanceof DmarcTesting
? $testingMode
: DmarcTesting::from($testingMode);
}
public function getVersion(): ?string
{
return $this->version;
}
public function getPolicy(): ?DmarcPolicy
{
return $this->policy;
}
public function getSubdomainPolicy(): ?DmarcPolicy
{
return $this->subdomainPolicy;
}
public function getRua(): ?string
{
return $this->rua;
}
public function getRuf(): ?string
{
return $this->ruf;
}
public function getAdkim(): ?DmarcAlignment
{
return $this->adkim;
}
public function getAspf(): ?DmarcAlignment
{
return $this->aspf;
}
public function getReporting(): array
{
return $this->reporting;
}
public function getPercentage(): ?int
{
return $this->percentage;
}
public function getReportInterval(): ?int
{
return $this->reportInterval;
}
public function getNonExistentSubdomainPolicy(): ?DmarcPolicy
{
return $this->nonExistentSubdomainPolicy;
}
public function getPublicSuffixDomainPolicy(): ?string
{
return $this->publicSuffixDomainPolicy;
}
public function getTestingMode(): ?DmarcTesting
{
return $this->testingMode;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
enum DmarcAlignment: string
{
case RELAXED = 'relaxed';
case STRICT = 'strict';
public function getShortCode(): string
{
return match($this) {
self::RELAXED => 'r',
self::STRICT => 's',
};
}
public function getDescription(): string
{
return match($this) {
self::RELAXED => 'Relaxed alignment (subdomains allowed)',
self::STRICT => 'Strict alignment (exact match required)',
};
}
public static function fromShortCode(string $code): self
{
return match($code) {
'r' => self::RELAXED,
's' => self::STRICT,
default => throw new \InvalidArgumentException("Invalid alignment code: {$code}"),
};
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
enum DmarcPolicy: string
{
case NONE = 'none';
case QUARANTINE = 'quarantine';
case REJECT = 'reject';
public function getDescription(): string
{
return match($this) {
self::NONE => 'No action, monitor only',
self::QUARANTINE => 'Mark as spam/quarantine',
self::REJECT => 'Reject the email outright',
};
}
public function getSeverity(): int
{
return match($this) {
self::NONE => 0,
self::QUARANTINE => 1,
self::REJECT => 2,
};
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
enum DmarcReporting: string
{
case ALL = 'all';
case ANY = 'any';
case DKIM = 'dkim';
case SPF = 'spf';
public function getShortCode(): string
{
return match($this) {
self::ALL => '0',
self::ANY => '1',
self::DKIM => 'd',
self::SPF => 's',
};
}
public function getDescription(): string
{
return match($this) {
self::ALL => 'Report all failures',
self::ANY => 'Report if either DKIM or SPF fails',
self::DKIM => 'Report only DKIM failures',
self::SPF => 'Report only SPF failures',
};
}
public static function fromShortCode(string $code): self
{
return match($code) {
'0' => self::ALL,
'1' => self::ANY,
'd' => self::DKIM,
's' => self::SPF,
default => throw new \InvalidArgumentException("Invalid reporting option code: {$code}"),
};
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Dmarc\Enums;
enum DmarcTesting: string
{
case YES = 'y';
case NO = 'n';
public function getDescription(): string
{
return match($this) {
self::YES => 'Testing mode (don\'t apply policy)',
self::NO => 'Normal mode (apply policy)',
};
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Dmarc\Exceptions;
use Exception;
class InvalidDmarcRecordException extends Exception
{
public static function invalidValue(string $message): self
{
return new self("Invalid DMARC value: {$message}");
}
public static function missingKey(string $key, string $message): self
{
return new self("Missing required key '{$key}': {$message}");
}
public static function invalidFormat(string $message): self
{
return new self("Invalid DMARC format: {$message}");
}
public static function conflict(string $message): self
{
return new self("DMARC configuration conflict: {$message}");
}
}
+429
View File
@@ -0,0 +1,429 @@
<?php
namespace VEximweb\Plugin\DnsTools\Dmarc\Services;
use VEximweb\Plugin\DnsTools\Dmarc\DmarcRecord;
use VEximweb\Plugin\DnsTools\Dmarc\Exceptions\InvalidDmarcRecordException;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
use Illuminate\Support\Facades\Log;
class DmarcCheckService
{
/**
* Check a single domain's DMARC record
*/
public function checkDomain(string $domain, ?int $domainId = null): ?DmarcCheck
{
$startTime = microtime(true);
$attempt = 1;
$maxAttempts = 3;
$lastError = null;
Log::info('DMARC check started', [
'domain' => $domain,
'domain_id' => $domainId,
'timestamp' => now()->toDateTimeString()
]);
while ($attempt <= $maxAttempts) {
$attemptStart = microtime(true);
try {
Log::debug('DMARC check attempt', [
'domain' => $domain,
'attempt' => $attempt,
'max_attempts' => $maxAttempts
]);
// Add delay between attempts (except first)
if ($attempt > 1) {
$delay = 1;
Log::debug('Adding delay before retry', [
'domain' => $domain,
'attempt' => $attempt,
'delay_seconds' => $delay
]);
sleep($delay);
}
// Increase DNS timeout for remote domains
$originalTimeout = ini_get('dns.timeout');
$originalRetry = ini_get('dns.retry');
Log::debug('DNS settings before change', [
'domain' => $domain,
'original_timeout' => $originalTimeout,
'original_retry' => $originalRetry
]);
ini_set('dns.timeout', '10');
ini_set('dns.retry', '5');
Log::debug('DNS settings after change', [
'domain' => $domain,
'new_timeout' => ini_get('dns.timeout'),
'new_retry' => ini_get('dns.retry')
]);
// Query DNS for DMARC record
$dnsStart = microtime(true);
$records = @dns_get_record("_dmarc.{$domain}", DNS_TXT);
$dnsDuration = microtime(true) - $dnsStart;
// Restore original settings
if ($originalTimeout !== false) {
ini_set('dns.timeout', $originalTimeout);
}
if ($originalRetry !== false) {
ini_set('dns.retry', $originalRetry);
}
Log::debug('DNS query completed', [
'domain' => $domain,
'attempt' => $attempt,
'duration_ms' => round($dnsDuration * 1000, 2),
'success' => $records !== false,
'record_count' => $records ? count($records) : 0,
'records_found' => $records ? array_column($records, 'txt') : []
]);
// If DNS query failed or returned no records
if ($records === false) {
$lastError = 'DNS query returned false (error)';
Log::warning('DNS query returned false', [
'domain' => $domain,
'attempt' => $attempt,
'duration_ms' => round($dnsDuration * 1000, 2)
]);
$attempt++;
continue;
}
if (empty($records)) {
$lastError = 'No DMARC record found (empty response)';
Log::warning('DNS query returned empty', [
'domain' => $domain,
'attempt' => $attempt,
'duration_ms' => round($dnsDuration * 1000, 2)
]);
$attempt++;
continue;
}
// Find the DMARC record (starts with v=DMARC1)
$dmarcRecord = null;
foreach ($records as $record) {
$txt = $record['txt'] ?? '';
if (str_starts_with(trim($txt), 'v=DMARC1')) {
$dmarcRecord = $txt;
Log::debug('Found DMARC record', [
'domain' => $domain,
'attempt' => $attempt,
'record' => $dmarcRecord
]);
break;
}
}
if (!$dmarcRecord) {
$lastError = 'No valid DMARC record found (v=DMARC1 missing)';
Log::warning('No DMARC record with v=DMARC1 found', [
'domain' => $domain,
'attempt' => $attempt,
'records' => array_column($records, 'txt')
]);
$attempt++;
continue;
}
// Parse the DMARC record
Log::debug('Parsing DMARC record', [
'domain' => $domain,
'attempt' => $attempt,
'record' => $dmarcRecord
]);
try {
$parsed = DmarcRecord::fromString($dmarcRecord);
} catch (InvalidDmarcRecordException $e) {
$lastError = 'Invalid DMARC record: ' . $e->getMessage();
Log::warning('Invalid DMARC record', [
'domain' => $domain,
'attempt' => $attempt,
'error' => $e->getMessage(),
'record' => $dmarcRecord
]);
$attempt++;
continue;
}
// Save to cache
$totalDuration = microtime(true) - $startTime;
Log::info('DMARC check successful', [
'domain' => $domain,
'attempt' => $attempt,
'total_duration_ms' => round($totalDuration * 1000, 2),
'dns_duration_ms' => round($dnsDuration * 1000, 2),
'policy' => $parsed->getPolicy()?->value,
'record' => $dmarcRecord
]);
return $this->saveSuccessfulCheck($domain, $domainId, $dmarcRecord, $parsed);
} catch (InvalidDmarcRecordException $e) {
$lastError = 'Invalid DMARC record: ' . $e->getMessage();
Log::warning('DMARC check attempt failed (InvalidDmarcRecordException)', [
'domain' => $domain,
'attempt' => $attempt,
'error' => $e->getMessage()
]);
$attempt++;
continue;
} catch (\Exception $e) {
$lastError = 'Error checking DMARC: ' . $e->getMessage();
Log::warning('DMARC check attempt failed (Exception)', [
'domain' => $domain,
'attempt' => $attempt,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
$attempt++;
continue;
}
}
// All attempts failed
$totalDuration = microtime(true) - $startTime;
Log::error('DMARC check failed after all attempts', [
'domain' => $domain,
'attempts' => $attempt - 1,
'total_duration_ms' => round($totalDuration * 1000, 2),
'last_error' => $lastError,
'timestamp' => now()->toDateTimeString()
]);
return $this->saveFailedCheck($domain, $domainId, $lastError ?? 'Unknown error after ' . ($attempt - 1) . ' attempts');
}
/**
* Check all domains from the domains table
*/
public function checkAllDomains(): void
{
Log::info('Starting DMARC check for all domains');
// Get domains from the domains table
$domains = \VEximweb\Core\Data\Models\Domain::where('enabled', true)
->select('domain_id', 'domain')
->get();
Log::info('Found domains to check', [
'total' => $domains->count()
]);
foreach ($domains as $domain) {
Log::debug('Checking domain', [
'domain' => $domain->domain,
'domain_id' => $domain->domain_id
]);
$this->checkDomain($domain->domain, $domain->domain_id);
}
Log::info('Finished DMARC check for all domains');
}
/**
* Check domains that need checking (next_check_at <= now)
*/
public function checkDomainsNeedingUpdate(): void
{
Log::info('Starting DMARC check for domains needing update');
// First, get all domains from the domains table
$domains = \VEximweb\Core\Data\Models\Domain::where('enabled', true)
->select('domain_id', 'domain')
->get();
$checked = 0;
$skipped = 0;
foreach ($domains as $domain) {
// Check if this domain needs updating
$cache = DmarcCheck::where('domain', $domain->domain)->first();
if (!$cache || ($cache->next_check_at && $cache->next_check_at <= now())) {
Log::debug('Domain needs update', [
'domain' => $domain->domain,
'last_check' => $cache?->last_checked_at,
'next_check' => $cache?->next_check_at
]);
$this->checkDomain($domain->domain, $domain->domain_id);
$checked++;
} else {
Log::debug('Domain skipped (not due for update)', [
'domain' => $domain->domain,
'next_check' => $cache->next_check_at
]);
$skipped++;
}
}
Log::info('Finished DMARC check for domains needing update', [
'checked' => $checked,
'skipped' => $skipped,
'total' => $domains->count()
]);
}
/**
* Save a successful check
*/
protected function saveSuccessfulCheck(string $domain, ?int $domainId, string $record, DmarcRecord $parsed): DmarcCheck
{
Log::debug('Saving successful check', [
'domain' => $domain,
'policy' => $parsed->getPolicy()?->value
]);
$result = DmarcCheck::updateOrCreate(
['domain' => $domain],
[
'domain_id' => $domainId,
'record' => $record,
'policy' => $parsed->getPolicy()?->value,
'subdomain_policy' => $parsed->getSubdomainPolicy()?->value,
'adkim' => $parsed->getAdkim()?->value,
'aspf' => $parsed->getAspf()?->value,
'rua' => $parsed->getRua() ? explode(',', $parsed->getRua()) : null,
'ruf' => $parsed->getRuf() ? explode(',', $parsed->getRuf()) : null,
'reporting' => $parsed->getReporting() ? array_map(fn($r) => $r->value, $parsed->getReporting()) : null,
'percentage' => $parsed->getPercentage(),
'report_interval' => $parsed->getReportInterval(),
'np' => $parsed->getNonExistentSubdomainPolicy()?->value,
'psd' => $parsed->getPublicSuffixDomainPolicy(),
't' => $parsed->getTestingMode()?->value,
'valid' => true,
'error_message' => null,
'last_checked_at' => now(),
'next_check_at' => now()->addHours(24),
]
);
Log::debug('Saved successful check', [
'domain' => $domain,
'id' => $result->id,
'valid' => $result->valid
]);
return $result;
}
/**
* Save a failed check
*/
protected function saveFailedCheck(string $domain, ?int $domainId, string $error): DmarcCheck
{
Log::debug('Saving failed check', [
'domain' => $domain,
'error' => $error
]);
$result = DmarcCheck::updateOrCreate(
['domain' => $domain],
[
'domain_id' => $domainId,
'valid' => false,
'error_message' => $error,
'last_checked_at' => now(),
'next_check_at' => now()->addHours(6),
]
);
Log::debug('Saved failed check', [
'domain' => $domain,
'id' => $result->id,
'valid' => $result->valid,
'error' => $result->error_message
]);
return $result;
}
/**
* Clear cache for a domain
*/
public function clearCache(string $domain): void
{
Log::info('Clearing DMARC cache', ['domain' => $domain]);
DmarcCheck::where('domain', $domain)->delete();
Log::debug('DMARC cache cleared', ['domain' => $domain]);
}
/**
* Get cached DMARC record for a domain
*/
public function getCached(string $domain): ?DmarcCheck
{
Log::debug('Getting cached DMARC record', ['domain' => $domain]);
$cache = DmarcCheck::where('domain', $domain)->first();
if (!$cache) {
Log::debug('No cached record found, checking domain', ['domain' => $domain]);
return $this->checkDomain($domain);
}
if ($cache->next_check_at && $cache->next_check_at <= now()) {
Log::debug('Cached record expired, rechecking', [
'domain' => $domain,
'next_check' => $cache->next_check_at,
'now' => now()
]);
return $this->checkDomain($domain);
}
Log::debug('Returning cached record', [
'domain' => $domain,
'valid' => $cache->valid,
'policy' => $cache->policy
]);
return $cache;
}
/**
* Get statistics about DMARC records
*/
public function getStats(): array
{
Log::debug('Getting DMARC statistics');
$total = DmarcCheck::count();
$valid = DmarcCheck::where('valid', true)->count();
$invalid = DmarcCheck::where('valid', false)->count();
$policies = DmarcCheck::where('valid', true)
->select('policy', \DB::raw('count(*) as count'))
->groupBy('policy')
->pluck('count', 'policy')
->toArray();
$noRecord = DmarcCheck::where('valid', false)
->where('error_message', 'No DMARC record found')
->count();
$stats = [
'total' => $total,
'valid' => $valid,
'invalid' => $invalid,
'policies' => $policies,
'no_record' => $noRecord,
];
Log::debug('DMARC statistics', $stats);
return $stats;
}
}
@@ -0,0 +1,316 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Filament\Forms\Form;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Slider;
use Filament\Forms\Components\Placeholder;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
use VEximweb\Plugin\DnsTools\Dmarc\DmarcRecord;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcPolicy;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcAlignment;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcReporting;
use VEximweb\Core\Data\Models\Domain;
class GenerateDmarc extends CreateRecord
{
protected static string $resource = DmarcResource::class;
public Domain $domain;
public ?DmarcCheck $existingDmarc = null;
public function mount($record = null): void
{
if ($record) {
$this->domain = Domain::find($record);
$this->existingDmarc = DmarcCheck::where('domain', $this->domain->domain)->first();
}
parent::mount();
if ($this->existingDmarc) {
$defaults = [
'policy' => $this->existingDmarc->policy ?? 'none',
'subdomain_policy' => $this->existingDmarc->subdomain_policy ?? null,
'adkim' => $this->existingDmarc->adkim ?? 'relaxed',
'aspf' => $this->existingDmarc->aspf ?? 'relaxed',
'reporting' => json_decode($this->existingDmarc->reporting ?? '["all"]', true),
'percentage' => $this->existingDmarc->percentage ?? 100,
't' => $this->existingDmarc->t ?? 'n',
];
if ($this->existingDmarc->rua) {
$rua = json_decode($this->existingDmarc->rua, true);
if (is_array($rua)) {
$defaults['rua'] = collect($rua)->map(function ($item) {
return ['email' => str_replace('mailto:', '', $item)];
})->toArray();
}
}
if ($this->existingDmarc->ruf) {
$ruf = json_decode($this->existingDmarc->ruf, true);
if (is_array($ruf)) {
$defaults['ruf'] = collect($ruf)->map(function ($item) {
return ['email' => str_replace('mailto:', '', $item)];
})->toArray();
}
}
$this->form->fill($defaults);
}
}
public function form(Form $form): Form
{
$service = app(DmarcCheckService::class);
$addresses = $service->getDefaultReportingAddresses($this->domain->domain ?? config('app.domain'));
return $form
->schema([
Select::make('policy')
->label('Policy')
->options([
'none' => 'None (Monitor only)',
'quarantine' => 'Quarantine (Mark as spam)',
'reject' => 'Reject (Block delivery)',
])
->required()
->default('none')
->live(),
Select::make('subdomain_policy')
->label('Subdomain Policy')
->options([
'' => 'Inherit from main policy',
'none' => 'None (Monitor only)',
'quarantine' => 'Quarantine (Mark as spam)',
'reject' => 'Reject (Block delivery)',
])
->nullable(),
Repeater::make('rua')
->label('Aggregate Reports (RUA)')
->schema([
TextInput::make('email')
->label('Email')
->email()
->prefix('mailto:')
->required(),
])
->default(function () use ($addresses) {
if ($this->existingDmarc && $this->existingDmarc->rua) {
$rua = json_decode($this->existingDmarc->rua, true);
if (is_array($rua)) {
return collect($rua)->map(function ($item) {
return ['email' => str_replace('mailto:', '', $item)];
})->toArray();
}
}
return [['email' => str_replace('mailto:', '', $addresses['rua'])]];
})
->addable()
->deletable()
->reorderable()
->columnSpanFull(),
Repeater::make('ruf')
->label('Forensic Reports (RUF)')
->schema([
TextInput::make('email')
->label('Email')
->email()
->prefix('mailto:')
->required(),
])
->default(function () use ($addresses) {
if ($this->existingDmarc && $this->existingDmarc->ruf) {
$ruf = json_decode($this->existingDmarc->ruf, true);
if (is_array($ruf)) {
return collect($ruf)->map(function ($item) {
return ['email' => str_replace('mailto:', '', $item)];
})->toArray();
}
}
return [['email' => str_replace('mailto:', '', $addresses['ruf'])]];
})
->addable()
->deletable()
->reorderable()
->columnSpanFull(),
Select::make('adkim')
->label('DKIM Alignment')
->options([
'relaxed' => 'Relaxed (subdomains allowed)',
'strict' => 'Strict (exact match)',
])
->default('relaxed'),
Select::make('aspf')
->label('SPF Alignment')
->options([
'relaxed' => 'Relaxed (subdomains allowed)',
'strict' => 'Strict (exact match)',
])
->default('relaxed'),
Select::make('reporting')
->label('Failure Reporting (FO)')
->multiple()
->options([
'all' => 'All failures',
'any' => 'Any failure',
'dkim' => 'DKIM failures only',
'spf' => 'SPF failures only',
])
->default(['all']),
Slider::make('percentage')
->label('Enforcement Percentage')
->min(0)
->max(100)
->default(100),
Select::make('t')
->label('Testing Mode')
->options([
'n' => 'Off (Apply policy)',
'y' => 'On (Don\'t apply policy)',
])
->default('n'),
Placeholder::make('generated_record_preview')
->label('Preview')
->content(function ($get) {
try {
$settings = $this->prepareSettings($get);
$dmarcRecord = new DmarcRecord(
policy: DmarcPolicy::tryFrom($settings['policy']),
subdomainPolicy: $settings['subdomain_policy'] ? DmarcPolicy::tryFrom($settings['subdomain_policy']) : null,
rua: $settings['rua'],
ruf: $settings['ruf'],
adkim: DmarcAlignment::tryFrom($settings['adkim']),
aspf: DmarcAlignment::tryFrom($settings['aspf']),
reporting: array_map(fn($r) => DmarcReporting::tryFrom($r), $settings['reporting']),
percentage: (int)$settings['percentage'],
t: $settings['t'],
);
if ($this->domain) {
return "Name: _dmarc.{$this->domain->domain}\n" .
"Type: TXT\n" .
"Value: v=DMARC1; " . $dmarcRecord->toDnsRecord();
}
return "Please select a domain to preview";
} catch (\Exception $e) {
return "Error generating preview: " . $e->getMessage();
}
})
->extraAttributes(['class' => 'font-mono text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded'])
->columnSpanFull(),
]);
}
protected function prepareSettings(array $data): array
{
return [
'policy' => $data['policy'] ?? 'none',
'subdomain_policy' => $data['subdomain_policy'] ?? null,
'rua' => collect($data['rua'] ?? [])
->filter(fn($item) => !empty($item['email']))
->map(fn($item) => 'mailto:' . $item['email'])
->values()
->toArray(),
'ruf' => collect($data['ruf'] ?? [])
->filter(fn($item) => !empty($item['email']))
->map(fn($item) => 'mailto:' . $item['email'])
->values()
->toArray(),
'adkim' => $data['adkim'] ?? 'relaxed',
'aspf' => $data['aspf'] ?? 'relaxed',
'reporting' => $data['reporting'] ?? ['all'],
'percentage' => (int)($data['percentage'] ?? 100),
't' => $data['t'] ?? 'n',
];
}
protected function handleRecordCreation(array $data): \Illuminate\Database\Eloquent\Model
{
$settings = $this->prepareSettings($data);
$dmarcRecord = new DmarcRecord(
policy: DmarcPolicy::tryFrom($settings['policy']),
subdomainPolicy: $settings['subdomain_policy'] ? DmarcPolicy::tryFrom($settings['subdomain_policy']) : null,
rua: $settings['rua'],
ruf: $settings['ruf'],
adkim: DmarcAlignment::tryFrom($settings['adkim']),
aspf: DmarcAlignment::tryFrom($settings['aspf']),
reporting: array_map(fn($r) => DmarcReporting::tryFrom($r), $settings['reporting']),
percentage: (int)$settings['percentage'],
t: $settings['t'],
);
$dnsRecord = 'v=DMARC1; ' . $dmarcRecord->toDnsRecord();
return DmarcCheck::updateOrCreate(
['domain' => $this->domain->domain],
[
'domain_id' => $this->domain->domain_id,
'record' => $dnsRecord,
'policy' => $settings['policy'],
'subdomain_policy' => $settings['subdomain_policy'],
'adkim' => $settings['adkim'],
'aspf' => $settings['aspf'],
'rua' => json_encode($settings['rua']),
'ruf' => json_encode($settings['ruf']),
'reporting' => json_encode($settings['reporting']),
'percentage' => (int)$settings['percentage'],
't' => $settings['t'],
'valid' => true,
'error_message' => null,
'last_checked_at' => now(),
'next_check_at' => now()->addHours(24),
]
);
}
protected function getCreatedNotificationTitle(): ?string
{
return "DMARC record generated for {$this->domain->domain}";
}
protected function getRedirectUrl(): string
{
return DmarcResource::getUrl('view', ['record' => $this->domain->getKey()]);
}
protected function getHeaderActions(): array
{
return [
Action::make('back')
->label('Back to View')
->icon('heroicon-o-arrow-left')
->url(DmarcResource::getUrl('view', ['record' => $this->domain->getKey()])),
Action::make('back_to_list')
->label('Back to List')
->icon('heroicon-o-list-bullet')
->url(DmarcResource::getUrl('index')),
];
}
public function getTitle(): string
{
$action = $this->existingDmarc ? 'Update' : 'Generate';
return "{$action} DMARC Record";
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Table;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Notifications\Notification;
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
use VEximweb\Core\Data\Models\Domain;
use Illuminate\Database\Eloquent\Builder;
class ListDmarc extends ListRecords
{
protected static string $resource = DmarcResource::class;
protected function getTableQuery(): Builder
{
return Domain::where('enabled', true);
}
public function table(Table $table): Table
{
return $table
->query($this->getTableQuery())
->columns([
TextColumn::make('domain')
->label('Domain')
->searchable()
->sortable()
->size('lg')
->weight('bold'),
BadgeColumn::make('dmarc_status')
->label('DMARC Status')
->getStateUsing(function ($record) {
$dmarc = DmarcCheck::where('domain', $record->domain)->first();
if (!$dmarc) {
return 'Not Checked';
}
if (!$dmarc->valid) {
return 'No Record';
}
return $dmarc->getPolicyLabel();
})
->colors([
'success' => 'Monitor',
'warning' => 'Quarantine',
'danger' => 'Reject',
'gray' => ['Not Checked', 'No Record'],
]),
BadgeColumn::make('dmarc_policy')
->label('Policy')
->getStateUsing(function ($record) {
$dmarc = DmarcCheck::where('domain', $record->domain)->first();
return $dmarc?->policy ?? '-';
})
->colors([
'success' => 'none',
'warning' => 'quarantine',
'danger' => 'reject',
'gray' => '-',
]),
TextColumn::make('dmarc_last_checked')
->label('Last Checked')
->getStateUsing(function ($record) {
$dmarc = DmarcCheck::where('domain', $record->domain)->first();
return $dmarc?->last_checked_at;
})
->since()
->toggleable(),
TextColumn::make('exim_users_count')
->label('Accounts')
->counts('eximUsers')
->badge()
->color('info'),
])
->filters([
\Filament\Tables\Filters\SelectFilter::make('dmarc_status')
->label('DMARC Status')
->options([
'has_record' => 'Has DMARC Record',
'no_record' => 'No DMARC Record',
'not_checked' => 'Not Checked',
])
->query(function ($query, $data) {
if (empty($data['value'])) {
return $query;
}
return match($data['value']) {
'has_record' => $query->whereIn('domain',
DmarcCheck::where('valid', true)->pluck('domain')->toArray()
),
'no_record' => $query->whereIn('domain',
DmarcCheck::where('valid', false)->pluck('domain')->toArray()
),
'not_checked' => $query->whereNotIn('domain',
DmarcCheck::pluck('domain')->toArray()
),
default => $query,
};
}),
\Filament\Tables\Filters\SelectFilter::make('policy')
->label('Policy')
->options([
'none' => 'Monitor',
'quarantine' => 'Quarantine',
'reject' => 'Reject',
])
->query(function ($query, $data) {
if (empty($data['value'])) {
return $query;
}
$domains = DmarcCheck::where('policy', $data['value'])
->where('valid', true)
->pluck('domain')
->toArray();
return $query->whereIn('domain', $domains);
}),
])
->actions([
Action::make('view')
->label('View DMARC')
->icon('heroicon-o-eye')
->color('info')
->url(fn ($record): string => DmarcResource::getUrl('view', ['record' => $record])),
Action::make('generate')
->label('Generate Record')
->icon('heroicon-o-document-plus')
->color('success')
->url(fn ($record): string => DmarcResource::getUrl('generate', ['record' => $record])),
Action::make('check_dns')
->label('Check DNS')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function ($record) {
$service = app(DmarcCheckService::class);
$result = $service->checkDomain($record->domain, $record->domain_id);
if ($result && $result->valid) {
Notification::make()
->success()
->title('DMARC Record Found')
->body("Policy: {$result->getPolicyLabel()}")
->send();
} else {
Notification::make()
->warning()
->title('No DMARC Record Found')
->body($result?->error_message ?? 'No DMARC record found in DNS')
->send();
}
}),
])
->bulkActions([
BulkActionGroup::make([
Action::make('check_all_dmarc')
->label('Check DMARC for Selected')
->icon('heroicon-o-arrow-path')
->action(function ($records) {
$service = app(DmarcCheckService::class);
$valid = 0;
foreach ($records as $record) {
$result = $service->checkDomain($record->domain, $record->domain_id);
if ($result && $result->valid) {
$valid++;
}
}
Notification::make()
->success()
->title('DMARC Check Complete')
->body("Checked {$records->count()} domains, {$valid} have valid DMARC records")
->send();
}),
]),
]);
}
}
@@ -0,0 +1,76 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc;
use Filament\Resources\Pages\ViewRecord;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
class ViewDmarc extends ViewRecord
{
protected static string $resource = DmarcResource::class;
public ?DmarcCheck $dmarc = null;
protected function mutateFormDataBeforeFill(array $data): array
{
// Get the DMARC record for this domain
$this->dmarc = DmarcCheck::where('domain', $this->record->domain)->first();
// If no cache or invalid, check now
if (!$this->dmarc || !$this->dmarc->valid) {
$service = app(DmarcCheckService::class);
$this->dmarc = $service->checkDomain($this->record->domain, $this->record->domain_id);
}
return $data;
}
protected function getHeaderActions(): array
{
return [
Action::make('check_dns')
->label('Check DNS Again')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function () {
$service = app(DmarcCheckService::class);
$this->dmarc = $service->checkDomain($this->record->domain, $this->record->domain_id);
Notification::make()
->success()
->title('DMARC Record Updated')
->body($this->dmarc?->valid ? 'Record found and updated' : 'No valid record found')
->send();
$this->refreshFormData();
}),
Action::make('generate')
->label('Generate New Record')
->icon('heroicon-o-document-plus')
->color('success')
->url(DmarcResource::getUrl('generate', ['record' => $this->record])),
Action::make('back')
->label('Back to List')
->icon('heroicon-o-arrow-left')
->url(DmarcResource::getUrl('index')),
];
}
protected function getViewData(): array
{
return [
'dmarc' => $this->dmarc,
];
}
public function getTitle(): string
{
return "DMARC Record for {$this->record->domain}";
}
}
@@ -0,0 +1,27 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Providers;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use VEximweb\Plugin\DnsTools\Filament\Resources\DmarcResource;
class DnsToolsPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('dns-tools')
->path('dns-tools')
->colors([
'primary' => Color::Blue,
])
->resources([
DmarcResource::class,
])
->navigationGroups([
'DNS Tools',
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Resources;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Notifications\Notification;
use VEximweb\Core\Data\Models\Domain;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
use VEximweb\Plugin\DnsTools\Dmarc\DmarcRecord;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcPolicy;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcAlignment;
use VEximweb\Plugin\DnsTools\Dmarc\Enums\DmarcReporting;
use BackedEnum;
use VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc\ListDmarc;
use VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc\ViewDmarc;
use VEximweb\Plugin\DnsTools\Filament\Pages\Dmarc\GenerateDmarc;
class DmarcResource extends Resource
{
protected static ?string $model = Domain::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|\UnitEnum|null $navigationGroup = 'DNS Tools';
protected static ?string $navigationLabel = 'DMARC Management';
protected static ?string $pluralLabel = 'DMARC Management';
protected static ?int $navigationSort = 1;
protected static ?string $slug = 'dnstools/dmarc';
public static function getModel(): string
{
return Domain::class;
}
public static function getPages(): array
{
return [
'index' => ListDmarc::route('/'),
'view' => ViewDmarc::route('/{record}/view'),
'generate' => GenerateDmarc::route('/{record}/generate'),
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Pages;
use VEximweb\Core\Domain\Filament\Resources\DomainResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListDomains extends ListRecords
{
protected static string $resource = DomainResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Resources;
//use VEximweb\Plugin\DnsTools\Filament\Resources\Dmarc\Pages\CreateDomain;
//use VEximweb\Core\Domain\Filament\Resources\Pages\EditDomain;
use VEximweb\Plugin\DnsTools\Filament\Resources\Pages\ListDomains;
use VEximweb\Core\Domain\Filament\Resources\Schemas\DomainForm;
use VEximweb\Plugin\DnsTools\Filament\Resources\Tables\DomainsTable;
use VEximweb\Plugin\DnsTools\Models\SystemDomains as Domain;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class DnsToolsResource extends Resource
{
protected static ?string $model = Domain::class;
protected static ?string $slug = 'dnstools/domains';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $recordTitleAttribute = 'domain';
protected static string|\UnitEnum|null $navigationGroup = 'DNS Tools';
protected static ?string $navigationLabel = 'DNS Tools';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
// Get the base form
$schema = DomainForm::configure($schema);
// Check if DNS Core is installed and try to get extensions
if (class_exists(\VEximweb\Plugin\DnsCore\Services\DnsProviderDiscoveryService::class)) {
try {
$discoveryService = app(\VEximweb\Plugin\DnsCore\Services\DnsProviderDiscoveryService::class);
// Make sure the discovery service is booted
if (method_exists($discoveryService, 'boot')) {
$discoveryService->boot();
}
$extensions = $discoveryService->getFormExtensions();
if (!empty($extensions)) {
$extensionComponents = [];
foreach ($extensions as $extension) {
if (isset($extension['components']) && is_callable($extension['components'])) {
$components = $extension['components']();
if (is_array($components)) {
$extensionComponents = array_merge($extensionComponents, $components);
}
}
}
if (!empty($extensionComponents)) {
\Log::debug('Injecting DNS form extensions', [
'component_count' => count($extensionComponents),
'extension_count' => count($extensions),
]);
$schema = $schema->components([
...$schema->getComponents(),
...$extensionComponents,
]);
}
}
} catch (\Exception $e) {
Log::debug('Could not load DNS form extensions: ' . $e->getMessage());
}
}
return $schema;
}
public static function form_old(Schema $schema): Schema
{
return DomainForm::configure($schema);
}
public static function table(Table $table): Table
{
return DomainsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
$user = auth()->user();
// If no user is logged in, return empty query
if (!$user) {
return $query->whereRaw('1 = 0');
}
// System admins can see all domains
if ($user->isSystemAdmin()) {
return $query;
}
// Domain admins only see domains they're assigned to
if ($user->isDomainAdmin()) {
return $query->whereHas('administrators', function ($q) use ($user) {
$q->where('user_id', $user->id)
->where('role', 'domain_admin');
});
}
// Domain users shouldn't see any domains
return $query->whereRaw('1 = 0');
}
public static function getNavigationBadgeColor(): ?string
{
return 'primary';
}
public static function getNavigationBadgeTooltip(): ?string
{
$user = auth()->user();
if (!$user) {
return null;
}
if ($user->isSystemAdmin()) {
return 'Total number of domains in the system';
}
if ($user->isDomainAdmin()) {
return 'Total number of domains you administer';
}
return null;
}
// Hide navigation item for domain-users
public static function shouldRegisterNavigation(): bool
{
$user = auth()->user();
// Hide for domain-user
if (!$user || $user->isDomainUser()) {
return false;
}
return true;
}
public static function getPages(): array
{
return [
'index' => ListDomains::route('/'),
//'create' => CreateDomain::route('/create'),
//'edit' => EditDomain::route('/{record}/edit'),
];
}
public static function getGloballySearchableAttributes(): array
{
return ['domain'];
}
}
@@ -0,0 +1,19 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Pages;
use VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListDomains extends ListRecords
{
protected static string $resource = DnsToolsResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}
@@ -0,0 +1,258 @@
<?php
namespace VEximweb\Plugin\DnsTools\Filament\Resources\Tables;
use VEximweb\Plugin\DnsTools\Models\SystemDomains;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Filament\Actions\Action;
use VEximweb\Core\EximUser\Filament\Resources\EximUserResource;
use VEximweb\Core\EximAlias\Filament\Resources\EximAliasResource;
use VEximweb\Core\EximCatchAll\Filament\Resources\EximCatchAllResource;
use VEximweb\Core\EximFail\Filament\Resources\EximFailResource;
use Filament\Support\Icons\Heroicon;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
use Filament\Notifications\Notification;
use Filament\Actions\ActionGroup;
class DomainsTable
{
public static function configure(Table $table): Table
{
return $table
->recordUrl(null)
->columns([
TextColumn::make('domain')
->searchable()
->sortable(),
IconColumn::make('enabled')
->boolean()
->sortable(),
IconColumn::make('dkim.enabled')
->label('DKIM')
->boolean()
->trueIcon('heroicon-o-key')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('gray')
->tooltip(function ($record): string {
if ($record->dkim && $record->dkim->enabled) {
return "DKIM active (selector: {$record->dkim->selector})";
} elseif ($record->dkim && !$record->dkim->enabled) {
return "DKIM keys exist but are disabled";
} else {
return "No DKIM keys configured";
}
}),
IconColumn::make('dmarccheck.valid')
->label('DMARC')
->boolean()
->trueIcon('heroicon-o-shield-check')
->falseIcon('heroicon-o-exclamation-triangle')
->trueColor('success')
->falseColor('danger')
->tooltip(function ($record): string {
$dmarc = $record->dmarcCheck;
if (!$dmarc) {
return 'DMARC not checked yet';
}
if ($dmarc->valid) {
return "DMARC valid (Policy: {$dmarc->getPolicyLabel()})";
} else {
return "DMARC invalid: {$dmarc->error_message}";
}
}),
])
->filters([
SelectFilter::make('enabled')
->options([
'1' => 'Enabled',
'0' => 'Disabled',
]),
SelectFilter::make('dkim_status')
->label('DKIM Status')
->options([
'has_dkim' => 'Has DKIM (Active)',
'has_disabled' => 'Has DKIM (Disabled)',
'no_dkim' => 'No DKIM',
])
->query(function (Builder $query, array $data): Builder {
if ($data['value'] === 'has_dkim') {
return $query->whereHas('dkim', function ($q) {
$q->where('enabled', true);
});
}
if ($data['value'] === 'has_disabled') {
return $query->whereHas('dkim', function ($q) {
$q->where('enabled', false);
});
}
if ($data['value'] === 'no_dkim') {
return $query->whereDoesntHave('dkim');
}
return $query;
}),
// DMARC Status Filter
SelectFilter::make('dmarc_status')
->label('DMARC Status')
->options([
'valid' => 'Valid DMARC',
'invalid' => 'Invalid DMARC',
'not_checked' => 'Not Checked',
])
->query(function (Builder $query, array $data): Builder {
if ($data['value'] === 'valid') {
return $query->whereHas('dmarcCheck', function ($q) {
$q->where('valid', true);
});
}
if ($data['value'] === 'invalid') {
return $query->whereHas('dmarcCheck', function ($q) {
$q->where('valid', false);
});
}
if ($data['value'] === 'not_checked') {
return $query->whereDoesntHave('dmarcCheck');
}
return $query;
}),
// DMARC Policy Filter
SelectFilter::make('dmarc_policy')
->label('DMARC Policy')
->options([
'none' => 'Monitor (none)',
'quarantine' => 'Quarantine',
'reject' => 'Reject',
])
->query(function (Builder $query, array $data): Builder {
if ($data['value']) {
return $query->whereHas('dmarcCheck', function ($q) use ($data) {
$q->where('policy', $data['value'])
->where('valid', true);
});
}
return $query;
}),
])
->recordActions([
Action::make('checkDmarc')
->icon(Heroicon::ArrowPath)
->label('')
->tooltip('Check DMARC record now')
->color('info')
->action(function ($record) {
try {
$service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class);
$result = $service->checkDomain($record->domain, $record->domain_id);
if ($result && $result->valid) {
Notification::make()
->title('DMARC check completed')
->body("Valid DMARC record found for {$record->domain}")
->success()
->send();
} else {
$error = $result ? $result->error_message : 'Unknown error';
Notification::make()
->title('DMARC check completed')
->body("No valid DMARC record for {$record->domain}: {$error}")
->warning()
->send();
}
} catch (\Exception $e) {
Notification::make()
->title('DMARC check failed')
->body($e->getMessage())
->danger()
->send();
}
}),
//EditAction::make(),
ActionGroup::make([
EditAction::make(),
EditAction::make(),
EditAction::make(),
Action::make('viewDmarc')
->label('DMARC Details')
->modalHeading('DMARC Record Details')
->modalSubheading(fn ($record) => $record->domain)
->modalContent(fn ($record) => view(
'dns-tools::filament.modals.dmarc-details',
[
'domain' => $record->domain,
'dmarc' => $record->dmarcCheck,
'record' => $record,
]
))
->modalWidth('3xl')
->modalActions([
Action::make('checkAgain')
->label('Check Again')
->action(function ($record) {
$service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class);
$service->checkDomain($record->domain, $record->domain_id);
Notification::make()
->title('DMARC check completed')
->success()
->send();
}),
Action::make('close')
->label('Close')
->close(),
]),
]),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
// Add bulk action to check DMARC for selected domains
Action::make('checkSelectedDmarc')
->label('Check DMARC for selected')
->icon(Heroicon::ArrowPath)
->color('info')
->requiresConfirmation()
->action(function ($records) {
$service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class);
$count = 0;
$valid = 0;
foreach ($records as $record) {
$result = $service->checkDomain($record->domain, $record->domain_id);
$count++;
if ($result && $result->valid) {
$valid++;
}
}
Notification::make()
->title("DMARC check completed for {$count} domains")
->body("{$valid} domains have valid DMARC records")
->success()
->send();
}),
]);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace VEximweb\Plugin\DnsTools\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Cached DMARC records for domains
*/
class DmarcCheck extends Model
{
protected $table = 'vw_dmarc_checks';
protected $primaryKey = 'id';
protected $fillable = [
'domain',
'domain_id', // Foreign key to domains.domain_id (just for reference)
'record',
'policy',
'subdomain_policy',
'adkim',
'aspf',
'rua',
'ruf',
'reporting',
'percentage',
'report_interval',
'np',
'psd',
't',
'valid',
'error_message',
'last_checked_at',
'next_check_at',
];
protected $casts = [
'valid' => 'boolean',
'last_checked_at' => 'datetime',
'next_check_at' => 'datetime',
'percentage' => 'integer',
'report_interval' => 'integer',
'rua' => 'array',
'ruf' => 'array',
'reporting' => 'array',
];
/**
* Get the domain this DMARC check belongs to (optional relation)
*/
public function domain(): BelongsTo
{
return $this->belongsTo(\VEximweb\Core\Data\Models\Domain::class, 'domain_id', 'domain_id');
}
/**
* Check if this domain has a DMARC record
*/
public function hasRecord(): bool
{
return $this->valid && !empty($this->record);
}
/**
* Get the DMARC record as a string
*/
public function getRecordString(): ?string
{
return $this->record;
}
/**
* Get the policy with a nice label
*/
public function getPolicyLabel(): string
{
$labels = [
'none' => 'Monitor',
'quarantine' => 'Quarantine',
'reject' => 'Reject',
];
return $labels[$this->policy] ?? 'Unknown';
}
/**
* Get the policy with a color
*/
public function getPolicyColor(): string
{
return match($this->policy) {
'none' => 'success',
'quarantine' => 'warning',
'reject' => 'danger',
default => 'gray',
};
}
/**
* Get policy icon
*/
public function getPolicyIcon(): string
{
return match($this->policy) {
'none' => 'heroicon-o-eye',
'quarantine' => 'heroicon-o-shield-exclamation',
'reject' => 'heroicon-o-x-circle',
default => 'heroicon-o-question-mark-circle',
};
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace VEximweb\Plugin\DnsTools\Models;
use VEximweb\Core\Data\Models\Domain as BaseDomain;
use VEximweb\Plugin\DnsTools\Models\DmarcCheck;
class SystemDomains extends BaseDomain
{
/**
* Get the DMARC check for this domain
*/
public function dmarcCheck()
{
return $this->hasOne(DmarcCheck::class, 'domain_id', 'domain_id');
}
/**
* Check if domain has a valid DMARC record
*/
public function hasValidDmarc(): bool
{
$dmarc = $this->dmarcCheck()->first();
return $dmarc && $dmarc->valid;
}
/**
* Get DMARC status
*/
public function getDmarcStatus(): string
{
$dmarc = $this->dmarcCheck()->first();
if (!$dmarc) {
return 'not_checked';
}
return $dmarc->valid ? 'valid' : 'invalid';
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace VEximweb\Plugin\DnsTools\Providers;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Support\Facades\File;
use VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource;
class DnsToolsPlugin implements Plugin
{
public static function make(): static
{
return app(static::class);
}
public function getId(): string
{
return 'dns-tools';
}
public function register(Panel $panel): void
{
$panel->resources([
DnsToolsResource::class,
]);
$widgetPath = __DIR__ . '/Filament/Widgets';
if (is_dir($widgetPath)) {
$widgetClasses = $this->discoverWidgets($widgetPath);
$panel->widgets($widgetClasses);
}
}
public function boot(Panel $panel): void {}
protected function discoverWidgets(string $path): array
{
$widgets = [];
/*
$files = File::allFiles($path);
foreach ($files as $file) {
$class = 'VEximweb\\Plugin\\DMARC\\Filament\\Widgets\\' . $file->getFilenameWithoutExtension();
if (class_exists($class)) {
$widgets[] = $class;
}
}
*/
return $widgets;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace VEximweb\Plugin\DnsTools\Providers;
use Illuminate\Support\ServiceProvider;
use VEximweb\Plugin\DnsTools\Services\DnsToolsService;
use VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService;
use Filament\Panel;
//use VEximweb\Plugin\DnsTools\Filament\Providers\DnsToolsPanelProvider;
class DnsToolsServiceProvider extends ServiceProvider
{
public function register(): void
{
// Merge config
/*
$this->mergeConfigFrom(
__DIR__ . '/../../config/dmarc.php',
'dmarc'
);
*/
// Register DMARC record service
$this->app->singleton('dmarc.record.service', function ($app) {
// Try to get setting repository from container
$settingRepository = null;
try {
if ($app->has('VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface')) {
$settingRepository = $app->make('VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface');
}
} catch (\Exception $e) {
// Setting repository not available, continue without it
}
return new DmarcRecordService($settingRepository);
});
// Register main service
$this->app->singleton('dns-tools.service', function ($app) {
return new DnsToolsService();
});
Panel::configureUsing(function (Panel $panel) {
$panel->plugin(DnsToolsPlugin::make());
});
//$this->app->register(DnsToolsPanelProvider::class);
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/dmarc.php' => config_path('dmarc.php'),
], 'dmarc-config');
// Load migrations
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
// Load routes
// $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php');
// Load views
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'dns-tools');
// Load translations
//$this->loadTranslationsFrom(__DIR__ . '/../../resources/lang', 'dns-tools');
if (class_exists(\Filament\Panel::class)) {
// The resource will be auto-discovered if you use:
// $panel->discoverResources() in your panel provider
// Or register it manually:
\Filament\Facades\Filament::registerResources([
\VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource::class,
]);
}
if ($this->app->runningInConsole()) {
$this->commands([
\VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class,
]);
}
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace VEximweb\Plugin\DnsTools\Services;
use Illuminate\Support\Facades\Log;
class DnsToolsService
{
/**
* Check MX records for a domain
*
* @param string $domain
* @return array|null Array of MX records or null if none found
*/
public function checkMxRecord(string $domain): ?array
{
try {
$mxRecords = dns_get_record($domain, DNS_MX);
if (empty($mxRecords)) {
Log::info("No MX records found for domain: {$domain}");
return null;
}
// Sort by priority
usort($mxRecords, function($a, $b) {
return $a['pri'] <=> $b['pri'];
});
return $mxRecords;
} catch (\Exception $e) {
Log::error("Error checking MX records for {$domain}: " . $e->getMessage());
return null;
}
}
/**
* Check if domain has valid MX records
*/
public function hasValidMxRecord(string $domain): bool
{
$records = $this->checkMxRecord($domain);
return !empty($records);
}
function getSPFRecord(string $domain) {
$dnsRecords = dns_get_record($domain, DNS_TXT);
if (!$dnsRecords) {
return "Failed to fetch DNS records.";
}
foreach ($dnsRecords as $record) {
if (isset($record['txt']) && stripos($record['txt'], 'v=spf1') === 0) {
return $record['txt'];
}
}
return "No SPF record found.";
}
}