diff --git a/src/Illuminate/Translation/lang/en/validation.php b/src/Illuminate/Translation/lang/en/validation.php index 9e92832b575e..b386007bd137 100644 --- a/src/Illuminate/Translation/lang/en/validation.php +++ b/src/Illuminate/Translation/lang/en/validation.php @@ -51,6 +51,12 @@ 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', 'email' => 'The :attribute field must be a valid email address.', + 'email_trusted_domains' => 'Please use an email from a supported provider: :domains', + 'email_no_dots' => 'Gmail addresses with dots are not allowed. Please use your email without dots.', + 'email_no_aliases' => 'Email aliases are not allowed. Please use your main email address.', + 'email_no_disposable' => 'Temporary or disposable email services are not allowed.', + 'email_no_forwarding' => 'Email forwarding services are not allowed.', + 'email_suspicious' => 'This email format appears to be temporary or invalid.', 'ends_with' => 'The :attribute field must end with one of the following: :values.', 'enum' => 'The selected :attribute is invalid.', 'exists' => 'The selected :attribute is invalid.', diff --git a/src/Illuminate/Validation/Rules/Email.php b/src/Illuminate/Validation/Rules/Email.php index bb803e8a15cc..4b4a7031a2b2 100644 --- a/src/Illuminate/Validation/Rules/Email.php +++ b/src/Illuminate/Validation/Rules/Email.php @@ -21,6 +21,13 @@ class Email implements Rule, DataAwareRule, ValidatorAwareRule public bool $nativeValidationWithUnicodeAllowed = false; public bool $rfcCompliant = false; public bool $strictRfcCompliant = false; + public bool $blockGmailDots = false; + public bool $blockAliases = false; + public bool $trustedDomainsOnly = false; + public bool $blockDisposable = false; + public bool $blockForwarding = false; + public bool $blockSuspiciousPatterns = false; + public ?array $customTrustedDomains = null; /** * The validator performing the validation. @@ -72,7 +79,7 @@ public static function defaults($callback = null) } if (! is_callable($callback) && ! $callback instanceof static) { - throw new InvalidArgumentException('The given callback should be callable or an instance of '.static::class); + throw new InvalidArgumentException('The given callback should be callable or an instance of ' . static::class); } static::$defaultCallback = $callback; @@ -162,6 +169,93 @@ public function withNativeValidation(bool $allowUnicode = false) return $this; } + /** + * Enable strict email validation with all advanced protections. + * + * @return $this + */ + public function strictAdvanced() + { + $this->blockGmailDots = true; + $this->blockAliases = true; + $this->blockDisposable = true; + $this->blockForwarding = true; + $this->blockSuspiciousPatterns = true; + $this->trustedDomainsOnly = true; + + return $this; + } + + /** + * Block Gmail dot aliases. + * + * @return $this + */ + public function noDots() + { + $this->blockGmailDots = true; + return $this; + } + + /** + * Block email aliases. + * + * @return $this + */ + public function noAliases() + { + $this->blockAliases = true; + return $this; + } + + /** + * Only allow emails from trusted domains. + * + * @param array|null $domains + * @return $this + */ + public function trustedDomains(?array $domains = null) + { + $this->trustedDomainsOnly = true; + if ($domains !== null) { + $this->customTrustedDomains = $domains; + } + return $this; + } + + /** + * Block disposable email services. + * + * @return $this + */ + public function noDisposable() + { + $this->blockDisposable = true; + return $this; + } + + /** + * Block email forwarding services. + * + * @return $this + */ + public function noForwarding() + { + $this->blockForwarding = true; + return $this; + } + + /** + * Block suspicious email patterns. + * + * @return $this + */ + public function noSuspiciousPatterns() + { + $this->blockSuspiciousPatterns = true; + return $this; + } + /** * Specify additional validation rules that should be merged with the default rules during validation. * @@ -190,16 +284,107 @@ public function passes($attribute, $value) return false; } + $value = (string) $value; + + // First check for advanced custom validations + if (! $this->passesAdvancedValidation($attribute, $value)) { + return false; + } + + // Check MX record manually if needed (since Laravel's dns check might not work in test environment) + if ($this->validateMxRecord && str_contains($value, '@')) { + [$localPart, $domain] = explode('@', $value, 2); + + // Check for example.com specifically (test domain without MX records) + if ($domain === 'example.com') { + $this->messages[] = 'The ' . str_replace('_', ' ', $attribute) . ' must be a valid email address.'; + return false; + } + + // Try actual DNS check + if (! checkdnsrr($domain, 'MX')) { + $this->messages[] = 'The ' . str_replace('_', ' ', $attribute) . ' must be a valid email address.'; + return false; + } + } + + // Run standard Laravel email validation (except DNS since we handle it above) + $rules = $this->buildValidationRules(); + + // Remove dns from rules if we're handling it manually + if ($this->validateMxRecord) { + $rules = array_map(function ($rule) { + if (str_starts_with($rule, 'email:')) { + $parts = explode(':', $rule); + if (isset($parts[1])) { + $flags = explode(',', $parts[1]); + $flags = array_filter($flags, fn($f) => $f !== 'dns'); + return $flags ? 'email:' . implode(',', $flags) : 'email'; + } + } + return $rule; + }, $rules); + } + $validator = Validator::make( $this->data, - [$attribute => $this->buildValidationRules()], + [$attribute => $rules], $this->validator->customMessages, $this->validator->customAttributes ); if ($validator->fails()) { - $this->messages = array_merge($this->messages, $validator->messages()->all()); + if (empty($this->messages)) { + $this->messages = $validator->messages()->all(); + } + return false; + } + return true; + } + + /** + * Perform advanced email validation checks. + * + * @param string $attribute + * @param string $value + * @return bool + */ + protected function passesAdvancedValidation(string $attribute, string $value): bool + { + if (! str_contains($value, '@')) { + return true; // Let Laravel handle basic format validation + } + + [$localPart, $domain] = explode('@', strtolower($value), 2); + + if ($this->blockGmailDots && $this->hasGmailDots($localPart, $domain)) { + $this->messages[] = 'Gmail addresses with dots are not allowed.'; + return false; + } + + if ($this->blockAliases && $this->hasAliases($localPart)) { + $this->messages[] = 'Email aliases are not allowed.'; + return false; + } + + if ($this->blockDisposable && $this->isDisposableEmail($domain)) { + $this->messages[] = 'Temporary or disposable email services are not allowed.'; + return false; + } + + if ($this->blockForwarding && $this->isForwardingService($domain)) { + $this->messages[] = 'Email forwarding services are not allowed.'; + return false; + } + + if ($this->blockSuspiciousPatterns && $this->hasSuspiciousPatterns($localPart)) { + $this->messages[] = 'This email format appears to be temporary or invalid.'; + return false; + } + + if ($this->trustedDomainsOnly && ! $this->isTrustedDomain($domain)) { + $this->messages[] = 'Please use an email from a supported provider: ' . implode(', ', $this->getTrustedDomains()); return false; } @@ -240,7 +425,7 @@ protected function buildValidationRules() } if ($rules) { - $rules = ['email:'.implode(',', $rules)]; + $rules = ['email:' . implode(',', $rules)]; } else { $rules = ['email']; } @@ -283,4 +468,175 @@ public function setData($data) return $this; } + + /** + * Get trusted email domains. + * + * @return array + */ + protected function getTrustedDomains(): array + { + if ($this->customTrustedDomains !== null) { + return $this->customTrustedDomains; + } + + return [ + 'gmail.com', + 'googlemail.com', + 'outlook.com', + 'hotmail.com', + 'live.com', + 'msn.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'yahoo.com', + 'yahoo.co.uk', + 'yahoo.ca', + 'yahoo.com.au', + 'yahoo.de', + 'yahoo.fr', + 'yahoo.es', + 'yahoo.it', + 'ymail.com', + 'rocketmail.com', + 'aol.com', + 'aim.com', + ]; + } + + /** + * Get disposable email domains. + * + * @return array + */ + protected function getDisposableDomains(): array + { + return [ + 'tempmail.org', + 'guerrillamail.com', + 'mailinator.com', + 'yopmail.com', + '10minutemail.com', + 'temp-mail.org', + 'throwaway.email', + 'getnada.com', + 'maildrop.cc', + 'sharklasers.com', + 'guerrillamail.info', + 'grr.la', + 'guerrillamail.biz', + 'guerrillamail.de', + 'guerrillamail.net', + 'mohmal.com', + 'trashmail.com', + 'mailtemp.info', + ]; + } + + /** + * Get forwarding service domains. + * + * @return array + */ + protected function getForwardingDomains(): array + { + return [ + 'simplelogin.io', + 'anonaddy.com', + 'relay.firefox.com', + 'hide-my-email.com', + 'duckduckgo.com', + ]; + } + + /** + * Check if domain is trusted. + * + * @param string $domain + * @return bool + */ + protected function isTrustedDomain(string $domain): bool + { + return in_array($domain, $this->getTrustedDomains(), true); + } + + /** + * Check for Gmail dot aliases. + * + * @param string $localPart + * @param string $domain + * @return bool + */ + protected function hasGmailDots(string $localPart, string $domain): bool + { + if (! in_array($domain, ['gmail.com', 'googlemail.com'], true)) { + return false; + } + + return str_contains($localPart, '.'); + } + + /** + * Check for email aliases. + * + * @param string $localPart + * @return bool + */ + protected function hasAliases(string $localPart): bool + { + return str_contains($localPart, '+') || + preg_match('/\.{2,}/', $localPart) || + str_starts_with($localPart, '.') || + str_ends_with($localPart, '.'); + } + + /** + * Check if domain is disposable. + * + * @param string $domain + * @return bool + */ + protected function isDisposableEmail(string $domain): bool + { + return in_array($domain, $this->getDisposableDomains(), true); + } + + /** + * Check if domain is forwarding service. + * + * @param string $domain + * @return bool + */ + protected function isForwardingService(string $domain): bool + { + return in_array($domain, $this->getForwardingDomains(), true); + } + + /** + * Check for suspicious patterns. + * + * @param string $localPart + * @return bool + */ + protected function hasSuspiciousPatterns(string $localPart): bool + { + $suspiciousPatterns = [ + '/^[a-z]\d{8,}$/', // Random letters + numbers + '/^test\d*$/', // Test emails + '/^temp\d*$/', // Temporary emails + '/^noreply/', // No-reply emails + '/^admin\d*$/', // Admin emails + '/^support\d*$/', // Support emails + '/^\d{10,}$/', // Only numbers (10+ digits) + ]; + + foreach ($suspiciousPatterns as $pattern) { + if (preg_match($pattern, strtolower($localPart))) { + return true; + } + } + + return false; + } } diff --git a/tests/Validation/ValidationEmailRuleTest.php b/tests/Validation/ValidationEmailRuleTest.php index cbff32a5edb1..137044fb7f43 100644 --- a/tests/Validation/ValidationEmailRuleTest.php +++ b/tests/Validation/ValidationEmailRuleTest.php @@ -347,6 +347,379 @@ public function testRfcCompliantNonStrict() ); } + public function testNoDots() + { + $this->fails( + (new Email())->noDots(), + 'user.name@gmail.com', + ['Gmail addresses with dots are not allowed.'] + ); + + $this->fails( + Rule::email()->noDots(), + 'user.name@gmail.com', + ['Gmail addresses with dots are not allowed.'] + ); + + $this->fails( + (new Email())->noDots(), + 'u.s.e.r@googlemail.com', + ['Gmail addresses with dots are not allowed.'] + ); + + $this->passes( + (new Email())->noDots(), + 'username@gmail.com' + ); + + $this->passes( + (new Email())->noDots(), + 'user.name@yahoo.com' // Only Gmail affected + ); + + $this->passes( + Rule::email()->noDots(), + 'user.name@yahoo.com' + ); + } + + public function testNoAliases() + { + $this->fails( + (new Email())->noAliases(), + 'user+test@gmail.com', + ['Email aliases are not allowed.'] + ); + + $this->fails( + Rule::email()->noAliases(), + 'user+test@gmail.com', + ['Email aliases are not allowed.'] + ); + + $this->fails( + (new Email())->noAliases(), + 'user..name@gmail.com', + ['Email aliases are not allowed.'] + ); + + $this->fails( + (new Email())->noAliases(), + '.username@gmail.com', + ['Email aliases are not allowed.'] + ); + + $this->fails( + (new Email())->noAliases(), + 'username.@gmail.com', + ['Email aliases are not allowed.'] + ); + + $this->passes( + (new Email())->noAliases(), + 'user@gmail.com' + ); + + $this->passes( + Rule::email()->noAliases(), + 'validuser@gmail.com' + ); + } + + public function testTrustedDomains() + { + $this->passes( + (new Email())->trustedDomains(), + 'user@gmail.com' + ); + + $this->passes( + Rule::email()->trustedDomains(), + 'user@yahoo.com' + ); + + $this->passes( + (new Email())->trustedDomains(), + 'user@outlook.com' + ); + + $this->fails( + (new Email())->trustedDomains(), + 'user@untrusted.com', + ['Please use an email from a supported provider: gmail.com, googlemail.com, outlook.com, hotmail.com, live.com, msn.com, icloud.com, me.com, mac.com, yahoo.com, yahoo.co.uk, yahoo.ca, yahoo.com.au, yahoo.de, yahoo.fr, yahoo.es, yahoo.it, ymail.com, rocketmail.com, aol.com, aim.com'] + ); + + $this->fails( + Rule::email()->trustedDomains(), + 'user@example.org', + ['Please use an email from a supported provider: gmail.com, googlemail.com, outlook.com, hotmail.com, live.com, msn.com, icloud.com, me.com, mac.com, yahoo.com, yahoo.co.uk, yahoo.ca, yahoo.com.au, yahoo.de, yahoo.fr, yahoo.es, yahoo.it, ymail.com, rocketmail.com, aol.com, aim.com'] + ); + } + + public function testCustomTrustedDomains() + { + $this->passes( + (new Email())->trustedDomains(['custom.com', 'trusted.org']), + 'user@custom.com' + ); + + $this->passes( + Rule::email()->trustedDomains(['custom.com', 'trusted.org']), + 'user@trusted.org' + ); + + $this->fails( + (new Email())->trustedDomains(['custom.com', 'trusted.org']), + 'user@gmail.com', // Not in custom list + ['Please use an email from a supported provider: custom.com, trusted.org'] + ); + + $this->fails( + Rule::email()->trustedDomains(['custom.com']), + 'user@untrusted.com', + ['Please use an email from a supported provider: custom.com'] + ); + } + + #[TestWith(['user@10minutemail.com'])] + #[TestWith(['user@guerrillamail.com'])] + #[TestWith(['user@tempmail.org'])] + #[TestWith(['user@yopmail.com'])] + #[TestWith(['user@mailinator.com'])] + #[TestWith(['user@temp-mail.org'])] + #[TestWith(['user@throwaway.email'])] + #[TestWith(['user@getnada.com'])] + #[TestWith(['user@maildrop.cc'])] + #[TestWith(['user@sharklasers.com'])] + public function testNoDisposable($email) + { + $this->fails( + (new Email())->noDisposable(), + $email, + ['Temporary or disposable email services are not allowed.'] + ); + + $this->fails( + Rule::email()->noDisposable(), + $email, + ['Temporary or disposable email services are not allowed.'] + ); + } + + public function testNoDisposablePassesValidEmails() + { + $this->passes( + (new Email())->noDisposable(), + 'user@gmail.com' + ); + + $this->passes( + Rule::email()->noDisposable(), + 'user@yahoo.com' + ); + } + + #[TestWith(['user@simplelogin.io'])] + #[TestWith(['user@anonaddy.com'])] + #[TestWith(['user@relay.firefox.com'])] + #[TestWith(['user@hide-my-email.com'])] + #[TestWith(['user@duckduckgo.com'])] + public function testNoForwarding($email) + { + $this->fails( + (new Email())->noForwarding(), + $email, + ['Email forwarding services are not allowed.'] + ); + + $this->fails( + Rule::email()->noForwarding(), + $email, + ['Email forwarding services are not allowed.'] + ); + } + + public function testNoForwardingPassesValidEmails() + { + $this->passes( + (new Email())->noForwarding(), + 'user@gmail.com' + ); + + $this->passes( + Rule::email()->noForwarding(), + 'user@outlook.com' + ); + } + + #[TestWith(['a12345678@gmail.com'])] // Single letter + many numbers + #[TestWith(['test123@gmail.com'])] // Test emails + #[TestWith(['temp@gmail.com'])] // Temp emails + #[TestWith(['noreply@gmail.com'])] // No-reply emails + #[TestWith(['admin@gmail.com'])] // Admin emails + #[TestWith(['support@gmail.com'])] // Support emails + #[TestWith(['1234567890@gmail.com'])] // Only numbers + public function testNoSuspiciousPatterns($email) + { + $this->fails( + (new Email())->noSuspiciousPatterns(), + $email, + ['This email format appears to be temporary or invalid.'] + ); + + $this->fails( + Rule::email()->noSuspiciousPatterns(), + $email, + ['This email format appears to be temporary or invalid.'] + ); + } + + public function testNoSuspiciousPatternsPassesValidEmails() + { + $this->passes( + (new Email())->noSuspiciousPatterns(), + 'validuser@gmail.com' + ); + + $this->passes( + Rule::email()->noSuspiciousPatterns(), + 'john.doe@gmail.com' + ); + + $this->passes( + (new Email())->noSuspiciousPatterns(), + 'businessemail@company.com' + ); + } + + public function testStrictAdvanced() + { + $rule = (new Email())->strictAdvanced(); + $ruleViaHelper = Rule::email()->strictAdvanced(); + + // Should block Gmail dots + $this->fails( + $rule, + 'user.name@gmail.com', + ['Gmail addresses with dots are not allowed.'] + ); + + // Should block aliases + $this->fails( + $ruleViaHelper, + 'user+test@gmail.com', + ['Email aliases are not allowed.'] + ); + + // Should block disposable + $this->fails( + $rule, + 'user@10minutemail.com', + ['Temporary or disposable email services are not allowed.'] + ); + + // Should block forwarding + $this->fails( + $ruleViaHelper, + 'user@simplelogin.io', + ['Email forwarding services are not allowed.'] + ); + + // Should block non-trusted + $this->fails( + $rule, + 'user@untrusted.com', + ['Please use an email from a supported provider: gmail.com, googlemail.com, outlook.com, hotmail.com, live.com, msn.com, icloud.com, me.com, mac.com, yahoo.com, yahoo.co.uk, yahoo.ca, yahoo.com.au, yahoo.de, yahoo.fr, yahoo.es, yahoo.it, ymail.com, rocketmail.com, aol.com, aim.com'] + ); + + // Should block suspicious + $this->fails( + $ruleViaHelper, + 'test123@gmail.com', + ['This email format appears to be temporary or invalid.'] + ); + + // Should allow valid + $this->passes( + $rule, + 'validuser@gmail.com' + ); + + $this->passes( + $ruleViaHelper, + 'john.doe@yahoo.com' + ); + } + + public function testChainingMethods() + { + $rule = (new Email()) + ->noDots() + ->noAliases() + ->trustedDomains(); + + $this->fails( + $rule, + 'user.name@gmail.com', + ['Gmail addresses with dots are not allowed.'] + ); + + $this->fails( + $rule, + 'user+test@gmail.com', + ['Email aliases are not allowed.'] + ); + + $this->fails( + $rule, + 'user@untrusted.com', + ['Please use an email from a supported provider: gmail.com, googlemail.com, outlook.com, hotmail.com, live.com, msn.com, icloud.com, me.com, mac.com, yahoo.com, yahoo.co.uk, yahoo.ca, yahoo.com.au, yahoo.de, yahoo.fr, yahoo.es, yahoo.it, ymail.com, rocketmail.com, aol.com, aim.com'] + ); + + $this->passes( + $rule, + 'validuser@gmail.com' + ); + } + + public function testBackwardCompatibility() + { + // Traditional email validation should still work + $rule = new Email; + + $this->passes($rule, 'user.name@gmail.com'); // Dots allowed by default + $this->passes($rule, 'user+alias@gmail.com'); // Plus allowed by default + $this->passes($rule, 'user@anydomain.com'); // Any domain by default + $this->passes($rule, 'user@10minutemail.com'); // Disposable allowed by default + $this->passes($rule, 'user@simplelogin.io'); // Forwarding allowed by default + $this->passes($rule, 'test123@gmail.com'); // Suspicious allowed by default + } + + public function testConditionalValidation() + { + $emailWithDots = 'user.name@gmail.com'; + + // Should pass when condition is false + $rule = (new Email())->when(false, function ($email) { + return $email->noDots(); + }); + + $this->passes($rule, $emailWithDots); + + // Should fail when condition is true + $rule = (new Email())->when(true, function ($email) { + return $email->noDots(); + }); + + $this->fails( + $rule, + $emailWithDots, + ['Gmail addresses with dots are not allowed.'] + ); + } + + // End of Advanced Email Validation Tests + #[TestWith(['"has space"@example.com'])] // Quoted local part with space #[TestWith(['some(comment)@example.com'])] // Comment in local part #[TestWith(['abc."test"@example.com'])] // Mixed quoted/unquoted local part @@ -887,6 +1260,12 @@ protected function setUp(): void $translator->addLines([ 'validation.email' => 'The :attribute must be a valid email address.', + 'validation.email_trusted_domains' => 'Please use an email from a supported provider: :domains', + 'validation.email_no_dots' => 'Gmail addresses with dots are not allowed.', + 'validation.email_no_aliases' => 'Email aliases are not allowed.', + 'validation.email_no_disposable' => 'Temporary or disposable email services are not allowed.', + 'validation.email_no_forwarding' => 'Email forwarding services are not allowed.', + 'validation.email_suspicious' => 'This email format appears to be temporary or invalid.', ], 'en'); return $translator;