├── src ├── Exception.php ├── Term.php ├── Term │ ├── Modifier.php │ ├── TermWithDomainSpec.php │ ├── Mechanism │ │ ├── AllMechanism.php │ │ ├── Ip4Mechanism.php │ │ ├── Ip6Mechanism.php │ │ ├── IncludeMechanism.php │ │ ├── ExistsMechanism.php │ │ ├── PtrMechanism.php │ │ ├── AMechanism.php │ │ └── MxMechanism.php │ ├── Modifier │ │ ├── UnknownModifier.php │ │ ├── ExpModifier.php │ │ └── RedirectModifier.php │ └── Mechanism.php ├── Macro │ ├── MacroString │ │ ├── Chunk.php │ │ ├── Chunk │ │ │ ├── LiteralString.php │ │ │ └── Placeholder.php │ │ ├── Decoder.php │ │ └── Expander.php │ └── MacroString.php ├── Check │ ├── State │ │ ├── HeloDomainState.php │ │ └── MailFromState.php │ ├── DomainNameValidator.php │ ├── Environment.php │ ├── Result.php │ └── State.php ├── Exception │ ├── TooManyDNSLookupsException.php │ ├── InvalidTermException.php │ ├── TooManyDNSVoidLookupsException.php │ ├── DNSResolutionException.php │ ├── InvalidIPAddressException.php │ ├── MultipleSPFRecordsException.php │ ├── InvalidMacroStringException.php │ ├── InvalidDomainException.php │ ├── IncludeMechanismException.php │ └── MissingEnvironmentValueException.php ├── Semantic │ ├── Issue.php │ ├── OnlineIssue │ │ └── TooManyDNSLookups.php │ ├── AbstractIssue.php │ └── OnlineIssue.php ├── DNS │ ├── Resolver.php │ └── StandardResolver.php ├── OnlineDnsLookup.php ├── Record.php ├── SemanticValidator.php ├── OnlineSemanticValidator.php ├── Decoder.php └── Checker.php ├── LICENSE ├── composer.json └── README.md /src/Exception.php: -------------------------------------------------------------------------------- 1 | getEnvironment()->getHeloDomain(); 22 | 23 | return $domain === '' ? '' : "postmaster@{$domain}"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Check/State/MailFromState.php: -------------------------------------------------------------------------------- 1 | getEnvironment()->getMailFrom(); 22 | if (($mailFrom[0] ?? '') === '@') { 23 | $mailFrom = 'postmaster' . $mailFrom; 24 | } 25 | 26 | return $mailFrom; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Macro/MacroString/Chunk/LiteralString.php: -------------------------------------------------------------------------------- 1 | dnsString = $dnsString; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | * 34 | * @see \SPFLib\Macro\MacroString\Chunk::__toString() 35 | */ 36 | public function __toString(): string 37 | { 38 | return $this->dnsString; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Exception/TooManyDNSLookupsException.php: -------------------------------------------------------------------------------- 1 | maxDnsLookups = $maxDnsLookups; 28 | } 29 | 30 | /** 31 | * Get the maximum number of DNS queries that can be performed. 32 | */ 33 | public function getMaxDnsLookups(): int 34 | { 35 | return $this->maxDnsLookups; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/InvalidTermException.php: -------------------------------------------------------------------------------- 1 | term = $term; 30 | } 31 | 32 | /** 33 | * Get the term that wasn't recognized. 34 | */ 35 | public function getTerm(): string 36 | { 37 | return $this->term; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exception/TooManyDNSVoidLookupsException.php: -------------------------------------------------------------------------------- 1 | maxVoidDnsLookups = $maxVoidDnsLookups; 28 | } 29 | 30 | /** 31 | * Get maximum number of DNS IP lookups that returned zero addresses. 32 | */ 33 | public function getMaxVoidDnsLookups(): int 34 | { 35 | return $this->maxVoidDnsLookups; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/DNSResolutionException.php: -------------------------------------------------------------------------------- 1 | domain = $domain; 31 | } 32 | 33 | /** 34 | * Get the domain name for which we couldn't retrieve the TXT records. 35 | */ 36 | public function getDomain(): string 37 | { 38 | return $this->domain; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Exception/InvalidIPAddressException.php: -------------------------------------------------------------------------------- 1 | wrongIPAddress = $wrongIPAddress; 31 | } 32 | 33 | /** 34 | * Get the wrong IP address. 35 | */ 36 | public function getWrongIPAddress(): string 37 | { 38 | return $this->wrongIPAddress; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michele Locati 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Semantic/Issue.php: -------------------------------------------------------------------------------- 1 | record = $record; 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | * 33 | * @see \SPFLib\Semantic\AbstractIssue::__toString() 34 | */ 35 | public function __toString(): string 36 | { 37 | $level = $this->getLevelDescription(); 38 | 39 | return $level === '' ? $this->getDescription() : "[{$level}] {$this->getDescription()}"; 40 | } 41 | 42 | /** 43 | * Get the affected record. 44 | */ 45 | public function getRecord(): Record 46 | { 47 | return $this->record; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Term/Mechanism/AllMechanism.php: -------------------------------------------------------------------------------- 1 | getQualifier(true) . static::HANDLE; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | * 46 | * @see \SPFLib\Term\Mechanism::getName() 47 | */ 48 | public function getName(): string 49 | { 50 | return static::HANDLE; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Exception/MultipleSPFRecordsException.php: -------------------------------------------------------------------------------- 1 | domain = $domain; 38 | $this->records = $records; 39 | } 40 | 41 | /** 42 | * Get the name of the domain that has multiple SPF records. 43 | */ 44 | public function getDomain(): string 45 | { 46 | return $this->domain; 47 | } 48 | 49 | /** 50 | * Get the multiple SPF records found. 51 | * 52 | * @var string[] 53 | */ 54 | public function getRecords(): array 55 | { 56 | return $this->records; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Semantic/OnlineIssue/TooManyDNSLookups.php: -------------------------------------------------------------------------------- 1 | dnsLookups = $dnsLookups; 33 | } 34 | 35 | /** 36 | * Get all direct DNS lookups that are present in this record. 37 | * 38 | * @return \SPFLib\OnlineDnsLookup[] 39 | */ 40 | public function getDnsLookups(): array 41 | { 42 | return $this->dnsLookups; 43 | } 44 | 45 | /** 46 | * Get the total amount of DNS lookups that are involved in this record. 47 | */ 48 | public function getTotalLookupCount(): int 49 | { 50 | return array_reduce($this->dnsLookups, static function ($total, $dnsLookup) { 51 | return $total + $dnsLookup->getLookupCount(); 52 | }, 0); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/DNS/Resolver.php: -------------------------------------------------------------------------------- 1 | macroString = $macroString; 35 | } 36 | 37 | /** 38 | * Get the invalid macro-string. 39 | */ 40 | public function getMacroString(): string 41 | { 42 | return $this->macroString; 43 | } 44 | 45 | /** 46 | * Get the position inside the macro-string when the error occurred. 47 | */ 48 | public function getMacroStringPosition(): int 49 | { 50 | return $this->macroStringPosition; 51 | } 52 | 53 | /** 54 | * Set the term that wasn't recognized. 55 | * 56 | * @return $this 57 | */ 58 | public function setTerm(string $value): self 59 | { 60 | $this->term = $value; 61 | 62 | return $this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mlocati/spf-lib", 3 | "description": "Parse, build and validate SPF (Sender Policy Framework) DNS records", 4 | "type": "library", 5 | "keywords": [ 6 | "spf", 7 | "Sender Policy Framework", 8 | "email", 9 | "mail", 10 | "smtp", 11 | "dns", 12 | "mx" 13 | ], 14 | "homepage": "https://github.com/mlocati/spf-lib", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Michele Locati", 19 | "email": "michele@locati.com", 20 | "homepage": "https://mlocati.github.io", 21 | "role": "author" 22 | } 23 | ], 24 | "support": { 25 | "issues": "https://github.com/mlocati/spf-lib/issues", 26 | "source": "https://github.com/mlocati/spf-lib" 27 | }, 28 | "funding": [ 29 | { 30 | "type": "github", 31 | "url": "https://github.com/sponsors/mlocati" 32 | }, 33 | { 34 | "type": "other", 35 | "url": "https://paypal.me/mlocati" 36 | } 37 | ], 38 | "require": { 39 | "php": ">=7.1.0", 40 | "mlocati/idna": "^1", 41 | "mlocati/ip-lib": "^1.11" 42 | }, 43 | "require-dev": { 44 | "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", 45 | "symfony/yaml": "^4.4 || ^5.1" 46 | }, 47 | "autoload": { 48 | "psr-4": { 49 | "SPFLib\\": "src/" 50 | } 51 | }, 52 | "autoload-dev": { 53 | "psr-4": { 54 | "SPFLib\\Test\\": "test/src/" 55 | } 56 | }, 57 | "config": { 58 | "optimize-autoloader": true, 59 | "sort-packages": true 60 | }, 61 | "scripts": { 62 | "test": "phpunit" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/OnlineDnsLookup.php: -------------------------------------------------------------------------------- 1 | name = $name; 30 | $this->record = $record; 31 | } 32 | 33 | public function getName(): string 34 | { 35 | return $this->name; 36 | } 37 | 38 | public function getRecord(): ?string 39 | { 40 | return $this->record; 41 | } 42 | 43 | /** 44 | * Add a recursive reference that is included within this lookup's record. 45 | * 46 | * @return $this 47 | */ 48 | public function addReference(self $reference): self 49 | { 50 | $this->references[] = $reference; 51 | 52 | return $this; 53 | } 54 | 55 | /** 56 | * Get all recursive references that are included in this lookup's record. 57 | * 58 | * @return \SPFLib\OnlineDnsLookup[] 59 | */ 60 | public function getReferences(): array 61 | { 62 | return $this->references; 63 | } 64 | 65 | /** 66 | * Get the total amount of recursive references present in this lookup's record. 67 | */ 68 | public function getLookupCount(): int 69 | { 70 | return array_reduce($this->references, static function ($total, $reference) { 71 | return $total + $reference->getLookupCount(); 72 | }, 1); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Term/Modifier/UnknownModifier.php: -------------------------------------------------------------------------------- 1 | name = $name; 37 | if (!$value instanceof MacroString) { 38 | $value = MacroString\Decoder::getInstance()->decode($value === null ? '' : $value, MacroString\Decoder::FLAG_ALLOWEMPTY); 39 | } 40 | $this->value = $value; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | * 46 | * @see \SPFLib\Term::__toString() 47 | */ 48 | public function __toString(): string 49 | { 50 | return $this->getName() . '=' . (string) $this->getValue(); 51 | } 52 | 53 | public function __clone() 54 | { 55 | $this->value = clone $this->getValue(); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | * 61 | * @see \SPFLib\Term\Modifier::getName() 62 | */ 63 | public function getName(): string 64 | { 65 | return $this->name; 66 | } 67 | 68 | /** 69 | * Get the name of the modifier (the part after '='). 70 | */ 71 | public function getValue(): MacroString 72 | { 73 | return $this->value; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Term/Mechanism.php: -------------------------------------------------------------------------------- 1 | qualifier = $qualifier; 57 | } 58 | 59 | /** 60 | * Get the name of the modifier (the part before ':' or '/'). 61 | */ 62 | abstract public function getName(): string; 63 | 64 | /** 65 | * Get the qualifier of this mechanism (the value of one of the Mechanism::QUALIFIER_... constants). 66 | * 67 | * @param bool $emptyIfPass use true to return an empty string if the qualifier is QUALIFIER_PASS 68 | */ 69 | public function getQualifier(bool $emptyIfPass = false): string 70 | { 71 | return $emptyIfPass && $this->qualifier === static::QUALIFIER_PASS ? '' : $this->qualifier; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Term/Mechanism/Ip4Mechanism.php: -------------------------------------------------------------------------------- 1 | ip = $ip; 43 | $this->cidrLength = $cidrLength === null ? 32 : $cidrLength; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | * 49 | * @see \SPFLib\Term::__toString() 50 | */ 51 | public function __toString(): string 52 | { 53 | $result = $this->getQualifier(true) . static::HANDLE . ':' . (string) $this->getIP(); 54 | $cidrLength = $this->getCidrLength(); 55 | if ($cidrLength !== 32) { 56 | $result .= '/' . (string) $cidrLength; 57 | } 58 | 59 | return $result; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | * 65 | * @see \SPFLib\Term\Mechanism::getName() 66 | */ 67 | public function getName(): string 68 | { 69 | return static::HANDLE; 70 | } 71 | 72 | public function getIP(): IPv4 73 | { 74 | return $this->ip; 75 | } 76 | 77 | public function getCidrLength(): int 78 | { 79 | return $this->cidrLength; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Term/Mechanism/Ip6Mechanism.php: -------------------------------------------------------------------------------- 1 | ip = $ip; 43 | $this->cidrLength = $cidrLength === null ? 128 : $cidrLength; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | * 49 | * @see \SPFLib\Term::__toString() 50 | */ 51 | public function __toString(): string 52 | { 53 | $result = $this->getQualifier(true) . static::HANDLE . ':' . (string) $this->getIP(); 54 | $cidrLength = $this->getCidrLength(); 55 | if ($cidrLength !== 128) { 56 | $result .= '/' . (string) $cidrLength; 57 | } 58 | 59 | return $result; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | * 65 | * @see \SPFLib\Term\Mechanism::getName() 66 | */ 67 | public function getName(): string 68 | { 69 | return static::HANDLE; 70 | } 71 | 72 | public function getIP(): IPv6 73 | { 74 | return $this->ip; 75 | } 76 | 77 | public function getCidrLength(): int 78 | { 79 | return $this->cidrLength; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Exception/InvalidDomainException.php: -------------------------------------------------------------------------------- 1 | reason = $reason; 47 | $this->domain = $domain; 48 | $this->derivedFrom = $defivedFrom; 49 | } 50 | 51 | /** 52 | * Get the invalid domain. 53 | */ 54 | public function getDomain(): string 55 | { 56 | return $this->domain; 57 | } 58 | 59 | /** 60 | * Get the reason why the domain is not valid. 61 | */ 62 | public function getReason(): string 63 | { 64 | return $this->reason; 65 | } 66 | 67 | /** 68 | * Get the domain-spec instance from which the invalid domain has been derived. 69 | */ 70 | public function getDerivedFrom(): ?MacroString 71 | { 72 | return $this->derivedFrom; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Term/Modifier/ExpModifier.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 40 | $domainSpec = MacroString\Decoder::getInstance()->decode($domainSpec); 41 | } 42 | $this->domainSpec = $domainSpec; 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | * 48 | * @see \SPFLib\Term::__toString() 49 | */ 50 | public function __toString(): string 51 | { 52 | return static::HANDLE . '=' . (string) $this->getDomainSpec(); 53 | } 54 | 55 | public function __clone() 56 | { 57 | $this->domainSpec = clone $this->getDomainSpec(); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | * 63 | * @see \SPFLib\Term\Modifier::getName() 64 | */ 65 | public function getName(): string 66 | { 67 | return static::HANDLE; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | * 73 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 74 | */ 75 | public function getDomainSpec(): MacroString 76 | { 77 | return $this->domainSpec; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Term/Modifier/RedirectModifier.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 40 | $domainSpec = MacroString\Decoder::getInstance()->decode($domainSpec); 41 | } 42 | $this->domainSpec = $domainSpec; 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | * 48 | * @see \SPFLib\Term::__toString() 49 | */ 50 | public function __toString(): string 51 | { 52 | return static::HANDLE . '=' . $this->getDomainSpec(); 53 | } 54 | 55 | public function __clone() 56 | { 57 | $this->domainSpec = clone $this->getDomainSpec(); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | * 63 | * @see \SPFLib\Term\Modifier::getName() 64 | */ 65 | public function getName(): string 66 | { 67 | return static::HANDLE; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | * 73 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 74 | */ 75 | public function getDomainSpec(): MacroString 76 | { 77 | return $this->domainSpec; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Record.php: -------------------------------------------------------------------------------- 1 | getTerms()), ' '); 27 | } 28 | 29 | public function __clone() 30 | { 31 | $terms = $this->getTerms(); 32 | $this->clearTerms(); 33 | foreach ($terms as $term) { 34 | $this->addTerm(clone $term); 35 | } 36 | } 37 | 38 | /** 39 | * Clear all the terms. 40 | * 41 | * @return $this 42 | */ 43 | public function clearTerms(): self 44 | { 45 | $this->terms = []; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * @return $this 52 | */ 53 | public function addTerm(Term $term): self 54 | { 55 | $this->terms[] = $term; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * @return \SPFLib\Term[] 62 | */ 63 | public function getTerms(): array 64 | { 65 | return $this->terms; 66 | } 67 | 68 | /** 69 | * @return \SPFLib\Term\Mechanism[] 70 | */ 71 | public function getMechanisms(): array 72 | { 73 | return array_values( 74 | array_filter( 75 | $this->getTerms(), 76 | static function (Term $term): bool { 77 | return $term instanceof Term\Mechanism; 78 | } 79 | ) 80 | ); 81 | } 82 | 83 | /** 84 | * @return \SPFLib\Term\Modifier[] 85 | */ 86 | public function getModifiers(): array 87 | { 88 | return array_values( 89 | array_filter( 90 | $this->getTerms(), 91 | static function (Term $term): bool { 92 | return $term instanceof Term\Modifier; 93 | } 94 | ) 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Term/Mechanism/IncludeMechanism.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 42 | $domainSpec = MacroString\Decoder::getInstance()->decode($domainSpec); 43 | } 44 | $this->domainSpec = $domainSpec; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | * 50 | * @see \SPFLib\Term::__toString() 51 | */ 52 | public function __toString(): string 53 | { 54 | return $this->getQualifier(true) . static::HANDLE . ':' . (string) $this->getDomainSpec(); 55 | } 56 | 57 | public function __clone() 58 | { 59 | $this->domainSpec = clone $this->getDomainSpec(); 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | * 65 | * @see \SPFLib\Term\Mechanism::getName() 66 | */ 67 | public function getName(): string 68 | { 69 | return static::HANDLE; 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | * 75 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 76 | */ 77 | public function getDomainSpec(): MacroString 78 | { 79 | return $this->domainSpec; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Term/Mechanism/ExistsMechanism.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 42 | $domainSpec = MacroString\Decoder::getInstance()->decode((string) $domainSpec); 43 | } 44 | 45 | $this->domainSpec = $domainSpec; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | * 51 | * @see \SPFLib\Term::__toString() 52 | */ 53 | public function __toString(): string 54 | { 55 | return $this->getQualifier(true) . static::HANDLE . ':' . (string) $this->getDomainSpec(); 56 | } 57 | 58 | public function __clone() 59 | { 60 | $this->domainSpec = clone $this->getDomainSpec(); 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | * 66 | * @see \SPFLib\Term\Mechanism::getName() 67 | */ 68 | public function getName(): string 69 | { 70 | return static::HANDLE; 71 | } 72 | 73 | /** 74 | * {@inheritdoc} 75 | * 76 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 77 | */ 78 | public function getDomainSpec(): MacroString 79 | { 80 | return $this->domainSpec; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Term/Mechanism/PtrMechanism.php: -------------------------------------------------------------------------------- 1 | decode($domainSpec === null ? '' : $domainSpec, MacroString\Decoder::FLAG_ALLOWEMPTY); 41 | } 42 | $this->domainSpec = $domainSpec; 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | * 48 | * @see \SPFLib\Term::__toString() 49 | */ 50 | public function __toString(): string 51 | { 52 | $result = $this->getQualifier(true) . static::HANDLE; 53 | $domainSpec = $this->getDomainSpec(); 54 | if (!$domainSpec->isEmpty()) { 55 | $result .= ':' . (string) $domainSpec; 56 | } 57 | 58 | return $result; 59 | } 60 | 61 | public function __clone() 62 | { 63 | $this->domainSpec = clone $this->getDomainSpec(); 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | * 69 | * @see \SPFLib\Term\Mechanism::getName() 70 | */ 71 | public function getName(): string 72 | { 73 | return static::HANDLE; 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | * 79 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 80 | */ 81 | public function getDomainSpec(): MacroString 82 | { 83 | return $this->domainSpec; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Macro/MacroString.php: -------------------------------------------------------------------------------- 1 | setChunks($chunks); 27 | } 28 | 29 | /** 30 | * Get the textual representation of this MacroString (to be used in the DNS TXT record). 31 | */ 32 | public function __toString(): string 33 | { 34 | return implode('', array_map('strval', $this->getChunks())); 35 | } 36 | 37 | public function __clone() 38 | { 39 | $chunks = $this->getChunks(); 40 | $this->setChunks([]); 41 | foreach ($chunks as $chunk) { 42 | $this->addChunk(clone $chunk); 43 | } 44 | } 45 | 46 | /** 47 | * Replace the chunks. 48 | * 49 | * @param \SPFLib\Macro\MacroString\Chunk[] $chunks 50 | * 51 | * @return $this 52 | */ 53 | public function setChunks(array $chunks): self 54 | { 55 | $this->chunks = []; 56 | foreach ($chunks as $chunk) { 57 | $this->addChunk($chunk); 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Get the parts that compose this MacroString. 65 | * 66 | * @return \SPFLib\Macro\MacroString\Chunk[] 67 | */ 68 | public function getChunks(): array 69 | { 70 | return $this->chunks; 71 | } 72 | 73 | /** 74 | * Append a new chunk. 75 | * 76 | * @return $this 77 | */ 78 | public function addChunk(Chunk $chunk): self 79 | { 80 | $this->chunks[] = $chunk; 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * Check if this instance has one or more chunk. 87 | */ 88 | public function isEmpty(): bool 89 | { 90 | return $this->getChunks() === []; 91 | } 92 | 93 | /** 94 | * Check if this instance contains some placeholders. 95 | */ 96 | public function containsPlaceholders(): bool 97 | { 98 | foreach ($this->getChunks() as $chunk) { 99 | if ($chunk instanceof Chunk\Placeholder) { 100 | return true; 101 | } 102 | } 103 | 104 | return false; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Exception/IncludeMechanismException.php: -------------------------------------------------------------------------------- 1 | finalResultCode = $finalResultCode; 56 | $this->domain = $domain; 57 | $this->mechanism = $mechanism; 58 | $this->includeResult = $includeResult; 59 | } 60 | 61 | /** 62 | * Get the final result code to be returned (the value of one of the Result::CODE_... 63 | */ 64 | public function getFinalResultCode(): string 65 | { 66 | return $this->finalResultCode; 67 | } 68 | 69 | /** 70 | * Get the domain owning the "include" spf mechanism. 71 | */ 72 | public function getDomain(): string 73 | { 74 | return $this->domain; 75 | } 76 | 77 | /** 78 | * Get the "include" mechanism for which the exception has been thrown. 79 | */ 80 | public function getMechanism(): IncludeMechanism 81 | { 82 | return $this->mechanism; 83 | } 84 | 85 | /** 86 | * Get the problematic result of the "include" mechanism. 87 | */ 88 | public function getIncludeResult(): Result 89 | { 90 | return $this->includeResult; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Exception/MissingEnvironmentValueException.php: -------------------------------------------------------------------------------- 1 | environmentValueIdentifier = $environmentValueIdentifier; 62 | } 63 | 64 | /** 65 | * Get the value of one Placeholder::ML_... constants that identifies which environment value is missing (case-insensitive). 66 | */ 67 | public function getEnvironmentValueIdentifier(): string 68 | { 69 | return $this->environmentValueIdentifier; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Term/Mechanism/AMechanism.php: -------------------------------------------------------------------------------- 1 | decode($domainSpec === null ? '' : $domainSpec, MacroString\Decoder::FLAG_ALLOWEMPTY); 53 | } 54 | $this->domainSpec = $domainSpec; 55 | $this->ip4CidrLength = $ip4CidrLength === null ? 32 : $ip4CidrLength; 56 | $this->ip6CidrLength = $ip6CidrLength === null ? 128 : $ip6CidrLength; 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | * 62 | * @see \SPFLib\Term::__toString() 63 | */ 64 | public function __toString(): string 65 | { 66 | $result = $this->getQualifier(true) . static::HANDLE; 67 | $domainSpec = $this->getDomainSpec(); 68 | if (!$domainSpec->isEmpty()) { 69 | $result .= ':' . (string) $domainSpec; 70 | } 71 | $ip4CidrLength = $this->getIp4CidrLength(); 72 | if ($ip4CidrLength !== 32) { 73 | $result .= "/{$ip4CidrLength}"; 74 | } 75 | $ip6CidrLength = $this->getIp6CidrLength(); 76 | if ($ip6CidrLength !== 128) { 77 | $result .= "//{$ip6CidrLength}"; 78 | } 79 | 80 | return $result; 81 | } 82 | 83 | public function __clone() 84 | { 85 | $this->domainSpec = clone $this->getDomainSpec(); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | * 91 | * @see \SPFLib\Term\Mechanism::getName() 92 | */ 93 | public function getName(): string 94 | { 95 | return static::HANDLE; 96 | } 97 | 98 | /** 99 | * {@inheritdoc} 100 | * 101 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 102 | */ 103 | public function getDomainSpec(): MacroString 104 | { 105 | return $this->domainSpec; 106 | } 107 | 108 | public function getIp4CidrLength(): int 109 | { 110 | return $this->ip4CidrLength; 111 | } 112 | 113 | public function getIp6CidrLength(): int 114 | { 115 | return $this->ip6CidrLength; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Term/Mechanism/MxMechanism.php: -------------------------------------------------------------------------------- 1 | decode($domainSpec === null ? '' : $domainSpec, MacroString\Decoder::FLAG_ALLOWEMPTY); 53 | } 54 | $this->domainSpec = $domainSpec; 55 | $this->ip4CidrLength = $ip4CidrLength === null ? 32 : $ip4CidrLength; 56 | $this->ip6CidrLength = $ip6CidrLength === null ? 128 : $ip6CidrLength; 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | * 62 | * @see \SPFLib\Term::__toString() 63 | */ 64 | public function __toString(): string 65 | { 66 | $result = $this->getQualifier(true) . static::HANDLE; 67 | $domainSpec = $this->getDomainSpec(); 68 | if (!$domainSpec->isEmpty()) { 69 | $result .= ':' . (string) $domainSpec; 70 | } 71 | $ip4CidrLength = $this->getIp4CidrLength(); 72 | if ($ip4CidrLength !== 32) { 73 | $result .= "/{$ip4CidrLength}"; 74 | } 75 | $ip6CidrLength = $this->getIp6CidrLength(); 76 | if ($ip6CidrLength !== 128) { 77 | $result .= "//{$ip6CidrLength}"; 78 | } 79 | 80 | return $result; 81 | } 82 | 83 | public function __clone() 84 | { 85 | $this->domainSpec = clone $this->getDomainSpec(); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | * 91 | * @see \SPFLib\Term\Mechanism::getName() 92 | */ 93 | public function getName(): string 94 | { 95 | return static::HANDLE; 96 | } 97 | 98 | /** 99 | * {@inheritdoc} 100 | * 101 | * @see \SPFLib\Term\TermWithDomainSpec::getDomainSpec() 102 | */ 103 | public function getDomainSpec(): MacroString 104 | { 105 | return $this->domainSpec; 106 | } 107 | 108 | public function getIp4CidrLength(): int 109 | { 110 | return $this->ip4CidrLength; 111 | } 112 | 113 | public function getIp6CidrLength(): int 114 | { 115 | return $this->ip6CidrLength; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Check/DomainNameValidator.php: -------------------------------------------------------------------------------- 1 | checkNotEmpty($domain); 42 | $error = $error ?: $this->checkNoSpaces($domain); 43 | $error = $error ?: $this->checkMinLabelsCount($domain); 44 | $error = $error ?: $this->checkMaxLabelSize($domain); 45 | $error = $error ?: $this->checkInvalidChars($domain); 46 | $error = $error ?: $this->checkTopLabel($domain); 47 | if ($error !== '') { 48 | throw new InvalidDomainException($domain, $error, $derivedFrom); 49 | } 50 | 51 | return $this->ensureMaxTotalLength($domain); 52 | } 53 | 54 | protected function checkNotEmpty(string $domain): string 55 | { 56 | if (trim($domain, ' .') === '') { 57 | return 'the domain name is empty'; 58 | } 59 | 60 | return ''; 61 | } 62 | 63 | protected function checkNoSpaces(string $domain): string 64 | { 65 | if (trim($domain) !== $domain) { 66 | return 'the domain starts or ends with a space'; 67 | } 68 | 69 | return ''; 70 | } 71 | 72 | /** 73 | * @throws \SPFLib\Exception\InvalidDomainException 74 | */ 75 | protected function checkMinLabelsCount(string $domain): string 76 | { 77 | $labels = explode('.', trim($domain, '.')); 78 | $numLabels = count($labels); 79 | if ($numLabels < static::MIN_NUM_LABELS) { 80 | return "the domain has {$numLabels} labels (less than " . static::MIN_NUM_LABELS . ')'; 81 | } 82 | 83 | return ''; 84 | } 85 | 86 | protected function checkMaxLabelSize(string $domain): string 87 | { 88 | $labels = explode('.', $domain); 89 | foreach ($labels as $label) { 90 | $labelLength = strlen($label); 91 | if ($labelLength > static::MAX_LABEL_SIZE) { 92 | return 'the domain contains a label longer than ' . static::MAX_LABEL_SIZE . ' octects'; 93 | } 94 | } 95 | 96 | return ''; 97 | } 98 | 99 | protected function checkInvalidChars(string $domain): string 100 | { 101 | return ''; 102 | $matches = 0; 103 | if (!preg_match_all('/[%]/', $domain, $matches)) { 104 | return ''; 105 | } 106 | $invalidChars = array_values(array_unique($matches[0])); 107 | 108 | return isset($invalidChars[1]) ? 'the domain contains these invalid characters: "' . implode("', '", $invalidChars) . '"' : "the domain contains this invalid character: \"{$invalidChars[0]}\""; 109 | } 110 | 111 | protected function checkTopLabel(string $domain): string 112 | { 113 | $alternatives = [ 114 | '[a-z0-9]*[a-z][a-z0-9]*', 115 | '[a-z0-9]+-[a-z0-9\-]*[a-z0-9]', 116 | ]; 117 | $rx = '/(^|\.)((' . implode(')|(', $alternatives) . '))$/i'; 118 | if (preg_match($rx, $domain)) { 119 | return ''; 120 | } 121 | 122 | return 'the top label is not valid'; 123 | } 124 | 125 | protected function ensureMaxTotalLength(string $domain): string 126 | { 127 | while (strlen($domain) > static::MAX_TOTAL_LENGTH) { 128 | $domain = preg_replace('/^[^\.]*\.(.+)$/', '\1', $domain); 129 | } 130 | 131 | return $domain; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Check/Environment.php: -------------------------------------------------------------------------------- 1 | clientIP = null; 68 | } elseif ($clientIP instanceof AddressInterface) { 69 | $this->clientIP = $clientIP; 70 | } else { 71 | $address = Factory::addressFromString($clientIP); 72 | if ($address === null) { 73 | throw new InvalidIPAddressException($clientIP); 74 | } 75 | $this->clientIP = $address; 76 | } 77 | $this->heloDomain = $heloDomain; 78 | $this->mailFrom = $mailFrom; 79 | $this->checkerDomain = $checkerDomain; 80 | } 81 | 82 | /** 83 | * Get the IP address of the SMTP client that is emitting the email. 84 | */ 85 | public function getClientIP(): ?AddressInterface 86 | { 87 | return $this->clientIP; 88 | } 89 | 90 | /** 91 | * Get the domain name that was provided to the SMTP server via the HELO or EHLO SMTP verb. 92 | */ 93 | public function getHeloDomain(): string 94 | { 95 | return $this->heloDomain; 96 | } 97 | 98 | /** 99 | * Get the email address specified in the "MAIL FROM" MTA command. 100 | */ 101 | public function getMailFrom(): string 102 | { 103 | return $this->mailFrom; 104 | } 105 | 106 | /** 107 | * Get the domain after the '@' character of the email address specified in the "MAIL FROM" MTA command. 108 | */ 109 | public function getMailFromDomain(): string 110 | { 111 | $mailFrom = $this->getMailFrom(); 112 | $atPosition = strpos($mailFrom, '@'); 113 | 114 | return $atPosition === false ? '' : substr($mailFrom, $atPosition + 1); 115 | } 116 | 117 | /** 118 | * Get the name of the receiving MTA. 119 | */ 120 | public function getCheckerDomain(): string 121 | { 122 | return $this->checkerDomain; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Semantic/AbstractIssue.php: -------------------------------------------------------------------------------- 1 | code = $code; 116 | $this->description = $description; 117 | $this->level = $level; 118 | } 119 | 120 | /** 121 | * Get a textual representation of this issue. 122 | */ 123 | abstract public function __toString(): string; 124 | 125 | /** 126 | * Get the code of the issue (the value of one of the Issue::CODE_... constants). 127 | */ 128 | public function getCode(): int 129 | { 130 | return $this->code; 131 | } 132 | 133 | /** 134 | * Get the issue description. 135 | */ 136 | public function getDescription(): string 137 | { 138 | return $this->description; 139 | } 140 | 141 | /** 142 | * Get the issue level (the value of one of the Issue::LEVEL_... constants). 143 | */ 144 | public function getLevel(): int 145 | { 146 | return $this->level; 147 | } 148 | 149 | protected function getLevelDescription(): string 150 | { 151 | $level = $this->getLevel(); 152 | switch ($level) { 153 | case static::LEVEL_NOTICE: 154 | return 'notice'; 155 | case static::LEVEL_WARNING: 156 | return 'warning'; 157 | case static::LEVEL_FATAL: 158 | return 'fatal'; 159 | } 160 | 161 | return (string) $level; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Semantic/OnlineIssue.php: -------------------------------------------------------------------------------- 1 | domain = $domain; 94 | $this->txtRecord = $txtRecord; 95 | $this->record = $record; 96 | } 97 | 98 | /** 99 | * {@inheritdoc} 100 | * 101 | * @see \SPFLib\Semantic\AbstractIssue::__toString() 102 | */ 103 | public function __toString(): string 104 | { 105 | $parts = []; 106 | $level = $this->getLevelDescription(); 107 | if ($level !== '') { 108 | $parts[] = "[{$level}]"; 109 | } 110 | $domain = $this->getDomain(); 111 | if ($domain !== '') { 112 | $parts[] = "[domain: {$domain}]"; 113 | } 114 | $record = $this->getTxtRecord(); 115 | if ($record !== '') { 116 | $parts[] = "[record: {$record}]"; 117 | } 118 | $parts[] = $this->getDescription(); 119 | 120 | return implode(' ', $parts); 121 | } 122 | 123 | /** 124 | * Create a new instance starting from an offline issue. 125 | */ 126 | public static function fromOfflineIssue(Issue $offlineIssue, string $domain): self 127 | { 128 | return new self($domain, '', $offlineIssue->getRecord(), $offlineIssue->getCode(), $offlineIssue->getDescription(), $offlineIssue->getLevel()); 129 | } 130 | 131 | /** 132 | * Get the associated domain where the SPF record has been fetched from (empty string if not available). 133 | */ 134 | public function getDomain(): string 135 | { 136 | return $this->domain; 137 | } 138 | 139 | /** 140 | * Get the raw TXT record (empty string if not available). 141 | */ 142 | public function getTxtRecord(): string 143 | { 144 | return $this->txtRecord !== '' ? $this->txtRecord : (string) $this->getRecord(); 145 | } 146 | 147 | /** 148 | * Get the affected record (NULL if not available). 149 | */ 150 | public function getRecord(): ?Record 151 | { 152 | return $this->record; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Check/Result.php: -------------------------------------------------------------------------------- 1 | code = $code; 100 | $this->matchedMechanism = $matchedMechanism; 101 | } 102 | 103 | /** 104 | * Create a new instance. 105 | * 106 | * @param string $code the value of one of the Result::CODE_... constants 107 | * @param \SPFLib\Term\Mechanism|null $matchedMechanism the mechanism that matched (if applicable) 108 | * 109 | * @return static 110 | */ 111 | public static function create(string $code, ?Mechanism $matchedMechanism = null): self 112 | { 113 | return new static($code, $matchedMechanism); 114 | } 115 | 116 | /** 117 | * Get the result code of the check. 118 | * 119 | * @return string the value of one of the Result::CODE_... constants 120 | */ 121 | public function getCode(): string 122 | { 123 | return $this->code; 124 | } 125 | 126 | /** 127 | * Get the mechanism that matched (if applicable). 128 | * 129 | * @var \SPFLib\Term\Mechanism|null 130 | */ 131 | public function getMatchedMechanism(): ?Mechanism 132 | { 133 | return $this->matchedMechanism; 134 | } 135 | 136 | /** 137 | * Get a list of messages met during the check process. 138 | * 139 | * @return string[] 140 | */ 141 | public function getMessages(): array 142 | { 143 | return $this->messages; 144 | } 145 | 146 | /** 147 | * Add a message. 148 | * 149 | * @return $this 150 | */ 151 | public function addMessage(string $value): self 152 | { 153 | $this->messages[] = $value; 154 | 155 | return $this; 156 | } 157 | 158 | /** 159 | * Add multiple messages. 160 | * 161 | * @param string[] $value 162 | * 163 | * @return $this 164 | */ 165 | public function addMessages(array $value): self 166 | { 167 | foreach ($value as $message) { 168 | $this->addMessage($message); 169 | } 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Set the explanation for the "fail" case. 176 | * 177 | * @return $this 178 | */ 179 | public function setFailExplanation(string $value): self 180 | { 181 | $this->failExplanation = $value; 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * Get the explanation for the "fail" case. 188 | */ 189 | public function getFailExplanation(): string 190 | { 191 | return $this->failExplanation; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/DNS/StandardResolver.php: -------------------------------------------------------------------------------- 1 | callWithErrorHandler(function () use ($domain) { 28 | return dns_get_record($this->normalizeDomain($domain), DNS_TXT); 29 | }, $error); 30 | if ($records === false) { 31 | throw new DNSResolutionException($domain, "Failed to get the TXT records for {$domain}: {$error}"); 32 | } 33 | $result = []; 34 | foreach ($records as $record) { 35 | if (isset($record['txt'])) { 36 | $result[] = $record['txt']; 37 | } 38 | } 39 | 40 | return $result; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | * 46 | * @see \SPFLib\DNS\Resolver::getIPAddressesFromDomainName() 47 | */ 48 | public function getIPAddressesFromDomainName(string $domain): array 49 | { 50 | $error = 'Unknown error'; 51 | $records = $this->callWithErrorHandler(function () use ($domain) { 52 | return dns_get_record($this->normalizeDomain($domain), DNS_A | DNS_AAAA); 53 | }, $error); 54 | if ($records === false) { 55 | throw new DNSResolutionException($domain, "Failed to get the A/AAAA records for {$domain}: {$error}"); 56 | } 57 | $result = []; 58 | foreach ($records as $record) { 59 | $ip = Factory::addressFromString($record['type'] === 'A' ? $record['ip'] : $record['ipv6']); 60 | if ($ip !== null) { 61 | $result[] = $ip; 62 | } 63 | } 64 | 65 | return $result; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | * 71 | * @see \SPFLib\DNS\Resolver::getMXRecords() 72 | */ 73 | public function getMXRecords(string $domain): array 74 | { 75 | $error = 'Unknown error'; 76 | $records = $this->callWithErrorHandler(function () use ($domain) { 77 | return dns_get_record($this->normalizeDomain($domain), DNS_MX); 78 | }, $error); 79 | if ($records === false) { 80 | throw new DNSResolutionException($domain, "Failed to get the A/AAAA records for {$domain}: {$error}"); 81 | } 82 | $result = []; 83 | foreach ($records as $record) { 84 | if ($record['type'] === 'MX') { 85 | $result[] = $record['target']; 86 | } 87 | } 88 | 89 | return $result; 90 | } 91 | 92 | /** 93 | * {@inheritdoc} 94 | * 95 | * @see \SPFLib\DNS\Resolver::getPTRRecords() 96 | */ 97 | public function getPTRRecords(string $domain): array 98 | { 99 | $error = 'Unknown error'; 100 | $records = $this->callWithErrorHandler(static function () use ($domain) { 101 | return dns_get_record($domain, DNS_PTR); 102 | }, $error); 103 | if ($records === false) { 104 | throw new DNSResolutionException($domain, "Failed to get the PTR records for {$domain}: {$error}"); 105 | } 106 | $results = []; 107 | foreach ($records as $record) { 108 | $results[] = $record['target']; 109 | } 110 | 111 | return $results; 112 | } 113 | 114 | /** 115 | * {@inheritdoc} 116 | * 117 | * @see \SPFLib\DNS\Resolver::getDomainNameFromIPAddress() 118 | */ 119 | public function getDomainNameFromIPAddress(AddressInterface $ip): string 120 | { 121 | $result = $this->callWithErrorHandler(static function () use ($ip) { 122 | return gethostbyaddr((string) $ip); 123 | }); 124 | 125 | return is_string($result) && $result !== (string) $ip ? $result : ''; 126 | } 127 | 128 | /** 129 | * @return mixed the result of calling $closure 130 | */ 131 | protected function callWithErrorHandler(Closure $closure, string &$error = '') 132 | { 133 | set_error_handler( 134 | static function ($errno, $errstr) use (&$error): void { 135 | $error = (string) $errstr; 136 | if ($error === '') { 137 | $error = "Unknown error (code: {$errno})"; 138 | } 139 | }, 140 | -1 141 | ); 142 | try { 143 | $result = $closure(); 144 | } finally { 145 | restore_error_handler(); 146 | } 147 | 148 | return $result; 149 | } 150 | 151 | /** 152 | * @throws \SPFLib\Exception\DNSResolutionException 153 | */ 154 | protected function normalizeDomain(string $domain): string 155 | { 156 | try { 157 | $actualDomain = DomainName::fromName($domain)->getPunycode(); 158 | } catch (IDNAException $x) { 159 | $actualDomain = $domain; 160 | } 161 | if ($actualDomain === '' || trim($actualDomain) !== $actualDomain) { 162 | throw new DNSResolutionException($domain, "The domain '{$domain}' is not valid"); 163 | } 164 | 165 | return $actualDomain; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Macro/MacroString/Chunk/Placeholder.php: -------------------------------------------------------------------------------- 1 | macroLetter = $macroLetter; 134 | $this->numOutputParts = $numOutputParts; 135 | $this->reverse = $reverse; 136 | $this->delimiters = $delimiters; 137 | } 138 | 139 | /** 140 | * {@inheritdoc} 141 | * 142 | * @see \SPFLib\Macro\MacroString\Chunk::__toString() 143 | */ 144 | public function __toString(): string 145 | { 146 | return implode('', [ 147 | '%{', 148 | $this->getMacroLetter(), 149 | (string) ($this->getNumOutputParts() ?: ''), 150 | $this->isReverse() ? 'r' : '', 151 | $this->getDelimiters(), 152 | '}', 153 | ]); 154 | } 155 | 156 | /** 157 | * Get the placeholder identifier (the value of one of the Placeholder::ML_... constants, case-insensitive). 158 | */ 159 | public function getMacroLetter(): string 160 | { 161 | return $this->macroLetter; 162 | } 163 | 164 | /** 165 | * Get the number of parts of the output, after applying the environment values, splitting by $delimiters (NULL or greater than 0). 166 | */ 167 | public function getNumOutputParts(): ?int 168 | { 169 | return $this->numOutputParts; 170 | } 171 | 172 | /** 173 | * Should the output be reversed? 174 | */ 175 | public function isReverse(): bool 176 | { 177 | return $this->reverse; 178 | } 179 | 180 | /** 181 | * Get the character(s) to be used to split the output (if empty, we'll assume '.'). 182 | * It can zero characters or more of: '.' / '-' / '+' / ',' / '/' / '_' / '='. 183 | */ 184 | public function getDelimiters(): string 185 | { 186 | return $this->delimiters; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/Macro/MacroString/Decoder.php: -------------------------------------------------------------------------------- 1 | [' . preg_quote($this->getAllowedPlaceholderChars($flags), '/') . '])', 76 | '(?P\d*)', 77 | '(?Pr?)', 78 | '(?P[' . preg_quote($this->getAllowedDelimiters($flags), '/') . ']*)', 79 | '\}', 80 | ]); 81 | $matches = null; 82 | $currentLiteralString = ''; 83 | $minLiteralAscii = ($flags & static::FLAG_EXP) ? 0x20 : 0x21; 84 | for ($index = 0; $index < $length; $index++) { 85 | $char = $string[$index]; 86 | if ($char === '%') { 87 | $nextIndex = $index + 1; 88 | $nextChar = $string[$nextIndex] ?? ''; 89 | switch ($nextChar) { 90 | case '%': 91 | case '_': 92 | case '-': 93 | $currentLiteralString .= $char . $nextChar; 94 | $index++; 95 | continue 2; 96 | } 97 | if ($nextChar !== '{') { 98 | throw new Exception\InvalidMacroStringException($string, $index, "The macro-string '{$string}' contains a misplaced '%' character at position {$index}"); 99 | } 100 | $substr = substr($string, $nextIndex); 101 | if (!preg_match("/^{$placeholderRegex}/i", $substr, $matches)) { 102 | throw new Exception\InvalidMacroStringException($string, $index, "The macro-string '{$string}' contains an unrecognized macro-expand string at position {$index}"); 103 | } 104 | $numOutputParts = $matches['numOutputParts'] === '' ? null : (int) $matches['numOutputParts']; 105 | if ($numOutputParts === 0) { 106 | throw new Exception\InvalidMacroStringException($string, $index, "The macro-string '{$string}' contains a macro-expand string at position {$index} with an invalid number of output parts ({$matches['numOutputParts']})"); 107 | } 108 | if ($currentLiteralString !== '') { 109 | $result->addChunk(new Chunk\LiteralString($currentLiteralString)); 110 | $currentLiteralString = ''; 111 | } 112 | $result->addChunk(new Placeholder($matches['macroLetter'], $numOutputParts, $matches['reverse'] !== '', $matches['delimiters'])); 113 | $index += strlen($matches[0]); 114 | continue; 115 | } 116 | $ord = ord($char); 117 | if ($ord < $minLiteralAscii || $ord > 0x7E) { 118 | throw new Exception\InvalidMacroStringException($string, $index, "The macro-string '{$string}' contains an invalid character (ASCII code: {$ord}) at the position {$index}"); 119 | } 120 | $currentLiteralString .= $char; 121 | } 122 | if ($currentLiteralString !== '') { 123 | $result->addChunk(new Chunk\LiteralString($currentLiteralString)); 124 | } 125 | 126 | return $result; 127 | } 128 | 129 | public function getAllowedPlaceholderChars(int $flags): string 130 | { 131 | $list = implode('', [ 132 | Placeholder::ML_SENDER, 133 | Placeholder::ML_SENDER_LOCAL_PART, 134 | Placeholder::ML_SENDER_DOMAIN, 135 | Placeholder::ML_DOMAIN, 136 | Placeholder::ML_IP, 137 | Placeholder::ML_IP_VALIDATED_DOMAIN, 138 | Placeholder::ML_IP_TYPE, 139 | Placeholder::ML_HELO_DOMAIN, 140 | ]); 141 | if ($flags & static::FLAG_EXP) { 142 | $list .= implode('', [ 143 | Placeholder::ML_SMTP_CLIENT_IP, 144 | Placeholder::ML_CHECKER_DOMAIN, 145 | Placeholder::ML_CURRENT_TIMESTAMP, 146 | ]); 147 | } 148 | 149 | return $list; 150 | } 151 | 152 | public function getAllowedDelimiters(int $flags): string 153 | { 154 | return '.-+,/_='; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Macro/MacroString/Expander.php: -------------------------------------------------------------------------------- 1 | expandChunk($chunk, $currentDomain, $state); 30 | }, 31 | $macroString->getChunks() 32 | ) 33 | ); 34 | } 35 | 36 | /** 37 | * @throws \SPFLib\Exception\MissingEnvironmentValueException if $state is missing a value used a placeholder (if $chunk is a placeholder) 38 | * @throws \SPFLib\Exception\TooManyDNSLookupsException if too many DNS queries have been performed 39 | */ 40 | protected function expandChunk(Chunk $chunk, string $currentDomain, State $state): string 41 | { 42 | if ($chunk instanceof Chunk\LiteralString) { 43 | return $this->expandLiteralString($chunk, $currentDomain, $state); 44 | } 45 | if ($chunk instanceof Chunk\Placeholder) { 46 | return $this->expandPlaceholder($chunk, $currentDomain, $state); 47 | } 48 | } 49 | 50 | protected function expandLiteralString(Chunk\LiteralString $literalString, string $currentDomain, State $state): string 51 | { 52 | return strtr( 53 | (string) $literalString, 54 | [ 55 | '%%' => '%', 56 | '%_' => ' ', 57 | '%-' => '%20', 58 | ] 59 | ); 60 | } 61 | 62 | /** 63 | * @throws \SPFLib\Exception\MissingEnvironmentValueException if $state is missing a value used by the placeholder 64 | * @throws \SPFLib\Exception\TooManyDNSLookupsException if too many DNS queries have been performed 65 | */ 66 | protected function expandPlaceholder(Chunk\Placeholder $placeholder, string $currentDomain, State $state): string 67 | { 68 | return $this->transformPlaceholderValue( 69 | $placeholder, 70 | $this->getPlaceholderValue($placeholder->getMacroLetter(), $currentDomain, $state) 71 | ); 72 | } 73 | 74 | /** 75 | * @throws \SPFLib\Exception\MissingEnvironmentValueException if $state is missing a value used by the placeholder 76 | * @throws \SPFLib\Exception\TooManyDNSLookupsException if too many DNS queries have been performed 77 | */ 78 | protected function getPlaceholderValue(string $macroLetter, string $currentDomain, State $state): string 79 | { 80 | $value = ''; 81 | switch (strtolower($macroLetter)) { 82 | case Chunk\Placeholder::ML_SENDER: 83 | $value = $state->getSender(); 84 | break; 85 | case Chunk\Placeholder::ML_SENDER_LOCAL_PART: 86 | if ($state->getSender() === '') { 87 | throw new Exception\MissingEnvironmentValueException(Chunk\Placeholder::ML_SENDER); 88 | } 89 | $value = $state->getSenderLocalPart(); 90 | break; 91 | case Chunk\Placeholder::ML_SENDER_DOMAIN: 92 | if ($state->getSender() === '') { 93 | throw new Exception\MissingEnvironmentValueException(Chunk\Placeholder::ML_SENDER); 94 | } 95 | $value = $state->getSenderDomain(); 96 | break; 97 | case Chunk\Placeholder::ML_DOMAIN: 98 | $value = $currentDomain; 99 | break; 100 | case Chunk\Placeholder::ML_IP: 101 | $ip = $state->getEnvironment()->getClientIP(); 102 | if ($ip !== null) { 103 | if ($ip instanceof Address\IPv6) { 104 | $value = implode('.', str_split(str_replace(':', '', $ip->toString(true)), 1)); 105 | } else { 106 | $value = (string) $ip; 107 | } 108 | } 109 | break; 110 | case Chunk\Placeholder::ML_IP_VALIDATED_DOMAIN: 111 | $value = $state->getValidatedDomain($state->getSenderDomain(), true); 112 | if ($value === '') { 113 | $value = 'unknown'; 114 | } 115 | break; 116 | case Chunk\Placeholder::ML_IP_TYPE: 117 | $ip = $state->getEnvironment()->getClientIP(); 118 | if ($ip === null) { 119 | throw new Exception\MissingEnvironmentValueException(Chunk\Placeholder::ML_IP); 120 | } 121 | if ($ip instanceof Address\IPv4) { 122 | $value = 'in-addr'; 123 | } elseif ($ip instanceof Address\IPv6) { 124 | $value = 'ip6'; 125 | } 126 | break; 127 | case Chunk\Placeholder::ML_HELO_DOMAIN: 128 | $value = $state->getEnvironment()->getHeloDomain(); 129 | break; 130 | case Chunk\Placeholder::ML_SMTP_CLIENT_IP: 131 | $ip = $state->getEnvironment()->getClientIP(); 132 | if ($ip === null) { 133 | throw new Exception\MissingEnvironmentValueException(Chunk\Placeholder::ML_IP); 134 | } 135 | $value = (string) $ip; 136 | break; 137 | case Chunk\Placeholder::ML_CHECKER_DOMAIN: 138 | $value = $state->getEnvironment()->getCheckerDomain(); 139 | break; 140 | case Chunk\Placeholder::ML_CURRENT_TIMESTAMP: 141 | $value = (string) time(); 142 | break; 143 | } 144 | if ($value === '') { 145 | throw new Exception\MissingEnvironmentValueException($macroLetter); 146 | } 147 | if ($macroLetter === strtoupper($macroLetter)) { 148 | $value = $this->urlEncode($value); 149 | } 150 | 151 | return $value; 152 | } 153 | 154 | protected function transformPlaceholderValue(Chunk\Placeholder $placeholder, string $value): string 155 | { 156 | $numOutputParts = $placeholder->getNumOutputParts(); 157 | $reverse = $placeholder->isReverse(); 158 | $delimiters = $placeholder->getDelimiters(); 159 | if ($numOutputParts === null && $reverse === false && $delimiters === '') { 160 | return $value; 161 | } 162 | if ($delimiters === '') { 163 | $delimiters = '.'; 164 | } 165 | $parts = preg_split('/[' . preg_quote($delimiters, '/') . ']/', $value); 166 | if ($reverse) { 167 | $parts = array_reverse($parts, false); 168 | } 169 | if ($numOutputParts !== null) { 170 | $numParts = count($parts); 171 | $strip = $numParts - $numOutputParts; 172 | if ($strip > 0) { 173 | $parts = array_splice($parts, $strip, $numOutputParts); 174 | } 175 | } 176 | 177 | return implode('.', $parts); 178 | } 179 | 180 | /** 181 | * @see https://tools.ietf.org/html/rfc3986#section-2.3 182 | */ 183 | protected function urlEncode(string $value): string 184 | { 185 | return preg_replace_callback( 186 | '/[^\w\-\.~]/', 187 | static function (array $match): string { 188 | return '%' . substr('0' . strtolower(dechex(ord($match[0]))), -2); 189 | }, 190 | $value 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Checks](https://github.com/mlocati/spf-lib/actions/workflows/checks.yml/badge.svg)](https://github.com/mlocati/spf-lib/actions/workflows/checks.yml) 2 | [![Code Coverage](https://codecov.io/gh/mlocati/spf-lib/branch/main/graph/badge.svg)](https://codecov.io/gh/mlocati/spf-lib) 3 | 4 | 5 | # SPF (Sender Policy Framework) Library 6 | 7 | This PHP library allows you to: 8 | 9 | - get the SPF record from a domain name 10 | - decode and validate the SPF record 11 | - create the value of a TXT record 12 | - check if domains and IP addresses satisfy the SPF records 13 | 14 | The implementation is based on [RFC 7208](https://tools.ietf.org/html/rfc7208). 15 | 16 | AFAIK this is the only PHP library that passes the [Open SPF Test Suite for RFC 7208](http://www.open-spf.org/Test_Suite/). 17 | 18 | This library supports any PHP from PHP 7.1 to 8.3. 19 | 20 | ## Short introduction about SPF 21 | 22 | Here's a very simplified short description of the purpose of the SPF protocol. 23 | 24 | When an email client contacts an email server in order to delivery an email message, the email server has this information: 25 | 26 | 1. the IP address of the email client that is sending the email 27 | 2. the domain that the email client specified at the beginning of the SMTP delivery (after the `HELO`/`EHLO` SMTP command) 28 | 3. the sender email address (as specified in the `MAIL FROM` SMTP command) 29 | 30 | The email server can use the SPF protocol to determine if the client is allowed or not to send email addresses with the specified domains (the `HELO`/`EHLO` domain and/or the domain after the `@` in the `MAIL FROM` email address). 31 | 32 | This is done by querying the SPF DNS records of the domain(s) being checked, which can tell the server if the client is allowed/non-allowed/probably not allowed to send the email. 33 | 34 | You can use this PHP library to build, validate and check the SPF records. 35 | 36 | ## Installation 37 | 38 | You can install this library with Composer: 39 | 40 | ```sh 41 | composer require mlocati/spf-lib 42 | ``` 43 | 44 | ## Usage 45 | 46 | ### Validating domains and IP addresses 47 | 48 | Let's assume that the email client has the IP address `1.2.3.4`, specified `helo.domain` with the `HELO`/`EHLO` SMTP command, and specified `sender@domain.com` in the `MAIL FROM` email address. 49 | 50 | These data are represented by the `SPFLib\Check\Environment` class: you can create it with: 51 | 52 | ```php 53 | $environment = new \SPFLib\Check\Environment('1.2.3.4', 'helo.domain', 'sender@domain.com'); 54 | ``` 55 | 56 | To check the SPF records, you can use the `SPFLib\Checker` class: 57 | 58 | ```php 59 | $checker = new \SPFLib\Checker(); 60 | $checkResult = $checker->check($environment); 61 | ``` 62 | 63 | By default, the `check()` method checks both the `HELO`/`EHLO` and the `MAIL FROM` domains (if both are available and if they are different). 64 | You can check just one by specifying `\SPFLib\Checker::FLAG_CHECK_HELODOMAIN` or `\SPFLib\Checker::FLAG_CHECK_MAILFROADDRESS` as the second argument of the `check()` method. 65 | Otherwise you can specify an empty string in the related `Environment` constructor (for example: `new Environment($ip, $domain)` will check only the `HELO`/`EHLO` domain, `new Environment($ip, '', $mailFromAddress)` will check only the domain of the `MAIL FROM` email address). 66 | 67 | `$checkResult` is an instance of `SPFLib\Term\Mechanism\Result`, that provides: 68 | 69 | - the check result (`$checkResult->getCode()`), which is one of the [values specified in the RFC](https://tools.ietf.org/html/rfc7208#section-2.6). 70 | - the SPF mechanism that provided the result, if available (`$checkResult->getMatchedMechanism()`) 71 | - the failure description, if provided by the SPF records (`$checkResult->getFailExplanation()`) 72 | - optional relevant messages from the check process (`$checkResult->getMessages()`) 73 | 74 | So, the simplest example is: 75 | 76 | ```php 77 | use SPFLib\Checker; 78 | use SPFLib\Check\Environment; 79 | 80 | $checker = new Checker(); 81 | $checkResult = $checker->check(new Environment('127.0.0.1', 'gmail.com')); 82 | echo $checkResult->getCode(); 83 | ``` 84 | 85 | which outputs 86 | 87 | ``` 88 | softfail 89 | ``` 90 | 91 | 92 | ### Retrieving the SPF record from a domain name 93 | 94 | An SPF record is composed by zero or more terms. Every term can be a mechanism or a modifier. 95 | 96 | This library allows you to inspect them: 97 | 98 | ```php 99 | 100 | $decoder = new \SPFLib\Decoder(); 101 | try { 102 | $record = $decoder->getRecordFromDomain('example.com'); 103 | } catch (\SPFLib\Exception $x) { 104 | // Problems retrieving the SPF record from example.com, 105 | // or problems decoding it 106 | return; 107 | } 108 | if ($record === null) { 109 | // SPF record not found for example.com 110 | return; 111 | } 112 | // List all terms (that is, mechanisms and modifiers) 113 | foreach ($record->getTerms() as $term) { 114 | // do your stuff 115 | } 116 | // List all mechanisms 117 | foreach ($record->getMechanisms() as $mechanism) { 118 | // do your stuff 119 | } 120 | // List all modifiers 121 | foreach ($record->getModifiers() as $modifiers) { 122 | // do your stuff 123 | } 124 | ``` 125 | 126 | Please note that: 127 | 128 | - all [mechanisms](https://github.com/mlocati/spf-lib/tree/main/src/Term/Mechanism) extend the [`SPFLib\Term\Mechanism`](https://github.com/mlocati/spf-lib/blob/main/src/Term/Mechanism.php) abstract class. 129 | - all [modifiers](https://github.com/mlocati/spf-lib/tree/main/src/Term/Modifier) extend the [`SPFLib\Term\Modifier`](https://github.com/mlocati/spf-lib/blob/main/src/Term/Modifier.php) abstract class. 130 | - both mechanisms and modifiers implement the [`SPFLib\Term`](https://github.com/mlocati/spf-lib/blob/main/src/Term.php) interface. 131 | 132 | ### Decoding the SPF record from the value of a TXT DNS record 133 | 134 | ```php 135 | $txtRecord = 'v=spf1 mx a -all'; 136 | $decoder = new \SPFLib\Decoder(); 137 | try { 138 | $record = $decoder->getRecordFromTXT($txtRecord); 139 | } catch (\SPFLib\Exception $x) { 140 | // Problems decoding $txtRecord (it's malformed). 141 | return; 142 | } 143 | if ($record === null) { 144 | // $txtRecord is not an SPF record 145 | return; 146 | } 147 | ``` 148 | 149 | ### Creating the value of an SPF record 150 | 151 | ```php 152 | use SPFLib\Term\Mechanism; 153 | 154 | $record = new \SPFLib\Record('example.org'); 155 | $record 156 | ->addTerm(new Mechanism\MxMechanism(Mechanism::QUALIFIER_PASS)) 157 | ->addTerm(new Mechanism\IncludeMechanism(Mechanism::QUALIFIER_PASS, 'example.com')) 158 | ->addTerm(new Mechanism\AllMechanism(Mechanism::QUALIFIER_FAIL)) 159 | ; 160 | echo (string) $record; 161 | ``` 162 | 163 | Output: 164 | 165 | ``` 166 | v=spf1 mx include:example.com -all 167 | ``` 168 | 169 | ### Checking problems with an SPF record 170 | 171 | ```php 172 | $spf = 'v=spf1 all redirect=example1.org redirect=example2.org ptr:foo.bar mx include=example3.org exp=test.%{p}'; 173 | $record = (new \SPFLib\Decoder())->getRecordFromTXT($spf); 174 | $issues = (new \SPFLib\SemanticValidator())->validate($record); 175 | foreach ($issues as $issue) { 176 | echo (string) $issue, "\n"; 177 | } 178 | ``` 179 | 180 | Output: 181 | 182 | ``` 183 | [warning] 'all' should be the last mechanism (any other mechanism will be ignored) 184 | [warning] The 'redirect' modifier will be ignored since there's a 'all' mechanism 185 | [notice] The 'ptr' mechanism shouldn't be used because it's slow, resource intensive, and not very reliable 186 | [notice] The term 'exp=test.%{p}' contains the macro-letter 'p' that shouldn't be used because it's slow, resource intensive, and not very reliable 187 | [notice] The modifiers ('redirect=example1.org', 'redirect=example2.org') should be after all the mechanisms 188 | [fatal] The 'redirect' modifier is present more than once (2 times) 189 | [notice] The 'include=example3.org' modifier is unknown 190 | ``` 191 | 192 | Please note that every item in the array returned by the `validate` method is an instance of the [`SPFLib\Semantic\Issue`](https://github.com/mlocati/spf-lib/blob/main/src/Semantic/Issue.php) class. 193 | 194 | ### Checking problems with an SPF record in real world 195 | 196 | The `SemanticValidator` only looks for issues in an SPF record, without inspecting include (or redirected-to) records. 197 | 198 | In order to check an SPF record and all the referenced records you can use the `OnlineSemanticValidator`: 199 | 200 | ```php 201 | $validator = new \SPFLib\OnlineSemanticValidator(); 202 | // Check an online domain 203 | $issues = $validator->validateDomain('example.org'); 204 | // Check a raw SPF record 205 | $issues = $validator->validateRawRecord('v=spf1 include:_sfp.example.org -all'); 206 | // Check an SPFLib\Record instance ($record in this case) 207 | $issues = $validator->validateRecord($record); 208 | ``` 209 | 210 | The result of these methods are arrays of `SPFLib\Semantic\OnlineIssue` instances, which are very similar to the `SPFLib\Semantic\Issue` instances returned by the offline `SemanticValidator`. 211 | 212 | ### Dealing with too many DNS lookups 213 | 214 | When the validation returns an issue of type `SPFLib\Semantic\OnlineIssue\TooManyDNSLookups`, you can get more details from the instance: 215 | 216 | ```php 217 | // Get the total amount of DNS lookups referenced in the SPF record 218 | $totalDnsLookups = $issue->getTotalLookupCount(); 219 | // Get a recursive list of referenced DNS lookups 220 | $lookups = $issue->getDnsLookups(); 221 | ``` 222 | 223 | You can also explicitly retrieve a list of DNS lookups for a SPF record by calling the relevant methods of `OnlineSemanticValidator`: 224 | 225 | ```php 226 | $validator = new \SPFLib\OnlineSemanticValidator(); 227 | // Get the DNS lookups for an online domain 228 | $lookups = $validator->getLookupsForDomain('example.org'); 229 | // Get the DNS lookups for a raw SPF record 230 | $lookups = $validator->getLookupsForRawRecord('v=spf1 include:_sfp.example.org -all'); 231 | // Get the DNS lookups for an SPFLib\Record instance ($record in this case) 232 | $lookups = $validator->getLookupsForRecord($record); 233 | ``` 234 | 235 | ## Do you want to really say thank you? 236 | 237 | You can offer me a [monthly coffee](https://github.com/sponsors/mlocati) or a [one-time coffee](https://paypal.me/mlocati) :wink: 238 | -------------------------------------------------------------------------------- /src/SemanticValidator.php: -------------------------------------------------------------------------------- 1 | checkMaxDNSLookups($record), 55 | $this->checkAllIsLastMechanism($record), 56 | $this->checkAllAndRedirect($record), 57 | $this->checkNoPtr($record), 58 | $this->checkNoValidatedDomain($record), 59 | $this->checkModifiersPosition($record), 60 | $this->checkModifiersUniqueness($record), 61 | $this->checkUnknownModifiers($record) 62 | ); 63 | if ($minimumLevel !== null) { 64 | $issues = array_values( 65 | array_filter( 66 | $issues, 67 | static function (Issue $issue) use ($minimumLevel): bool { 68 | return $issue->getLevel() >= $minimumLevel; 69 | } 70 | ) 71 | ); 72 | } 73 | 74 | return $issues; 75 | } 76 | 77 | /** 78 | * Calculate the number of DNS lookups caused by a record, without counting the lookups in the included/redirected-to domains. 79 | */ 80 | public function getDirectDNSLookups(Record $record): int 81 | { 82 | $count = 0; 83 | foreach ($record->getMechanisms() as $mechanism) { 84 | if (in_array($mechanism->getName(), static::MECHANISMS_INVOLVING_DNS_LOOKUPS, true)) { 85 | $count++; 86 | } 87 | } 88 | foreach ($record->getModifiers() as $modifier) { 89 | if (in_array($modifier->getName(), static::MODIFIERS_INVOLVING_DNS_LOOKUPS, true)) { 90 | $count++; 91 | } 92 | } 93 | 94 | return $count; 95 | } 96 | 97 | /** 98 | * @return \SPFLib\Semantic\Issue[] 99 | * 100 | * @see https://tools.ietf.org/html/rfc7208#section-4.6.4 101 | */ 102 | protected function checkMaxDNSLookups(Record $record): array 103 | { 104 | $count = $this->getDirectDNSLookups($record); 105 | $maxQueries = State::MAX_DNS_LOOKUPS; 106 | if ($count <= $maxQueries) { 107 | return []; 108 | } 109 | 110 | return [ 111 | new Issue( 112 | $record, 113 | Issue::CODE_TOO_MANY_DNS_LOOKUPS, 114 | "The total number of the '" . implode("', '", static::MECHANISMS_INVOLVING_DNS_LOOKUPS) . "' mechanisms and the '" . implode("', '", static::MODIFIERS_INVOLVING_DNS_LOOKUPS) . "' modifiers is {$count} (it should not exceed {$maxQueries})", 115 | Issue::LEVEL_WARNING 116 | ), 117 | ]; 118 | } 119 | 120 | /** 121 | * @return \SPFLib\Semantic\Issue[] 122 | * 123 | * @see https://tools.ietf.org/html/rfc7208#section-5.1 124 | */ 125 | protected function checkAllIsLastMechanism(Record $record): array 126 | { 127 | $mechanisms = $record->getMechanisms(); 128 | $count = count($mechanisms); 129 | for ($i = 0; $i < $count - 1; $i++) { 130 | if ($mechanisms[$i] instanceof Mechanism\AllMechanism) { 131 | return [ 132 | new Issue( 133 | $record, 134 | Issue::CODE_ALL_NOT_LAST_MECHANISM, 135 | "'" . Mechanism\AllMechanism::HANDLE . "' should be the last mechanism (any other mechanism will be ignored)", 136 | Issue::LEVEL_WARNING 137 | ), 138 | ]; 139 | } 140 | } 141 | 142 | return []; 143 | } 144 | 145 | /** 146 | * @return \SPFLib\Semantic\Issue[] 147 | * 148 | * @see https://tools.ietf.org/html/rfc7208#section-5.1 149 | */ 150 | protected function checkAllAndRedirect(Record $record): array 151 | { 152 | $all = array_filter( 153 | $record->getMechanisms(), 154 | static function (Mechanism $mechanism): bool { 155 | return $mechanism instanceof Mechanism\AllMechanism; 156 | } 157 | ); 158 | $redirect = array_filter( 159 | $record->getModifiers(), 160 | static function (Modifier $modifier): bool { 161 | return $modifier instanceof Modifier\RedirectModifier; 162 | } 163 | ); 164 | if ($all !== [] && $redirect !== []) { 165 | return [ 166 | new Issue( 167 | $record, 168 | Issue::CODE_ALL_AND_REDIRECT, 169 | "The '" . Modifier\RedirectModifier::HANDLE . "' modifier will be ignored since there's a '" . Mechanism\AllMechanism::HANDLE . "' mechanism", 170 | Issue::LEVEL_WARNING 171 | ), 172 | ]; 173 | } 174 | 175 | return []; 176 | } 177 | 178 | /** 179 | * @return \SPFLib\Semantic\Issue[] 180 | * 181 | * @see https://tools.ietf.org/html/rfc7208#section-5.5 182 | */ 183 | protected function checkNoPtr(Record $record): array 184 | { 185 | foreach ($record->getMechanisms() as $mechanism) { 186 | if ($mechanism instanceof Mechanism\PtrMechanism) { 187 | return [ 188 | new Issue( 189 | $record, 190 | Issue::CODE_SHOULD_AVOID_PTR, 191 | "The '" . Mechanism\PtrMechanism::HANDLE . "' mechanism shouldn't be used because it's slow, resource intensive, and not very reliable", 192 | Issue::LEVEL_NOTICE 193 | ), 194 | ]; 195 | } 196 | } 197 | 198 | return []; 199 | } 200 | 201 | protected function checkNoValidatedDomain(Record $record): array 202 | { 203 | $result = []; 204 | foreach ($record->getTerms() as $term) { 205 | if ($term instanceof TermWithDomainSpec) { 206 | $domainSpec = $term->getDomainSpec(); 207 | foreach ($domainSpec->getChunks() as $chunk) { 208 | if ($chunk instanceof Chunk\Placeholder) { 209 | if (strtolower($chunk->getMacroLetter()) === Chunk\Placeholder::ML_IP_VALIDATED_DOMAIN) { 210 | $result[] = new Issue( 211 | $record, 212 | Issue::CODE_SHOULD_AVOID_VALIDATED_DOMAIN_MACRO, 213 | "The term '{$term}' contains the macro-letter '{$chunk->getMacroLetter()}' that shouldn't be used because it's slow, resource intensive, and not very reliable", 214 | Issue::LEVEL_NOTICE 215 | ); 216 | } 217 | } 218 | } 219 | } 220 | } 221 | 222 | return $result; 223 | } 224 | 225 | /** 226 | * @return \SPFLib\Semantic\Issue[] 227 | * 228 | * @see https://tools.ietf.org/html/rfc7208#section-6 229 | */ 230 | protected function checkModifiersPosition(Record $record): array 231 | { 232 | $mechanismFound = false; 233 | $misplacedModifiers = []; 234 | foreach (array_reverse($record->getTerms()) as $term) { 235 | if ($term instanceof Mechanism) { 236 | $mechanismFound = true; 237 | } elseif ($mechanismFound && $term instanceof Modifier) { 238 | switch ($term->getName()) { 239 | case Modifier\RedirectModifier::HANDLE: 240 | case Modifier\ExpModifier::HANDLE: 241 | $misplacedModifiers[] = (string) $term; 242 | break; 243 | } 244 | } 245 | } 246 | if ($misplacedModifiers !== []) { 247 | return [ 248 | new Issue( 249 | $record, 250 | Issue::CODE_MODIFIER_NOT_AFTER_MECHANISMS, 251 | "The modifiers ('" . implode("', '", array_reverse($misplacedModifiers)) . "') should be after all the mechanisms", 252 | Issue::LEVEL_NOTICE 253 | ), 254 | ]; 255 | } 256 | 257 | return []; 258 | } 259 | 260 | /** 261 | * @return \SPFLib\Semantic\Issue[] 262 | * 263 | * @see https://tools.ietf.org/html/rfc7208#section-6 264 | */ 265 | protected function checkModifiersUniqueness(Record $record): array 266 | { 267 | $counters = [ 268 | Modifier\RedirectModifier::HANDLE => 0, 269 | Modifier\ExpModifier::HANDLE => 0, 270 | ]; 271 | foreach ($record->getModifiers() as $modifier) { 272 | $name = $modifier->getName(); 273 | if (!isset($counters[$name])) { 274 | continue; 275 | } 276 | $counters[$name]++; 277 | } 278 | $result = []; 279 | foreach ($counters as $name => $count) { 280 | if ($count > 1) { 281 | $result[] = new Issue( 282 | $record, 283 | Issue::CODE_DUPLICATED_MODIFIER, 284 | "The '{$name}' modifier is present more than once ({$count} times)", 285 | Issue::LEVEL_FATAL 286 | ); 287 | } 288 | } 289 | 290 | return $result; 291 | } 292 | 293 | protected function checkUnknownModifiers(Record $record): array 294 | { 295 | $result = []; 296 | foreach ($record->getModifiers() as $modifier) { 297 | if ($modifier instanceof Modifier\UnknownModifier) { 298 | $result[] = new Issue( 299 | $record, 300 | Issue::CODE_UNKNOWN_MODIFIER, 301 | "The '{$modifier}' modifier is unknown", 302 | Issue::LEVEL_NOTICE 303 | ); 304 | } 305 | } 306 | 307 | return $result; 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/Check/State.php: -------------------------------------------------------------------------------- 1 | environment = $environment; 95 | $this->resolver = $resolver; 96 | $this->resetDNSQueryCounters(); 97 | } 98 | 99 | /** 100 | * Get the environment being checked. 101 | */ 102 | public function getEnvironment(): Environment 103 | { 104 | return $this->environment; 105 | } 106 | 107 | /** 108 | * Get the sender email address currently being checked. 109 | */ 110 | abstract public function getSender(): string; 111 | 112 | /** 113 | * Get the local part of the sender email address currently being checked (that is, the part before '@'). 114 | */ 115 | public function getSenderLocalPart(): string 116 | { 117 | $sender = $this->getSender(); 118 | $p = strpos($sender, '@'); 119 | if ($p === false) { 120 | return ''; 121 | } 122 | 123 | return substr($sender, 0, $p); 124 | } 125 | 126 | /** 127 | * Get the domain of the sender email address currently being checked (that is, the part after '@'). 128 | */ 129 | public function getSenderDomain(): string 130 | { 131 | $sender = $this->getSender(); 132 | $p = strpos($sender, '@'); 133 | if ($p === false) { 134 | return ''; 135 | } 136 | 137 | return substr($sender, $p + 1); 138 | } 139 | 140 | /** 141 | * Get the domain name derived from the reverse lookup of the SMTP client IP. 142 | * 143 | * @throws \SPFLib\Exception\TooManyDNSLookupsException if too many DNS queries have been performed 144 | */ 145 | public function getClientIPDomain(): string 146 | { 147 | $ip = $this->getEnvironment()->getClientIP(); 148 | if ($ip === null) { 149 | return ''; 150 | } 151 | $key = (string) $ip; 152 | if (!isset($this->reverseLookups[$key])) { 153 | $this->countDNSLookup(); 154 | $domainName = $this->getDNSResolver()->getDomainNameFromIPAddress($ip); 155 | $this->reverseLookups[$key] = $domainName; 156 | } 157 | 158 | return $this->reverseLookups[$key]; 159 | } 160 | 161 | /** 162 | * Reset the number of DNS queries already performed. 163 | * 164 | * @return $this 165 | */ 166 | public function resetDNSQueryCounters(): self 167 | { 168 | $this->dnsLookupsCount = 0; 169 | $this->voidIPLookupsCount = 0; 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Count a DNS lookup and, if we are over the limit, throw a TooManyDNSLookupsException exception. 176 | * 177 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 178 | */ 179 | public function countDNSLookup(int $number = 1): void 180 | { 181 | $this->dnsLookupsCount += $number; 182 | if ($this->dnsLookupsCount > static::MAX_DNS_LOOKUPS) { 183 | throw new Exception\TooManyDNSLookupsException(static::MAX_DNS_LOOKUPS); 184 | } 185 | } 186 | 187 | /** 188 | * Count a DNS IP lookup that returned zero addresses. 189 | * 190 | * @throws \SPFLib\Exception\TooManyDNSVoidLookupsException 191 | */ 192 | public function countVoidIPLookupsCount(int $number = 1): void 193 | { 194 | $this->voidIPLookupsCount += $number; 195 | if ($this->voidIPLookupsCount > static::MAX_VOID_DNS_LOOKUPS) { 196 | throw new Exception\TooManyDNSVoidLookupsException(static::MAX_VOID_DNS_LOOKUPS); 197 | } 198 | } 199 | 200 | public function getValidatedDomain(string $targetDomain, bool $allowSubdomain): string 201 | { 202 | $pointers = $this->getPTRPointers(); 203 | $targetDomainChunks = explode('.', trim($targetDomain, '.')); 204 | while ($allowSubdomain === false || isset($targetDomainChunks[1])) { 205 | $search = '.' . implode('.', $targetDomainChunks); 206 | foreach ($pointers as $pointer) { 207 | $pointerAddresses = $this->getDNSResolver()->getIPAddressesFromDomainName($pointer); 208 | foreach ($pointerAddresses as $pointerAddress) { 209 | if ($this->matchIP($pointerAddress, 32, 128)) { 210 | $compare = '.' . ltrim($pointer, '.'); 211 | if (strcasecmp($search, substr($compare, -strlen($search))) === 0) { 212 | return $pointer; 213 | } 214 | } 215 | } 216 | } 217 | if ($allowSubdomain === false) { 218 | break; 219 | } 220 | array_shift($targetDomainChunks); 221 | } 222 | 223 | return ''; 224 | } 225 | 226 | /** 227 | * @throws \SPFLib\Exception\DNSResolutionException 228 | * @throws \SPFLib\Exception\TooManyDNSVoidLookupsException 229 | */ 230 | public function matchDomainIPs(string $domain, ?int $ipv4CidrLength, ?int $ipv6CidrLength): bool 231 | { 232 | $ips = $this->getDNSResolver()->getIPAddressesFromDomainName($domain); 233 | if ($ips === []) { 234 | $this->countVoidIPLookupsCount(); 235 | } else { 236 | foreach ($ips as $ip) { 237 | if ($this->matchIP($ip, $ipv4CidrLength, $ipv6CidrLength)) { 238 | return true; 239 | } 240 | } 241 | } 242 | 243 | return false; 244 | } 245 | 246 | public function matchIP(AddressInterface $check, ?int $ipv4CidrLength, ?int $ipv6CidrLength): bool 247 | { 248 | $clientIP = $this->getEnvironment()->getClientIP(); 249 | if ($ipv4CidrLength === 0) { 250 | if ($clientIP instanceof Address\IPv4 && $check instanceof Address\IPv4) { 251 | return true; 252 | } 253 | } elseif ($ipv4CidrLength !== null) { 254 | $clientIPv4 = $clientIP instanceof Address\IPv6 ? $clientIP->toIPv4() : $clientIP; 255 | if ($clientIPv4 instanceof Address\IPv4) { 256 | $checkIPv4 = $check instanceof Address\IPv6 ? $check->toIPv4() : $check; 257 | if ($checkIPv4 instanceof Address\IPv4) { 258 | $range = Subnet::fromString("{$checkIPv4}/{$ipv4CidrLength}"); 259 | if ($range !== null && $range->contains($clientIPv4)) { 260 | return true; 261 | } 262 | } 263 | } 264 | } 265 | if ($ipv6CidrLength === 0) { 266 | if ($clientIP instanceof Address\IPv6 && $check instanceof Address\IPv6) { 267 | return strpos((string) $clientIP, '.') === false; 268 | } 269 | } elseif ($ipv6CidrLength !== null) { 270 | $clientIPv6 = $clientIP instanceof Address\IPv4 ? $clientIP->toIPv6() : $clientIP; 271 | if ($clientIPv6 instanceof Address\IPv6) { 272 | $checkIPv4 = $check instanceof Address\IPv4 ? $check->toIPv6() : $check; 273 | if ($clientIPv6 instanceof Address\IPv6) { 274 | $range = Subnet::fromString("{$checkIPv4}/{$ipv6CidrLength}"); 275 | if ($range !== null && $range->contains($clientIPv6)) { 276 | return true; 277 | } 278 | } 279 | } 280 | } 281 | 282 | return false; 283 | } 284 | 285 | /** 286 | * @deprecated since version 3.1.2 287 | * @see \SPFLib\Check\State::getEnvironment() 288 | */ 289 | public function getEnvoronment(): Environment 290 | { 291 | return $this->getEnvironment(); 292 | } 293 | 294 | /** 295 | * Get the DNS resolver instance to be used for queries. 296 | */ 297 | protected function getDNSResolver(): Resolver 298 | { 299 | return $this->resolver; 300 | } 301 | 302 | protected function getPTRPointers(): array 303 | { 304 | if ($this->ptrPointers === null) { 305 | $this->countDNSLookup(); 306 | $pointers = $this->getDNSResolver()->getPTRRecords($this->buildPTRQuery()); 307 | array_splice($pointers, static::MAX_DNS_LOOKUPS); 308 | $this->ptrPointers = $pointers; 309 | } 310 | 311 | return $this->ptrPointers; 312 | } 313 | 314 | protected function buildPTRQuery(): string 315 | { 316 | $ip = $this->getEnvironment()->getClientIP(); 317 | if ($ip instanceof Address\IPv4) { 318 | return implode( 319 | '.', 320 | array_reverse($ip->getBytes()) 321 | ) . '.in-addr.arpa'; 322 | } 323 | if ($ip instanceof Address\IPv6) { 324 | return implode( 325 | '.', 326 | array_reverse(str_split(str_replace(':', '', $ip->toString(true)), 1)) 327 | ) . '.ip6.arpa'; 328 | } 329 | 330 | throw new Exception\MissingEnvironmentValueException(Placeholder::ML_IP); 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/OnlineSemanticValidator.php: -------------------------------------------------------------------------------- 1 | decoder = $decoder ?: new Decoder(); 31 | $this->semanticValidator = $semanticValidator ?: new SemanticValidator(); 32 | } 33 | 34 | /** 35 | * Get all the semantical warnings of the SPF record of a domain, parsing also all the included/redirected-to records. 36 | * 37 | * @param string $domain the domain to be checked 38 | * @param int|null $minimumLevel the minimum level of the issues (the value of one of the OnlineIssue::LEVEL_... constants) 39 | * 40 | * @return \SPFLib\Semantic\OnlineIssue[] The warnings 41 | */ 42 | public function validateDomain(string $domain, ?int $minimumLevel = null): array 43 | { 44 | return $this->filterLevel($this->validate($domain, null), $minimumLevel); 45 | } 46 | 47 | /** 48 | * Get all the semantical warnings of a raw SPF record, parsing also all the included/redirected-to records. 49 | * 50 | * @param string $txtRecord the raw SPF record to be checked 51 | * @param string $domain the domain owning the $txtRecord SFP record 52 | * @param int|null $minimumLevel the minimum level of the issues (the value of one of the OnlineIssue::LEVEL_... constants) 53 | * 54 | * @return \SPFLib\Semantic\OnlineIssue[] The warnings 55 | */ 56 | public function validateRawRecord(string $txtRecord, string $domain = '', ?int $minimumLevel = null): array 57 | { 58 | $issues = null; 59 | try { 60 | $record = $this->getDecoder()->getRecordFromTXT($txtRecord); 61 | } catch (Exception $x) { 62 | $issues = [new OnlineIssue($domain, $txtRecord, null, OnlineIssue::CODE_RECORD_PARSE_FAILED, $x->getMessage(), OnlineIssue::LEVEL_FATAL)]; 63 | } 64 | if ($issues === null) { 65 | if ($record === null) { 66 | $issues = [new OnlineIssue($domain, $txtRecord, null, OnlineIssue::CODE_RECORD_PARSE_FAILED, "'{$txtRecord}' is not a valid SPF record", OnlineIssue::LEVEL_FATAL)]; 67 | } else { 68 | $issues = $this->validate($domain, $record); 69 | } 70 | } 71 | 72 | return $this->filterLevel($issues, $minimumLevel); 73 | } 74 | 75 | /** 76 | * Get all the semantical warnings of a parsed SPF record, parsing also all the included/redirected-to records. 77 | * 78 | * @param \SPFLib\Record $record the record to be checked 79 | * @param string $domain the domain owning the $record SFP record 80 | * @param int|null $minimumLevel the minimum level of the issues (the value of one of the OnlineIssue::LEVEL_... constants) 81 | * 82 | * @return \SPFLib\Semantic\OnlineIssue[] The warnings 83 | */ 84 | public function validateRecord(Record $record, string $domain = '', ?int $minimumLevel = null): array 85 | { 86 | return $this->filterLevel($this->validate($domain, $record), $minimumLevel); 87 | } 88 | 89 | /** 90 | * Get all the DNS lookups involved in the SPF record of a domain. 91 | * 92 | * @param string $domain the domain to be checked 93 | * 94 | * @return \SPFLib\OnlineDnsLookup[] 95 | */ 96 | public function getLookupsForDomain(string $domain): array 97 | { 98 | return $this->getLookupsForRecord(null, $domain); 99 | } 100 | 101 | /** 102 | * Get all the DNS lookups involved in a raw SPF record. 103 | * 104 | * @param string $txtRecord the raw SPF record to be checked 105 | * @param string $domain the domain owning the $txtRecord SFP record 106 | * 107 | * @return \SPFLib\OnlineDnsLookup[] 108 | */ 109 | public function getLookupsForRawRecord(string $txtRecord, string $domain = ''): array 110 | { 111 | try { 112 | $record = $this->getDecoder()->getRecordFromTXT($txtRecord); 113 | } catch (Exception $x) { 114 | return []; 115 | } 116 | if ($record) { 117 | return $this->getLookupsForRecord($record, $domain); 118 | } 119 | 120 | return []; 121 | } 122 | 123 | /** 124 | * Get all the DNS lookups involved in a parsed SPF record. 125 | * 126 | * @param \SPFLib\Record|null $record the record to be checked 127 | * @param string $domain the domain owning the $record SFP record 128 | * 129 | * @return \SPFLib\OnlineDnsLookup[] 130 | */ 131 | public function getLookupsForRecord(?Record $record, string $domain = ''): array 132 | { 133 | $state = []; 134 | $this->validate($domain, $record, $state); 135 | if (isset($state['subRecordsDNSLookups'])) { 136 | return $state['subRecordsDNSLookups']; 137 | } 138 | 139 | return []; 140 | } 141 | 142 | protected function validate(string $domain, ?Record $record, ?array &$state = null): array 143 | { 144 | if ($state === null) { 145 | $isTopLevel = true; 146 | $state = []; 147 | } else { 148 | $isTopLevel = false; 149 | } 150 | if (!isset($state['subRecordsDNSLookupCount'])) { 151 | $state['subRecordsDNSLookupCount'] = 0; 152 | } 153 | if (!isset($state['dnsLookupCount'])) { 154 | $state['dnsLookupCount'] = 0; 155 | } 156 | if (!isset($state['parentParsedDomains'])) { 157 | $state['parentParsedDomains'] = []; 158 | } 159 | $state['record'] = $record; 160 | $state['subRecordsDNSLookups'] = []; 161 | if ($record === null) { 162 | if ($domain === '') { 163 | return [new OnlineIssue($domain, '', null, OnlineIssue::CODE_NODOMAIN_NORECORD_PROVIDED, 'Neither a domain nor an SPF record has been provided.', OnlineIssue::LEVEL_FATAL)]; 164 | } 165 | if (in_array($domain, $state['parentParsedDomains'], true)) { 166 | return [new OnlineIssue($domain, '', null, OnlineIssue::CODE_RECURSIVE_DOMAIN_DETECTED, "The domain {$domain} is included/redirected-to recursively", OnlineIssue::LEVEL_FATAL)]; 167 | } 168 | try { 169 | $record = $this->getDecoder()->getRecordFromDomain($domain); 170 | } catch (Exception $x) { 171 | return [new OnlineIssue($domain, '', null, OnlineIssue::CODE_RECORD_FETCH_OR_PARSE_FAILED, $x->getMessage(), OnlineIssue::LEVEL_FATAL)]; 172 | } 173 | if ($record === null) { 174 | return [new OnlineIssue($domain, '', null, OnlineIssue::CODE_RECORD_NOT_FOUND, "No SPF records found for domain {$domain}", OnlineIssue::LEVEL_FATAL)]; 175 | } 176 | } 177 | $subRecordsDNSLookups = []; 178 | $parentParsedDomains = $state['parentParsedDomains']; 179 | if ($domain !== '') { 180 | $state['parentParsedDomains'][] = $domain; 181 | } 182 | $result = []; 183 | foreach ($this->getSemanticValidator()->validate($record, null) as $offlineIssue) { 184 | $result[] = OnlineIssue::fromOfflineIssue($offlineIssue, $domain); 185 | } 186 | 187 | $thisDNSLookupCount = 0; 188 | foreach ($record->getMechanisms() as $mechanism) { 189 | if (in_array($mechanism->getName(), SemanticValidator::MECHANISMS_INVOLVING_DNS_LOOKUPS, true)) { 190 | $thisDNSLookupCount++; 191 | if ($mechanism instanceof Mechanism\IncludeMechanism) { 192 | if ($mechanism->getDomainSpec()->containsPlaceholders()) { 193 | $result[] = new OnlineIssue($domain, '', $record, OnlineIssue::CODE_DOMAIN_WITH_PLACEHOLDER, "The mechanism {$mechanism} includes a placeholder: its SPF record has not been parsed.", OnlineIssue::LEVEL_NOTICE); 194 | $dnsLookup = null; 195 | } else { 196 | $result = array_merge($result, $this->validate((string) $mechanism->getDomainSpec(), null, $state)); 197 | $lookupRecord = isset($state['record']) ? (string) $state['record'] : null; 198 | $dnsLookup = new OnlineDnsLookup((string) $mechanism, $lookupRecord); 199 | foreach ($state['subRecordsDNSLookups'] as $subRecordsDNSLookup) { 200 | $dnsLookup->addReference($subRecordsDNSLookup); 201 | } 202 | } 203 | } else { 204 | $dnsLookup = new OnlineDnsLookup((string) $mechanism); 205 | } 206 | if ($dnsLookup) { 207 | $subRecordsDNSLookups[] = $dnsLookup; 208 | } 209 | } 210 | } 211 | foreach ($record->getModifiers() as $modifier) { 212 | if (in_array($modifier->getName(), SemanticValidator::MODIFIERS_INVOLVING_DNS_LOOKUPS, true)) { 213 | $thisDNSLookupCount++; 214 | if ($modifier instanceof Modifier\RedirectModifier) { 215 | if ($modifier->getDomainSpec()->containsPlaceholders()) { 216 | $result[] = new OnlineIssue($domain, '', $record, OnlineIssue::CODE_DOMAIN_WITH_PLACEHOLDER, "The modifier {$modifier} includes a placeholder: its SPF record has not been parsed.", OnlineIssue::LEVEL_NOTICE); 217 | $dnsLookup = null; 218 | } else { 219 | $result = array_merge($result, $this->validate((string) $modifier->getDomainSpec(), null, $state)); 220 | $lookupRecord = isset($state['record']) ? (string) $state['record'] : null; 221 | $dnsLookup = new OnlineDnsLookup((string) $modifier, $lookupRecord); 222 | foreach ($state['subRecordsDNSLookups'] as $subRecordsDNSLookup) { 223 | $dnsLookup->addReference($subRecordsDNSLookup); 224 | } 225 | } 226 | } else { 227 | $dnsLookup = new OnlineDnsLookup((string) $modifier); 228 | } 229 | if ($dnsLookup) { 230 | $subRecordsDNSLookups[] = $dnsLookup; 231 | } 232 | } 233 | } 234 | $state['parentParsedDomains'] = $parentParsedDomains; 235 | $state['subRecordsDNSLookupCount'] += $thisDNSLookupCount; 236 | $state['dnsLookupCount'] = $thisDNSLookupCount; 237 | $state['subRecordsDNSLookups'] = $subRecordsDNSLookups; 238 | $state['record'] = $record; 239 | 240 | if ($isTopLevel) { 241 | $totalDNSLookupCount = $state['subRecordsDNSLookupCount']; 242 | $maxQueries = State::MAX_DNS_LOOKUPS; 243 | if ($totalDNSLookupCount > $maxQueries) { 244 | $result[] = new TooManyDNSLookups( 245 | $state['subRecordsDNSLookups'], 246 | $domain, 247 | '', 248 | $record, 249 | OnlineIssue::CODE_TOO_MANY_DNS_LOOKUPS_ONLINE, 250 | "The total number of the '" . implode("', '", $this->getSemanticValidator()::MECHANISMS_INVOLVING_DNS_LOOKUPS) . "' mechanisms and the '" . implode("', '", $this->getSemanticValidator()::MODIFIERS_INVOLVING_DNS_LOOKUPS) . "' modifiers is {$totalDNSLookupCount} (it should not exceed {$maxQueries})", 251 | OnlineIssue::LEVEL_WARNING 252 | ); 253 | } 254 | } 255 | 256 | return $result; 257 | } 258 | 259 | protected function getDecoder(): Decoder 260 | { 261 | return $this->decoder; 262 | } 263 | 264 | protected function getSemanticValidator(): SemanticValidator 265 | { 266 | return $this->semanticValidator; 267 | } 268 | 269 | /** 270 | * @param \SPFLib\Semantic\OnlineIssue[] $issues 271 | * 272 | * @return \SPFLib\Semantic\OnlineIssue[] 273 | */ 274 | protected function filterLevel(array $issues, ?int $minimumLevel): array 275 | { 276 | if ($minimumLevel === null) { 277 | return $issues; 278 | } 279 | 280 | return array_values( 281 | array_filter( 282 | $issues, 283 | static function (OnlineIssue $issue) use ($minimumLevel): bool { 284 | return $issue->getLevel() >= $minimumLevel; 285 | } 286 | ) 287 | ); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Decoder.php: -------------------------------------------------------------------------------- 1 | dnsResolver = $dnsResolver ?: new StandardResolver(); 39 | $this->macroStringDecoder = $macroStringDecoder ?: MacroStringDecoder::getInstance(); 40 | } 41 | 42 | /** 43 | * Get the raw SPF TXT record associated to a domain. 44 | * 45 | * @throws \SPFLib\Exception\DNSResolutionException in case of DNS resolution errors 46 | * @throws \SPFLib\Exception\MultipleSPFRecordsException if the domain has more that 1 SPF record 47 | * 48 | * @return string returns an empty string if no SPF TXT record has been found 49 | * 50 | * @see https://tools.ietf.org/html/rfc7208#section-4.5 51 | */ 52 | public function getTXTRecordFromDomain(string $domain): string 53 | { 54 | $rawSpfRecords = []; 55 | $txtRecords = $this->getDNSResolver()->getTXTRecords($domain); 56 | foreach ($txtRecords as $txtRecord) { 57 | if (strcasecmp($txtRecord, Record::PREFIX) === 0 || stripos($txtRecord, Record::PREFIX . ' ') === 0) { 58 | $rawSpfRecords[] = $txtRecord; 59 | } 60 | } 61 | switch (count($rawSpfRecords)) { 62 | case 0: 63 | return ''; 64 | case 1: 65 | return $rawSpfRecords[0]; 66 | default: 67 | throw new Exception\MultipleSPFRecordsException($domain, $rawSpfRecords); 68 | } 69 | } 70 | 71 | /** 72 | * Extract the SPF record associated to a domain. 73 | * 74 | * @throws \SPFLib\Exception\DNSResolutionException in case of DNS resolution errors 75 | * @throws \SPFLib\Exception\MultipleSPFRecordsException if the domain has more that 1 SPF record 76 | * @throws \SPFLib\Exception\InvalidTermException if the SPF record contains invalid terms 77 | * @throws \SPFLib\Exception\InvalidMacroStringException if the SPF record contains a term with an invalid macro-string 78 | * 79 | * @return \SPFLib\Record|null return NULL if no SPF record has been found 80 | * 81 | * @see https://tools.ietf.org/html/rfc7208#section-4.5 82 | */ 83 | public function getRecordFromDomain(string $domain): ?Record 84 | { 85 | $txtRecord = $this->getTXTRecordFromDomain($domain); 86 | if ($txtRecord === '') { 87 | return null; 88 | } 89 | 90 | return $this->getRecordFromTXT($txtRecord); 91 | } 92 | 93 | /** 94 | * Parse a TXT record and extract the SPF data. 95 | * 96 | * @throws \SPFLib\Exception\InvalidTermException if the SPF record contains invalid terms 97 | * @throws \SPFLib\Exception\InvalidMacroStringException if the SPF record contains a term with an invalid macro-string 98 | * 99 | * @return \SPFLib\Record|null return NULL if $txtRecord is not an SPF record 100 | * 101 | * @see https://tools.ietf.org/html/rfc7208#section-4.5 102 | */ 103 | public function getRecordFromTXT(string $txtRecord): ?Record 104 | { 105 | $rawTerms = explode(' ', rtrim($txtRecord, ' ')); 106 | $version = array_shift($rawTerms); 107 | if (strcasecmp($version, Record::PREFIX) !== 0) { 108 | return null; 109 | } 110 | $record = new Record(); 111 | foreach ($rawTerms as $rawTerm) { 112 | if ($rawTerm !== '') { 113 | $record->addTerm($this->parseTerm($rawTerm)); 114 | } 115 | } 116 | 117 | return $record; 118 | } 119 | 120 | /** 121 | * Get the DNS resolver to be used. 122 | */ 123 | public function getDNSResolver(): Resolver 124 | { 125 | return $this->dnsResolver; 126 | } 127 | 128 | /** 129 | * Get the MacroString decoder to be used. 130 | */ 131 | public function getMacroStringDecoder(): MacroStringDecoder 132 | { 133 | return $this->macroStringDecoder; 134 | } 135 | 136 | /** 137 | * @throws \SPFLib\Exception\InvalidTermException 138 | * @throws \SPFLib\Exception\InvalidMacroStringException 139 | */ 140 | protected function parseTerm(string $rawTerm): Term 141 | { 142 | try { 143 | $rxQualifier = '(' . implode('|', [ 144 | preg_quote(Mechanism::QUALIFIER_PASS, '/'), 145 | preg_quote(Mechanism::QUALIFIER_FAIL, '/'), 146 | preg_quote(Mechanism::QUALIFIER_SOFTFAIL, '/'), 147 | preg_quote(Mechanism::QUALIFIER_NEUTRAL, '/'), 148 | ]) . ')'; 149 | $rxMechanism = '(' . implode('|', [ 150 | preg_quote(Mechanism\AllMechanism::HANDLE, '/'), 151 | preg_quote(Mechanism\IncludeMechanism::HANDLE, '/'), 152 | preg_quote(Mechanism\AMechanism::HANDLE, '/'), 153 | preg_quote(Mechanism\MxMechanism::HANDLE, '/'), 154 | preg_quote(Mechanism\PtrMechanism::HANDLE, '/'), 155 | preg_quote(Mechanism\Ip4Mechanism::HANDLE, '/'), 156 | preg_quote(Mechanism\Ip6Mechanism::HANDLE, '/'), 157 | preg_quote(Mechanism\ExistsMechanism::HANDLE, '/'), 158 | ]) . ')'; 159 | $rx = '/^' . implode('', [ 160 | "(?P{$rxQualifier})?", 161 | "(?P{$rxMechanism})", 162 | '(?P[:\/].*)?', 163 | ]) . '$/i'; 164 | $matches = null; 165 | if (preg_match($rx, $rawTerm, $matches)) { 166 | $mechanism = $this->parseMechanism($matches['handle'], $matches['qualifier'], $matches['data'] ?? ''); 167 | if ($mechanism !== null) { 168 | return $mechanism; 169 | } 170 | } else { 171 | $rxUnknownModifierName = '([A-Za-z][\w\-.]*)'; 172 | $rxModifier = '(' . implode('|', [ 173 | preg_quote(Modifier\RedirectModifier::HANDLE, '/'), 174 | preg_quote(Modifier\ExpModifier::HANDLE, '/'), 175 | $rxUnknownModifierName, 176 | ]) . ')'; 177 | $rx = '/^' . implode('', [ 178 | "(?P{$rxModifier})", 179 | '=', 180 | '(?P.*)?', 181 | ]) . '$/i'; 182 | if (preg_match($rx, $rawTerm, $matches)) { 183 | $modifier = $this->parseModifier($matches['handle'], $matches['data'] ?? ''); 184 | if ($modifier !== null) { 185 | return $modifier; 186 | } 187 | } 188 | } 189 | } catch (Exception\InvalidMacroStringException $x) { 190 | throw $x->setTerm($rawTerm); 191 | } 192 | 193 | throw new Exception\InvalidTermException($rawTerm); 194 | } 195 | 196 | /** 197 | * @throws \SPFLib\Exception\InvalidMacroStringException 198 | */ 199 | protected function parseMechanism(string $handle, string $qualifier, string $data): ?Mechanism 200 | { 201 | if ($qualifier === '') { 202 | $qualifier = Mechanism::QUALIFIER_PASS; 203 | } 204 | switch (strtolower($handle)) { 205 | case Mechanism\AllMechanism::HANDLE: 206 | return $this->parseAllMechanism($qualifier, $data); 207 | case Mechanism\IncludeMechanism::HANDLE: 208 | return $this->parseIncludeMechanism($qualifier, $data); 209 | case Mechanism\AMechanism::HANDLE: 210 | return $this->parseAMechanism($qualifier, $data); 211 | case Mechanism\MxMechanism::HANDLE: 212 | return $this->parseMxMechanism($qualifier, $data); 213 | case Mechanism\PtrMechanism::HANDLE: 214 | return $this->parsePtrMechanism($qualifier, $data); 215 | case Mechanism\Ip4Mechanism::HANDLE: 216 | return $this->parseIp4Mechanism($qualifier, $data); 217 | case Mechanism\Ip6Mechanism::HANDLE: 218 | return $this->parseIp6Mechanism($qualifier, $data); 219 | case Mechanism\ExistsMechanism::HANDLE: 220 | return $this->parseExistsMechanism($qualifier, $data); 221 | } 222 | } 223 | 224 | protected function parseAllMechanism(string $qualifier, string $data): ?Mechanism\AllMechanism 225 | { 226 | if ($data !== '') { 227 | return null; 228 | } 229 | 230 | return new Mechanism\AllMechanism($qualifier); 231 | } 232 | 233 | /** 234 | * @throws \SPFLib\Exception\InvalidMacroStringException 235 | */ 236 | protected function parseIncludeMechanism(string $qualifier, string $data): ?Mechanism\IncludeMechanism 237 | { 238 | if ($data === '' || $data[0] !== ':' || $data === ':') { 239 | return null; 240 | } 241 | 242 | return new Mechanism\IncludeMechanism( 243 | $qualifier, 244 | $this->getMacroStringDecoder()->decode(substr($data, 1)) 245 | ); 246 | } 247 | 248 | /** 249 | * @throws \SPFLib\Exception\InvalidMacroStringException 250 | */ 251 | protected function parseAMechanism(string $qualifier, string $data): ?Mechanism\AMechanism 252 | { 253 | $parsed = $this->extractDomainSpecDualCidr($data); 254 | if ($parsed === null) { 255 | return null; 256 | } 257 | 258 | return new Mechanism\AMechanism($qualifier, $parsed[0], $parsed[1], $parsed[2]); 259 | } 260 | 261 | /** 262 | * @throws \SPFLib\Exception\InvalidMacroStringException 263 | */ 264 | protected function parseMxMechanism(string $qualifier, string $data): ?Mechanism\MxMechanism 265 | { 266 | $parsed = $this->extractDomainSpecDualCidr($data); 267 | if ($parsed === null) { 268 | return null; 269 | } 270 | 271 | return new Mechanism\MxMechanism($qualifier, $parsed[0], $parsed[1], $parsed[2]); 272 | } 273 | 274 | /** 275 | * @throws \SPFLib\Exception\InvalidMacroStringException 276 | */ 277 | protected function parsePtrMechanism(string $qualifier, string $data): ?Mechanism\PtrMechanism 278 | { 279 | $domainSpec = null; 280 | if ($data !== '') { 281 | if ($data[0] !== ':' || $data === ':') { 282 | return null; 283 | } 284 | $domainSpec = $this->getMacroStringDecoder()->decode(substr($data, 1)); 285 | } 286 | 287 | return new Mechanism\PtrMechanism($qualifier, $domainSpec); 288 | } 289 | 290 | protected function parseIp4Mechanism(string $qualifier, string $data): ?Mechanism\Ip4Mechanism 291 | { 292 | if ($data === '' || $data[0] !== ':') { 293 | return null; 294 | } 295 | $data = substr($data, 1); 296 | $matches = null; 297 | if (preg_match('_^(.+)/(0|([1-9]\d?))$_', $data, $matches)) { 298 | $cidr = (int) $matches[2]; 299 | if ($cidr > 32) { 300 | return null; 301 | } 302 | $data = $matches[1]; 303 | } else { 304 | $cidr = null; 305 | } 306 | $ip = IPv4::fromString($data, false); 307 | if ($ip === null) { 308 | return null; 309 | } 310 | 311 | return new Mechanism\Ip4Mechanism($qualifier, $ip, $cidr); 312 | } 313 | 314 | protected function parseIp6Mechanism(string $qualifier, string $data): ?Mechanism\Ip6Mechanism 315 | { 316 | if ($data === '' || $data[0] !== ':') { 317 | return null; 318 | } 319 | $data = substr($data, 1); 320 | $matches = null; 321 | if (preg_match('_^(.+)/(0|([1-9]\d{0,2}))$_', $data, $matches)) { 322 | $cidr = (int) $matches[2]; 323 | if ($cidr > 128) { 324 | return null; 325 | } 326 | $data = $matches[1]; 327 | } else { 328 | $cidr = null; 329 | } 330 | $ip = IPv6::fromString($data, false, false); 331 | if ($ip === null) { 332 | return null; 333 | } 334 | 335 | return new Mechanism\Ip6Mechanism($qualifier, $ip, $cidr); 336 | } 337 | 338 | /** 339 | * @throws \SPFLib\Exception\InvalidMacroStringException 340 | */ 341 | protected function parseExistsMechanism(string $qualifier, string $data): ?Mechanism\ExistsMechanism 342 | { 343 | if ($data === '' || $data[0] !== ':' || $data === ':') { 344 | return null; 345 | } 346 | 347 | return new Mechanism\ExistsMechanism( 348 | $qualifier, 349 | $this->getMacroStringDecoder()->decode(substr($data, 1)) 350 | ); 351 | } 352 | 353 | /** 354 | * @throws \SPFLib\Exception\InvalidMacroStringException 355 | */ 356 | protected function extractDomainSpecDualCidr(string $data): ?array 357 | { 358 | $domainSpec = null; 359 | $ip4CidrLength = null; 360 | $ip6CidrLength = null; 361 | $matches = null; 362 | if (preg_match('_^:(.*?)((?://?\d+)*)$_', $data, $matches)) { 363 | $domainSpecString = $matches[1]; 364 | if ($domainSpecString === '') { 365 | return null; 366 | } 367 | $domainSpec = $this->getMacroStringDecoder()->decode($domainSpecString); 368 | $data = $matches[2]; 369 | } 370 | if ($data !== '') { 371 | $slashPosition = strpos($data, '/'); 372 | if ($slashPosition !== 0) { 373 | return null; 374 | } 375 | if ($slashPosition !== false) { 376 | $matches = null; 377 | $data = substr($data, $slashPosition); 378 | while ($data !== '') { 379 | if (!preg_match('_^/(/)?(0|([1-9]\d{0,2}))_', $data, $matches)) { 380 | return null; 381 | } 382 | $num = (int) $matches[2]; 383 | if ($matches[1] === '/') { 384 | if ($num > 128 || $ip6CidrLength !== null) { 385 | return null; 386 | } 387 | $ip6CidrLength = $num; 388 | } else { 389 | if ($num > 32 || $ip4CidrLength !== null) { 390 | return null; 391 | } 392 | $ip4CidrLength = $num; 393 | } 394 | $data = substr($data, strlen($matches[0])); 395 | } 396 | } 397 | } 398 | 399 | return [$domainSpec, $ip4CidrLength, $ip6CidrLength]; 400 | } 401 | 402 | /** 403 | * @throws \SPFLib\Exception\InvalidMacroStringException 404 | */ 405 | protected function parseModifier(string $handle, string $data): ?Modifier 406 | { 407 | try { 408 | $term = $this->parseTerm($data); 409 | } catch (Exception $soFarSoGood) { 410 | $term = null; 411 | } 412 | if ($term !== null && !$term instanceof Modifier\UnknownModifier) { 413 | throw new Exception\InvalidMacroStringException("{$handle}={$data}", 0, 'The modifier "{$handle}" as a value that indicates that the syntax is probably wrong'); 414 | } 415 | switch (strtolower($handle)) { 416 | case Modifier\RedirectModifier::HANDLE: 417 | return $this->parseRedirectModifier($data); 418 | case Modifier\ExpModifier::HANDLE: 419 | return $this->parseExpModifier($data); 420 | default: 421 | return $this->parseUnknownModifier($handle, $data); 422 | } 423 | } 424 | 425 | /** 426 | * @throws \SPFLib\Exception\InvalidMacroStringException 427 | */ 428 | protected function parseRedirectModifier(string $data): ?Modifier\RedirectModifier 429 | { 430 | if ($data === '') { 431 | return null; 432 | } 433 | 434 | return new Modifier\RedirectModifier( 435 | $this->getMacroStringDecoder()->decode($data) 436 | ); 437 | } 438 | 439 | /** 440 | * @throws \SPFLib\Exception\InvalidMacroStringException 441 | */ 442 | protected function parseExpModifier(string $data): ?Modifier\ExpModifier 443 | { 444 | if ($data === '') { 445 | return null; 446 | } 447 | 448 | return new Modifier\ExpModifier( 449 | $this->getMacroStringDecoder()->decode($data) 450 | ); 451 | } 452 | 453 | /** 454 | * @throws \SPFLib\Exception\InvalidMacroStringException 455 | */ 456 | protected function parseUnknownModifier(string $name, string $data): ?Modifier\UnknownModifier 457 | { 458 | return new Modifier\UnknownModifier( 459 | $name, 460 | $this->getMacroStringDecoder()->decode($data) 461 | ); 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /src/Checker.php: -------------------------------------------------------------------------------- 1 | dnsResolver = $dnsResolver ?: ($spfDecoder === null ? new StandardResolver() : $spfDecoder->getDNSResolver()); 79 | $this->spfDecoder = $spfDecoder ?: new Decoder($this->getDNSResolver()); 80 | $this->semanticValidator = $semanticValidator ?: new SemanticValidator(); 81 | $this->macroStringExpander = $macroStringExpander ?: new Expander(); 82 | $this->domainNameValidator = $domainNameValidator ?: new DomainNameValidator(); 83 | } 84 | 85 | /** 86 | * Check the the environment agains SPF records. 87 | * 88 | * @param \SPFLib\Check\Environment $environment the environment instance holding all the environment values 89 | * 90 | * @see https://tools.ietf.org/html/rfc7208#section-2.3 91 | */ 92 | public function check(Environment $environment, int $flags = self::FLAG_CHECK_HELODOMAIN | self::FLAG_CHECK_MAILFROADDRESS): Result 93 | { 94 | if ($environment->getClientIP() === null) { 95 | return Result::create(Result::CODE_NONE)->addMessage('The IP address of the sender SMTP client is not speciified'); 96 | } 97 | if ($flags & static::FLAG_CHECK_MAILFROADDRESS) { 98 | $result = $this->checkMailFrom($environment); 99 | } else { 100 | $result = null; 101 | } 102 | if ($flags & static::FLAG_CHECK_HELODOMAIN) { 103 | if ($result === null) { 104 | $result = $this->checkHeloDomain($environment); 105 | } else { 106 | switch ($result->getCode()) { 107 | case Result::CODE_PASS: 108 | case Result::CODE_FAIL: 109 | case Result::CODE_ERROR_TEMPORARY: 110 | case Result::CODE_ERROR_PERMANENT: 111 | break; 112 | default: 113 | $heloDomain = $environment->getHeloDomain(); 114 | if ($heloDomain === '' || strcasecmp($heloDomain, $environment->getMailFromDomain()) !== 0) { 115 | $result2 = $this->checkHeloDomain($environment); 116 | switch ($result2->getCode()) { 117 | case Result::CODE_NONE: 118 | break; 119 | default: 120 | $result = $result2; 121 | break; 122 | } 123 | } 124 | break; 125 | } 126 | } 127 | } 128 | if ($result === null) { 129 | return Result::create(Result::CODE_NONE)->addMessage('No check has been performed (as requested)'); 130 | } 131 | 132 | return $result; 133 | } 134 | 135 | protected function checkHeloDomain(Environment $environment): Result 136 | { 137 | return $this->startValidation($environment, $this->createHeloDomainCheckState($environment)); 138 | } 139 | 140 | protected function checkMailFrom(Environment $environment): Result 141 | { 142 | return $this->startValidation($environment, $this->createMailFromCheckState($environment)); 143 | } 144 | 145 | protected function startValidation(Environment $environment, State $state): Result 146 | { 147 | $domain = $state->getSenderDomain(); 148 | try { 149 | $domain = $this->getDomainNameValidator()->check($domain); 150 | } catch (Exception\InvalidDomainException $x) { 151 | return Result::create(Result::CODE_NONE)->addMessage($x->getMessage()); 152 | } 153 | try { 154 | return $this->validate($state, $domain); 155 | } catch (Exception\TooManyDNSLookupsException $x) { 156 | return Result::create(Result::CODE_ERROR_PERMANENT)->addMessage($x->getMessage()); 157 | } catch (Exception\TooManyDNSVoidLookupsException $x) { 158 | return Result::create(Result::CODE_ERROR_PERMANENT)->addMessage($x->getMessage()); 159 | } catch (Exception\DNSResolutionException $x) { 160 | return Result::create(Result::CODE_ERROR_TEMPORARY)->addMessage($x->getMessage()); 161 | } catch (Exception\IncludeMechanismException $x) { 162 | return Result::create($x->getFinalResultCode())->addMessages($x->getIncludeResult()->getMessages()); 163 | } catch (Exception\InvalidDomainException $x) { 164 | return $this->buildInvalidDomainResult($state, $x); 165 | } 166 | } 167 | 168 | /** 169 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 170 | * @throws \SPFLib\Exception\TooManyDNSVoidLookupsException 171 | * @throws \SPFLib\Exception\DNSResolutionException 172 | * @throws \SPFLib\Exception\IncludeMechanismException 173 | * @throws \SPFLib\Exception\InvalidDomainException 174 | */ 175 | protected function validate(State $state, string $domain): Result 176 | { 177 | try { 178 | $record = $this->getSPFDecoder()->getRecordFromDomain($domain); 179 | } catch (Exception\DNSResolutionException $x) { 180 | return Result::create(Result::CODE_ERROR_TEMPORARY)->addMessage($x->getMessage()); 181 | } catch (Exception $x) { 182 | return Result::create(Result::CODE_ERROR_PERMANENT)->addMessage($x->getMessage()); 183 | } 184 | if ($record === null) { 185 | return Result::create(Result::CODE_NONE)->addMessage("No SPF DNS record found for domain '{$domain}'"); 186 | } 187 | $issues = $this->getSemanticValidator()->validate($record, Issue::LEVEL_FATAL); 188 | if ($issues !== []) { 189 | $result = Result::create(Result::CODE_ERROR_PERMANENT); 190 | foreach ($issues as $issue) { 191 | $result->addMessage($issue->getDescription()); 192 | } 193 | 194 | return $result; 195 | } 196 | foreach ($record->getMechanisms() as $mechanism) { 197 | if ($this->matchMechanism($state, $domain, $mechanism)) { 198 | switch ($mechanism->getQualifier()) { 199 | case Mechanism::QUALIFIER_PASS: 200 | return Result::create(Result::CODE_PASS, $mechanism); 201 | case Mechanism::QUALIFIER_FAIL: 202 | return $this->buildFailResult($state, $domain, $record, $mechanism, Result::CODE_FAIL); 203 | case Mechanism::QUALIFIER_SOFTFAIL: 204 | return $this->buildFailResult($state, $domain, $record, $mechanism, Result::CODE_SOFTFAIL); 205 | case Mechanism::QUALIFIER_NEUTRAL: 206 | return Result::create(Result::CODE_NEUTRAL, $mechanism); 207 | } 208 | } 209 | } 210 | foreach ($record->getModifiers() as $modifier) { 211 | if ($modifier instanceof Modifier\RedirectModifier) { 212 | $state->countDNSLookup(); 213 | /** @see https://tools.ietf.org/html/rfc7208#section-6.1 */ 214 | $targetDomain = $this->expandDomainSpec($state, $domain, $modifier->getDomainSpec(), false); 215 | $result = $this->validate($state, $targetDomain); 216 | if ($result->getCode() === Result::CODE_NONE) { 217 | $result = Result::create(Result::CODE_ERROR_PERMANENT)->addMessage("The redirect SPF record didn't return a response code"); 218 | } 219 | 220 | return $result; 221 | } 222 | } 223 | 224 | // @see https://tools.ietf.org/html/rfc7208#section-4.7 225 | return Result::create(Result::CODE_NEUTRAL)->addMessage('No mechanism matched and no redirect modifier found.'); 226 | } 227 | 228 | protected function createHeloDomainCheckState(Environment $environment): State 229 | { 230 | return new State\HeloDomainState($environment, $this->getDNSResolver()); 231 | } 232 | 233 | protected function createMailFromCheckState(Environment $environment): State 234 | { 235 | return new State\MailFromState($environment, $this->getDNSResolver()); 236 | } 237 | 238 | protected function getDNSResolver(): Resolver 239 | { 240 | return $this->dnsResolver; 241 | } 242 | 243 | protected function getSPFDecoder(): Decoder 244 | { 245 | return $this->spfDecoder; 246 | } 247 | 248 | protected function getSemanticValidator(): SemanticValidator 249 | { 250 | return $this->semanticValidator; 251 | } 252 | 253 | protected function getMacroStringExpander(): Expander 254 | { 255 | return $this->macroStringExpander; 256 | } 257 | 258 | protected function getDomainNameValidator(): DomainNameValidator 259 | { 260 | return $this->domainNameValidator; 261 | } 262 | 263 | /** 264 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 265 | * @throws \SPFLib\Exception\DNSResolutionException 266 | * @throws \SPFLib\Exception\IncludeMechanismException 267 | * @throws \SPFLib\Exception\InvalidDomainException 268 | * @throws \SPFLib\Exception\TooManyDNSVoidLookupsException 269 | */ 270 | protected function matchMechanism(State $state, string $domain, Mechanism $mechanism): bool 271 | { 272 | if ($mechanism instanceof Mechanism\AllMechanism) { 273 | return $this->matchMechanismAll($state, $domain, $mechanism); 274 | } 275 | if ($mechanism instanceof Mechanism\IncludeMechanism) { 276 | return $this->matchMechanismInclude($state, $domain, $mechanism); 277 | } 278 | if ($mechanism instanceof Mechanism\AMechanism) { 279 | return $this->matchMechanismA($state, $domain, $mechanism); 280 | } 281 | if ($mechanism instanceof Mechanism\MxMechanism) { 282 | return $this->matchMechanismMx($state, $domain, $mechanism); 283 | } 284 | if ($mechanism instanceof Mechanism\PtrMechanism) { 285 | return $this->matchMechanismPtr($state, $domain, $mechanism); 286 | } 287 | if ($mechanism instanceof Mechanism\Ip4Mechanism) { 288 | return $this->matchMechanismIp($state, $domain, $mechanism); 289 | } 290 | if ($mechanism instanceof Mechanism\Ip6Mechanism) { 291 | return $this->matchMechanismIp($state, $domain, $mechanism); 292 | } 293 | if ($mechanism instanceof Mechanism\ExistsMechanism) { 294 | return $this->matchMechanismExists($state, $domain, $mechanism); 295 | } 296 | } 297 | 298 | /** 299 | * @see https://tools.ietf.org/html/rfc7208#section-5.1 300 | */ 301 | protected function matchMechanismAll(State $state, string $domain, Mechanism\AllMechanism $mechanism): bool 302 | { 303 | return true; 304 | } 305 | 306 | /** 307 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 308 | * @throws \SPFLib\Exception\DNSResolutionException 309 | * @throws \SPFLib\Exception\IncludeMechanismException 310 | * @throws \SPFLib\Exception\InvalidDomainException 311 | * 312 | * @see https://tools.ietf.org/html/rfc7208#section-5.2 313 | */ 314 | protected function matchMechanismInclude(State $state, string $domain, Mechanism\IncludeMechanism $mechanism): bool 315 | { 316 | $targetDomain = $this->expandDomainSpec($state, $domain, $mechanism->getDomainSpec(), false); 317 | $state->countDNSLookup(); 318 | $includeResult = $this->validate($state, $targetDomain); 319 | switch ($includeResult->getCode()) { 320 | case Result::CODE_PASS: 321 | return true; 322 | case Result::CODE_FAIL: 323 | case Result::CODE_SOFTFAIL: 324 | case Result::CODE_NEUTRAL: 325 | return false; 326 | case Result::CODE_ERROR_TEMPORARY: 327 | throw new Exception\IncludeMechanismException(Result::CODE_ERROR_TEMPORARY, $domain, $mechanism, $includeResult); 328 | case Result::CODE_NONE: 329 | case Result::CODE_ERROR_PERMANENT: 330 | throw new Exception\IncludeMechanismException(Result::CODE_ERROR_PERMANENT, $domain, $mechanism, $includeResult); 331 | } 332 | } 333 | 334 | /** 335 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 336 | * @throws \SPFLib\Exception\DNSResolutionException 337 | * @throws \SPFLib\Exception\InvalidDomainException 338 | * @throws \SPFLib\Exception\TooManyDNSVoidLookupsException 339 | * 340 | * @see https://tools.ietf.org/html/rfc7208#section-5.3 341 | */ 342 | protected function matchMechanismA(State $state, string $domain, Mechanism\AMechanism $mechanism): bool 343 | { 344 | $targetDomain = $this->expandDomainSpec($state, $domain, $mechanism->getDomainSpec(), true); 345 | $state->countDNSLookup(); 346 | 347 | return $state->matchDomainIPs($targetDomain, $mechanism->getIp4CidrLength(), $mechanism->getIp6CidrLength()); 348 | } 349 | 350 | /** 351 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 352 | * @throws \SPFLib\Exception\DNSResolutionException 353 | * @throws \SPFLib\Exception\InvalidDomainException 354 | * @throws \SPFLib\Exception\TooManyDNSVoidLookupsException 355 | * 356 | * @see https://tools.ietf.org/html/rfc7208#section-5.4 357 | */ 358 | protected function matchMechanismMx(State $state, string $domain, Mechanism\MxMechanism $mechanism): bool 359 | { 360 | $targetDomain = $this->expandDomainSpec($state, $domain, $mechanism->getDomainSpec(), true); 361 | $state->countDNSLookup(); 362 | $mxRecords = $this->getDNSResolver()->getMXRecords($targetDomain); 363 | if (count($mxRecords) > $state::MAX_DNS_LOOKUPS) { 364 | throw new Exception\TooManyDNSLookupsException($state::MAX_DNS_LOOKUPS); 365 | } 366 | foreach ($mxRecords as $mxRecord) { 367 | $mxRecordIP = Factory::addressFromString($mxRecord); 368 | if ($mxRecordIP !== null) { 369 | if ($state->matchIP($mxRecordIP, $mechanism->getIp4CidrLength(), $mechanism->getIp6CidrLength())) { 370 | return true; 371 | } 372 | } else { 373 | if ($state->matchDomainIPs($mxRecord, $mechanism->getIp4CidrLength(), $mechanism->getIp6CidrLength())) { 374 | return true; 375 | } 376 | } 377 | } 378 | 379 | return false; 380 | } 381 | 382 | /** 383 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 384 | * @throws \SPFLib\Exception\DNSResolutionException 385 | * @throws \SPFLib\Exception\InvalidDomainException 386 | * 387 | * @see https://tools.ietf.org/html/rfc7208#section-5.5 388 | */ 389 | protected function matchMechanismPtr(State $state, string $domain, Mechanism\PtrMechanism $mechanism): bool 390 | { 391 | $targetDomain = $this->expandDomainSpec($state, $domain, $mechanism->getDomainSpec(), true); 392 | 393 | return $state->getValidatedDomain($targetDomain, false) !== ''; 394 | } 395 | 396 | /** 397 | * @param \SPFLib\Term\Mechanism\Ip4Mechanism|\SPFLib\Term\Mechanism\Ip6Mechanism $mechanism 398 | * 399 | * @see https://tools.ietf.org/html/rfc7208#section-5.6 400 | */ 401 | protected function matchMechanismIp(State $state, string $domain, Mechanism $mechanism): bool 402 | { 403 | return $state->matchIP( 404 | $mechanism->getIP(), 405 | $mechanism instanceof Mechanism\Ip4Mechanism ? $mechanism->getCidrLength() : null, 406 | $mechanism instanceof Mechanism\Ip6Mechanism ? $mechanism->getCidrLength() : null 407 | ); 408 | } 409 | 410 | /** 411 | * @throws \SPFLib\Exception\TooManyDNSLookupsException 412 | * @throws \SPFLib\Exception\DNSResolutionException 413 | * @throws \SPFLib\Exception\InvalidDomainException 414 | * 415 | * @see https://tools.ietf.org/html/rfc7208#section-5.7 416 | */ 417 | protected function matchMechanismExists(State $state, string $domain, Mechanism\ExistsMechanism $mechanism): bool 418 | { 419 | $targetDomain = $this->expandDomainSpec($state, $domain, $mechanism->getDomainSpec(), false); 420 | $state->countDNSLookup(); 421 | foreach ($this->getDNSResolver()->getIPAddressesFromDomainName($targetDomain) as $ip) { 422 | if ($state->getEnvironment()->getClientIP() instanceof Address\IPv4) { 423 | if ($ip instanceof Address\IPv4) { 424 | return true; 425 | } 426 | } elseif ($state->getEnvironment()->getClientIP() instanceof Address\IPv6) { 427 | if ($ip instanceof Address\IPv4) { 428 | return true; 429 | } 430 | } 431 | } 432 | 433 | return false; 434 | } 435 | 436 | /** 437 | * @see https://tools.ietf.org/html/rfc7208#section-6.2 438 | */ 439 | protected function buildFailResult(State $state, string $domain, Record $record, Mechanism $matchedMechanism, string $failCode): Result 440 | { 441 | $result = Result::create($failCode, $matchedMechanism); 442 | foreach ($record->getModifiers() as $modifier) { 443 | if (!$modifier instanceof Modifier\ExpModifier) { 444 | break; 445 | } 446 | try { 447 | $targetDomain = $this->expandDomainSpec($state, $domain, $modifier->getDomainSpec(), false); 448 | $txtRecords = $this->getDNSResolver()->getTXTRecords($targetDomain); 449 | $numTxtRecords = count($txtRecords); 450 | switch ($numTxtRecords) { 451 | case 0: 452 | $result->addMessage("Failed to build the fail explanation string: no TXT records for '{$targetDomain}'"); 453 | break; 454 | case 1: 455 | $macroStringDecoder = $this->getSPFDecoder()->getMacroStringDecoder(); 456 | $macroString = $macroStringDecoder->decode($txtRecords[0], $macroStringDecoder::FLAG_EXP); 457 | $string = $this->getMacroStringExpander()->expand($macroString, $targetDomain, $state); 458 | if (!preg_match('/^[\x01-\x7f]*$/s', $string)) { 459 | $result->addMessage("Failed to build the fail explanation string: non US-ASCII chars found in '{$string}'"); 460 | } else { 461 | $result->setFailExplanation($string); 462 | } 463 | break; 464 | default: 465 | $result->addMessage("Failed to build the fail explanation string: more that one TXT records (exactly {$numTxtRecords}) for '{$targetDomain}'"); 466 | break; 467 | } 468 | } catch (Throwable $x) { 469 | $result->addMessage("Failed to build the fail explanation string: {$x->getMessage()}."); 470 | } 471 | break; 472 | } 473 | 474 | return $result; 475 | } 476 | 477 | /** 478 | * @throws \SPFLib\Exception\InvalidDomainException 479 | */ 480 | protected function expandDomainSpec(State $state, string $currentDomain, MacroString $macroString, bool $useCurrentDomainIfEmpty): string 481 | { 482 | if ($useCurrentDomainIfEmpty && $macroString->isEmpty()) { 483 | return $currentDomain; 484 | } 485 | $targetDomain = $this->getMacroStringExpander()->expand($macroString, $currentDomain, $state); 486 | 487 | return $this->getDomainNameValidator()->check($targetDomain, $macroString); 488 | } 489 | 490 | protected function buildInvalidDomainResult(State $state, Exception\InvalidDomainException $exception): Result 491 | { 492 | $code = Result::CODE_ERROR_PERMANENT; 493 | $domainSpec = $exception->getDerivedFrom(); 494 | if ($domainSpec !== null) { 495 | foreach ($domainSpec->getChunks() as $chunk) { 496 | if ($chunk instanceof Placeholder) { 497 | $code = Result::CODE_FAIL; 498 | break; 499 | } 500 | } 501 | } 502 | 503 | return Result::create($code)->addMessage($exception->getMessage()); 504 | } 505 | } 506 | --------------------------------------------------------------------------------